Compare commits

..
17 Commits
Author SHA1 Message Date
Peter Steinberger c48751a99c chore: sync plugin versions for 2026.1.22 2026-01-23 08:18:55 +00:00
Peter Steinberger 86e0916fa3 fix: allow windows spawn in test parallel 2026-01-23 07:52:04 +00:00
Peter Steinberger 0c7e649676 docs: fix 2026.1.21 changelog placement 2026-01-23 07:51:40 +00:00
Peter Steinberger 45ce07a098 test: split vitest into unit and gateway 2026-01-23 07:34:57 +00:00
Peter Steinberger aed8dc1ade test: consolidate pi-tools shards 2026-01-23 07:34:57 +00:00
Peter Steinberger 86a341be62 test: speed up history and cron suites 2026-01-23 07:34:57 +00:00
Ian Hildebrand ff78e9a564 fix: support direct token and provider in auth apply commands (#1485) 2026-01-23 07:27:52 +00:00
Peter Steinberger 60a60779d7 test: streamline slow suites 2026-01-23 07:26:19 +00:00
Peter Steinberger 32da00cb2f docs: note vitest worker cap 2026-01-23 07:26:19 +00:00
Peter Steinberger 0420f2804c fix: log config update in copilot auth 2026-01-23 07:23:52 +00:00
Hiren PatelandPeter Steinberger 4de660bec6 [AI Assisted] Usage: add Google Antigravity usage tracking (#1490)
* Usage: add Google Antigravity usage tracking

- Add dedicated fetcher for google-antigravity provider
- Fetch credits and per-model quotas from Cloud Code API
- Report individual model IDs sorted by usage (top 10)
- Include comprehensive debug logging with [antigravity] prefix

* fix: refine antigravity usage tracking (#1490) (thanks @patelhiren)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 07:17:59 +00:00
Peter Steinberger 58f638463f fix: stop gateway before uninstall 2026-01-23 07:17:42 +00:00
Peter Steinberger f1afc722da Revert "fix: improve GitHub Copilot integration"
This reverts commit 21a9b3b66f.
2026-01-23 07:14:00 +00:00
Peter Steinberger bc75d58e9e Revert "fix: set Copilot user agent header"
This reverts commit cfcc4548bb.
2026-01-23 07:14:00 +00:00
Peter Steinberger 2efd265697 Revert "fix: treat copilot oauth tokens as non-expiring"
This reverts commit 35228ecae9.
2026-01-23 07:14:00 +00:00
Peter Steinberger 9c1f1476bc docs: fix Lobster changelog placement 2026-01-23 07:12:13 +00:00
Peter Steinberger e8352c8d21 fix: stabilize cron log wait 2026-01-23 07:11:01 +00:00
50 changed files with 1831 additions and 1284 deletions

No files matched your search

+1
View File
@@ -51,6 +51,7 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + what’s covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
+15 -16
View File
@@ -5,25 +5,11 @@ Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
@@ -32,6 +18,7 @@ Docs: https://docs.clawd.bot
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Gateway: stop the service before uninstalling and fail if it remains loaded.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
@@ -50,7 +37,6 @@ Docs: https://docs.clawd.bot
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 2026.1.21-2
@@ -61,6 +47,8 @@ Docs: https://docs.clawd.bot
## 2026.1.21
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
@@ -70,13 +58,24 @@ Docs: https://docs.clawd.bot
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
+5 -9
View File
@@ -16,9 +16,9 @@ provider in two different ways.
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token and use it directly
against the Copilot API. This is the **default** and simplest path because it
does not require VS Code. Enterprise domains are supported.
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
domain (for example `company.ghe.com`).
### Optional flags
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and uses it directly
for Copilot API calls.
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
for GitHub Enterprise.
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.20-2",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
+1 -1
View File
@@ -111,7 +111,7 @@
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift",
"format:fix": "oxfmt --write src test",
"test": "vitest run",
"test": "node scripts/test-parallel.mjs",
"test:watch": "vitest",
"test:ui": "pnpm --dir ui test",
"test:force": "node --import tsx scripts/test-force.ts",
+43
View File
@@ -0,0 +1,43 @@
import { spawn } from "node:child_process";
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const runs = [
{
name: "unit",
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
},
{
name: "gateway",
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
},
];
const children = new Set();
const run = (entry) =>
new Promise((resolve) => {
const child = spawn(pnpm, entry.args, {
stdio: "inherit",
env: { ...process.env, VITEST_GROUP: entry.name },
shell: process.platform === "win32",
});
children.add(child);
child.on("exit", (code, signal) => {
children.delete(child);
resolve(code ?? (signal ? 1 : 0));
});
});
const shutdown = (signal) => {
for (const child of children) {
child.kill(signal);
}
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const codes = await Promise.all(runs.map(run));
const failed = codes.find((code) => code !== 0);
process.exit(failed ?? 0);
-70
View File
@@ -1,70 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
vi.mock("@mariozechner/pi-ai", () => ({
getOAuthApiKey: vi.fn(() => {
throw new Error("refresh should not be called");
}),
}));
describe("auth-profiles (github-copilot)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tempDir: string | null = null;
afterEach(async () => {
vi.unstubAllGlobals();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
});
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
};
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
const loaded = ensureAuthProfileStore();
const resolved = await resolveApiKeyForProfile({
store: loaded,
profileId: "github-copilot:github",
});
expect(resolved?.apiKey).toBe("gh-token");
});
});
-23
View File
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") return null;
if (
cred.provider === "github-copilot" &&
(!Number.isFinite(cred.expires) || cred.expires <= 0)
) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
-1
View File
@@ -19,7 +19,6 @@ export type TokenCredential = {
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
enterpriseUrl?: string;
email?: string;
};
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -52,6 +51,16 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const agentDir = path.join(home, "agent-default-base-url");
@@ -62,55 +71,48 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("uses enterprise URL from auth profiles to derive base URL", async () => {
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
try {
vi.resetModules();
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
},
null,
2,
),
);
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
await ensureClawdbotModelsJson({ models: { providers: {} } });
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "copilot-token" }),
);
} finally {
// no-op
process.env.COPILOT_GITHUB_TOKEN = previous;
process.env.GH_TOKEN = previousGh;
process.env.GITHUB_TOKEN = previousGithub;
}
});
});
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -44,7 +43,7 @@ describe("models-config", () => {
process.env.HOME = previousHome;
});
it("uses default baseUrl when env token is present", async () => {
it("falls back to default baseUrl when token exchange fails", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
@@ -52,6 +51,11 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -63,13 +67,13 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("normalizes enterprise URL when deriving base URL", async () => {
it("uses agentDir override auth profiles for copilot injection", async () => {
await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
@@ -90,12 +94,9 @@ describe("models-config", () => {
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
type: "token",
provider: "github-copilot",
refresh: "gh-profile-token",
access: "gh-profile-token",
expires: 0,
enterpriseUrl: "https://company.ghe.com/",
token: "gh-profile-token",
},
},
},
@@ -104,6 +105,16 @@ describe("models-config", () => {
),
);
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
@@ -113,9 +124,7 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
+20 -9
View File
@@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
normalizeGithubCopilotDomain,
resolveGithubCopilotBaseUrl,
} from "../providers/github-copilot-utils.js";
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import {
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
if (!hasProfile && !githubToken) return null;
let enterpriseDomain: string | null = null;
if (hasProfile) {
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
}
}
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -81,16 +80,25 @@ describe("models-config", () => {
),
);
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "alpha-token" }),
);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
@@ -109,6 +117,16 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
@@ -16,13 +16,15 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
runEmbeddedAttemptMock.mockReset();
beforeAll(async () => {
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
});
beforeEach(() => {
vi.useRealTimers();
runEmbeddedAttemptMock.mockReset();
});
const baseUsage = {
input: 0,
output: 0,
+7
View File
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
);
}
} else if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
+3 -18
View File
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js";
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
if (model.provider === "github-copilot") {
const headers = model.headers
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
: { "User-Agent": resolveGithubCopilotUserAgent() };
return { ...model, headers };
}
return model;
}
export function buildInlineProviderModels(
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
): InlineModelEntry[] {
@@ -71,7 +60,7 @@ export function resolveModel(
if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
return {
model: applyProviderModelOverrides(normalized),
model: normalized,
authStorage,
modelRegistry,
};
@@ -89,7 +78,7 @@ export function resolveModel(
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
return { model: fallbackModel, authStorage, modelRegistry };
}
return {
error: `Unknown model: ${provider}/${modelId}`,
@@ -97,9 +86,5 @@ export function resolveModel(
modelRegistry,
};
}
return {
model: applyProviderModelOverrides(normalizeModelCompat(model)),
authStorage,
modelRegistry,
};
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
}
+11 -2
View File
@@ -184,8 +184,17 @@ export async function runEmbeddedPiAgent(
lastProfileId = resolvedProfileId;
return;
}
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
lastProfileId = resolvedProfileId;
if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
lastProfileId = apiKeyInfo.profileId;
};
const advanceAuthProfile = async (): Promise<boolean> => {
@@ -1,212 +0,0 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
const browser = createBrowserTool();
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="chrome"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown[];
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("exposes raw for gateway config.apply tool calls", () => {
const tools = createClawdbotCodingTools();
const gateway = tools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined();
const parameters = gateway?.parameters as {
type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
};
expect(parameters.type).toBe("object");
expect(parameters.properties?.raw).toBeDefined();
expect(parameters.required ?? []).not.toContain("raw");
});
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(action?.type).toBe("string");
expect(action?.anyOf).toBeUndefined();
expect(Array.isArray(action?.enum)).toBe(true);
expect(action?.enum).toContain("act");
const snapshotFormat = parameters.properties?.snapshotFormat as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(snapshotFormat?.type).toBe("string");
expect(snapshotFormat?.anyOf).toBeUndefined();
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.anyOf).toBeUndefined();
expect(parentId?.oneOf).toBeUndefined();
expect(parentId?.type).toBe("string");
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(count?.anyOf).toBeUndefined();
expect(Array.isArray(count?.oneOf)).toBe(true);
});
});
@@ -1,74 +1,11 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
const collectActionValues = (schema: unknown, values: Set<string>): void => {
@@ -88,7 +25,7 @@ describe("createClawdbotCodingTools", () => {
};
for (const name of toolNames) {
const tool = tools.find((candidate) => candidate.name === name);
const tool = defaultTools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
const parameters = tool?.parameters as {
properties?: Record<string, unknown>;
@@ -108,10 +45,9 @@ describe("createClawdbotCodingTools", () => {
}
});
it("includes exec and process tools by default", () => {
const tools = createClawdbotCodingTools();
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
expect(tools.some((tool) => tool.name === "process")).toBe(true);
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
});
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
const config: ClawdbotConfig = {
@@ -1,78 +1,15 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { describe, expect, it } from "vitest";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("keeps read tool image metadata intact", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
const readTool = defaultTools.find((tool) => tool.name === "read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -1,183 +0,0 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") continue;
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of tools) {
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});
});
@@ -1,74 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { describe, expect, it } from "vitest";
import { createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("uses workspaceDir for Read tool path resolution", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
try {
@@ -1,95 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("applies sandbox path guards to file_path alias", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
await fs.writeFile(outsidePath, "outside", "utf8");
try {
const readTool = createSandboxedReadTool(tmpDir);
await expect(readTool.execute("tool-sbx-1", { file_path: outsidePath })).rejects.toThrow();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outsidePath, { force: true });
}
});
it("falls back to process.cwd() when workspaceDir not provided", () => {
const prevCwd = process.cwd();
const tools = createClawdbotCodingTools();
// Tools should be created without error
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(true);
expect(tools.some((tool) => tool.name === "edit")).toBe(true);
// cwd should be unchanged
expect(process.cwd()).toBe(prevCwd);
});
});
@@ -1,7 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
import { createBrowserTool } from "./tools/browser-tool.js";
const defaultTools = createClawdbotCodingTools();
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
@@ -67,8 +74,144 @@ describe("createClawdbotCodingTools", () => {
});
});
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
const browser = createBrowserTool();
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="chrome"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown[];
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("exposes raw for gateway config.apply tool calls", () => {
const gateway = defaultTools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined();
const parameters = gateway?.parameters as {
type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
};
expect(parameters.type).toBe("object");
expect(parameters.properties?.raw).toBeDefined();
expect(parameters.required ?? []).not.toContain("raw");
});
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(action?.type).toBe("string");
expect(action?.anyOf).toBeUndefined();
expect(Array.isArray(action?.enum)).toBe(true);
expect(action?.enum).toContain("act");
const snapshotFormat = parameters.properties?.snapshotFormat as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(snapshotFormat?.type).toBe("string");
expect(snapshotFormat?.anyOf).toBeUndefined();
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.type).toBe("string");
expect(parentId?.anyOf).toBeUndefined();
expect(count?.oneOf).toBeDefined();
});
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
@@ -96,7 +239,7 @@ describe("createClawdbotCodingTools", () => {
}
};
for (const tool of tools) {
for (const tool of defaultTools) {
walk(tool.parameters, "", tool.name);
}
@@ -192,4 +335,131 @@ describe("createClawdbotCodingTools", () => {
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") continue;
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of defaultTools) {
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});
it("applies sandbox path guards to file_path alias", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
await fs.writeFile(outsidePath, "outside", "utf8");
try {
const readTool = createSandboxedReadTool(tmpDir);
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
/sandbox root/i,
);
} finally {
await fs.rm(outsidePath, { force: true });
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+18 -1
View File
@@ -38,6 +38,19 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
}
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
if (loaded) {
try {
await service.stop({ env: process.env, stdout });
} catch {
// Best-effort stop; final loaded check gates success.
}
}
try {
await service.uninstall({ env: process.env, stdout });
} catch (err) {
@@ -45,12 +58,16 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
return;
}
let loaded = false;
loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
if (loaded) {
fail("Gateway service still loaded after uninstall.");
return;
}
emit({
ok: true,
result: "uninstalled",
+11 -1
View File
@@ -198,10 +198,20 @@ export async function applyAuthChoiceAnthropic(
}
if (params.authChoice === "apiKey") {
if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") {
return null;
}
let nextConfig = params.config;
let hasCredential = false;
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
if (envKey) {
if (params.opts?.token) {
await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
initialValue: true,
+105 -28
View File
@@ -56,7 +56,33 @@ export async function applyAuthChoiceApiProviders(
);
};
if (params.authChoice === "openrouter-api-key") {
let authChoice = params.authChoice;
if (
authChoice === "apiKey" &&
params.opts?.tokenProvider &&
params.opts.tokenProvider !== "anthropic" &&
params.opts.tokenProvider !== "openai"
) {
if (params.opts.tokenProvider === "openrouter") {
authChoice = "openrouter-api-key";
} else if (params.opts.tokenProvider === "vercel-ai-gateway") {
authChoice = "ai-gateway-api-key";
} else if (params.opts.tokenProvider === "moonshot") {
authChoice = "moonshot-api-key";
} else if (params.opts.tokenProvider === "kimi-code") {
authChoice = "kimi-code-api-key";
} else if (params.opts.tokenProvider === "google") {
authChoice = "gemini-api-key";
} else if (params.opts.tokenProvider === "zai") {
authChoice = "zai-api-key";
} else if (params.opts.tokenProvider === "synthetic") {
authChoice = "synthetic-api-key";
} else if (params.opts.tokenProvider === "opencode") {
authChoice = "opencode-zen";
}
}
if (authChoice === "openrouter-api-key") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
@@ -82,6 +108,11 @@ export async function applyAuthChoiceApiProviders(
hasCredential = true;
}
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("openrouter");
if (envKey) {
@@ -129,8 +160,18 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "ai-gateway-api-key") {
if (authChoice === "ai-gateway-api-key") {
let hasCredential = false;
if (
!hasCredential &&
params.opts?.token &&
params.opts?.tokenProvider === "vercel-ai-gateway"
) {
await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("vercel-ai-gateway");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -171,8 +212,14 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "moonshot-api-key") {
if (authChoice === "moonshot-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") {
await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("moonshot");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -212,15 +259,22 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "kimi-code-api-key") {
await params.prompter.note(
[
"Kimi Code uses a dedicated endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/en",
].join("\n"),
"Kimi Code",
);
if (authChoice === "kimi-code-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kimi-code") {
await setKimiCodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Kimi Code uses a dedicated endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/en",
].join("\n"),
"Kimi Code",
);
}
const envKey = resolveEnvApiKey("kimi-code");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -261,8 +315,14 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "gemini-api-key") {
if (authChoice === "gemini-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") {
await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("google");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -302,8 +362,14 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "zai-api-key") {
if (authChoice === "zai-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") {
await setZaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("zai");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -359,12 +425,16 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "synthetic-api-key") {
const key = await params.prompter.text({
message: "Enter Synthetic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setSyntheticApiKey(String(key).trim(), params.agentDir);
if (authChoice === "synthetic-api-key") {
if (params.opts?.token && params.opts?.tokenProvider === "synthetic") {
await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir);
} else {
const key = await params.prompter.text({
message: "Enter Synthetic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setSyntheticApiKey(String(key).trim(), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "synthetic:default",
provider: "synthetic",
@@ -387,16 +457,23 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "opencode-zen") {
await params.prompter.note(
[
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
"Requires an active OpenCode Zen subscription.",
].join("\n"),
"OpenCode Zen",
);
if (authChoice === "opencode-zen") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") {
await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
"Requires an active OpenCode Zen subscription.",
].join("\n"),
"OpenCode Zen",
);
}
const envKey = resolveEnvApiKey("opencode");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "github-copilot:github",
provider: "github-copilot",
mode: "oauth",
mode: "token",
});
if (params.setDefaultModel) {
+16 -5
View File
@@ -20,7 +20,12 @@ import {
export async function applyAuthChoiceOpenAI(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice === "openai-api-key") {
let authChoice = params.authChoice;
if (authChoice === "apiKey" && params.opts?.tokenProvider === "openai") {
authChoice = "openai-api-key";
}
if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = await params.prompter.confirm({
@@ -43,10 +48,16 @@ export async function applyAuthChoiceOpenAI(
}
}
const key = await params.prompter.text({
message: "Enter OpenAI API key",
validate: validateApiKeyInput,
});
let key: string | undefined;
if (params.opts?.token && params.opts?.tokenProvider === "openai") {
key = params.opts.token;
} else {
key = await params.prompter.text({
message: "Enter OpenAI API key",
validate: validateApiKeyInput,
});
}
const trimmed = normalizeApiKeyInput(String(key));
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
+4
View File
@@ -21,6 +21,10 @@ export type ApplyAuthChoiceParams = {
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
opts?: {
tokenProvider?: string;
token?: string;
};
};
export type ApplyAuthChoiceResult = {
@@ -1,6 +1,9 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createDiscordMessageHandler } from "./monitor.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { __resetDiscordThreadStarterCacheForTest } from "./monitor/threading.js";
const sendMock = vi.fn();
const reactMock = vi.fn();
@@ -41,12 +44,12 @@ beforeEach(() => {
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
vi.resetModules();
__resetDiscordChannelInfoCacheForTest();
__resetDiscordThreadStarterCacheForTest();
});
describe("discord tool result dispatch", () => {
it("sends status replies with responsePrefix", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
@@ -116,7 +119,6 @@ describe("discord tool result dispatch", () => {
}, 30_000);
it("caches channel info lookups between messages", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
@@ -189,7 +191,6 @@ describe("discord tool result dispatch", () => {
});
it("includes forwarded message snapshots in body", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedBody = "";
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedBody = ctx.Body ?? "";
+4
View File
@@ -30,6 +30,10 @@ type DiscordThreadParentInfo = {
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
export function __resetDiscordThreadStarterCacheForTest() {
DISCORD_THREAD_STARTER_CACHE.clear();
}
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
+15 -1
View File
@@ -1,7 +1,21 @@
export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
const DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
export const getMaxChatHistoryMessagesBytes = () => maxChatHistoryMessagesBytes;
export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => {
if (!process.env.VITEST && process.env.NODE_ENV !== "test") return;
if (value === undefined) {
maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
return;
}
if (Number.isFinite(value) && value > 0) {
maxChatHistoryMessagesBytes = value;
}
};
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
export const getHandshakeTimeoutMs = () => {
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
+2 -2
View File
@@ -28,7 +28,7 @@ import {
validateChatInjectParams,
validateChatSendParams,
} from "../protocol/index.js";
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
import {
capArrayByJsonBytes,
loadSessionEntry,
@@ -66,7 +66,7 @@ export const chatHandlers: GatewayRequestHandlers = {
const max = Math.min(hardMax, requested);
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const sanitized = stripEnvelopeFromMessages(sliced);
const capped = capArrayByJsonBytes(sanitized, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
const capped = capArrayByJsonBytes(sanitized, getMaxChatHistoryMessagesBytes()).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
+209 -188
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
@@ -26,129 +26,226 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
});
}
const openWs = async (port: number) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
return ws;
};
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
try {
const { server, ws } = await startServerWithClient();
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
expect(closed).toBe(true);
describe("default auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
beforeAll(async () => {
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
} finally {
if (prevHandshakeTimeout === undefined) {
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
}
}
});
});
test("connect (req) handshake returns hello-ok payload", async () => {
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
expect(res.ok).toBe(true);
const payload = res.payload as
| {
type?: unknown;
snapshot?: { configPath?: string; stateDir?: string };
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
try {
const ws = await openWs(port);
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
expect(closed).toBe(true);
} finally {
if (prevHandshakeTimeout === undefined) {
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
}
| undefined;
expect(payload?.type).toBe("hello-ok");
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT);
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT);
}
});
ws.close();
await server.close();
});
test("connect (req) handshake returns hello-ok payload", async () => {
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
const ws = await openWs(port);
test("sends connect challenge on open", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const evtPromise = onceMessage<{ payload?: unknown }>(
ws,
(o) => o.type === "event" && o.event === "connect.challenge",
const res = await connectReq(ws);
expect(res.ok).toBe(true);
const payload = res.payload as
| {
type?: unknown;
snapshot?: { configPath?: string; stateDir?: string };
}
| undefined;
expect(payload?.type).toBe("hello-ok");
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT);
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT);
ws.close();
});
test("sends connect challenge on open", async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const evtPromise = onceMessage<{ payload?: unknown }>(
ws,
(o) => o.type === "event" && o.event === "connect.challenge",
);
await new Promise<void>((resolve) => ws.once("open", resolve));
const evt = await evtPromise;
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
ws.close();
});
test("rejects protocol mismatch", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, {
minProtocol: PROTOCOL_VERSION + 1,
maxProtocol: PROTOCOL_VERSION + 2,
});
expect(res.ok).toBe(false);
} catch {
// If the server closed before we saw the frame, that's acceptable.
}
ws.close();
});
test("rejects non-connect first request", async () => {
const ws = await openWs(port);
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === "h1",
);
expect(res.ok).toBe(false);
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test(
"invalid connect params surface in response and close reason",
{ timeout: 60_000 },
async () => {
const ws = await openWs(port);
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
ws.send(
JSON.stringify({
type: "req",
id: "h-bad",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "bad-client",
version: "dev",
platform: "web",
mode: "webchat",
},
device: {
id: 123,
publicKey: "bad",
signature: "bad",
signedAt: "bad",
},
},
}),
);
const res = await onceMessage<{
ok: boolean;
error?: { message?: string };
}>(
ws,
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
);
expect(res.ok).toBe(false);
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
const closeInfo = await closeInfoPromise;
expect(closeInfo.code).toBe(1008);
expect(closeInfo.reason).toContain("invalid connect params");
},
);
await new Promise<void>((resolve) => ws.once("open", resolve));
const evt = await evtPromise;
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
ws.close();
await server.close();
});
test("rejects protocol mismatch", async () => {
const { server, ws } = await startServerWithClient();
try {
describe("password auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
beforeAll(async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
});
test("accepts password auth when configured", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { password: "secret" });
expect(res.ok).toBe(true);
ws.close();
});
test("rejects invalid password", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { password: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("unauthorized");
ws.close();
});
});
describe("token auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects invalid token", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { token: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("unauthorized");
ws.close();
});
test("rejects control ui without device identity by default", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, {
minProtocol: PROTOCOL_VERSION + 1,
maxProtocol: PROTOCOL_VERSION + 2,
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(false);
} catch {
// If the server closed before we saw the frame, that's acceptable.
}
ws.close();
await server.close();
});
test("rejects invalid token", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("unauthorized");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts password auth when configured", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { password: "secret" });
expect(res.ok).toBe(true);
ws.close();
await server.close();
});
test("rejects control ui without device identity by default", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
expect(res.error?.message ?? "").toContain("secure context");
ws.close();
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("allows control ui without device identity when insecure auth is enabled", async () => {
@@ -327,81 +424,5 @@ describe("gateway server auth/connect", () => {
}
});
test("rejects invalid password", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { password: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("unauthorized");
ws.close();
await server.close();
});
test("rejects non-connect first request", async () => {
const { server, ws } = await startServerWithClient();
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === "h1",
);
expect(res.ok).toBe(false);
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
await server.close();
});
test(
"invalid connect params surface in response and close reason",
{ timeout: 60_000 },
async () => {
const { server, ws } = await startServerWithClient();
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
ws.send(
JSON.stringify({
type: "req",
id: "h-bad",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "bad-client",
version: "dev",
platform: "web",
mode: "webchat",
},
device: {
id: 123,
publicKey: "bad",
signature: "bad",
signedAt: "bad",
},
},
}),
);
const res = await onceMessage<{
ok: boolean;
error?: { message?: string };
}>(
ws,
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
);
expect(res.ok).toBe(false);
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
const closeInfo = await closeInfoPromise;
expect(closeInfo.code).toBe(1008);
expect(closeInfo.reason).toContain("invalid connect params");
await server.close();
},
);
// Remaining tests require isolated gateway state.
});
@@ -14,6 +14,7 @@ import {
testState,
writeSessionStore,
} from "./test-helpers.js";
import { __setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js";
installGatewayTestHooks({ scope: "suite" });
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
@@ -52,6 +53,8 @@ describe("gateway server chat", () => {
spy.mockResolvedValue(undefined);
};
try {
const historyMaxBytes = 192 * 1024;
__setMaxChatHistoryMessagesBytesForTest(historyMaxBytes);
await connectOk(ws);
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(sessionDir);
@@ -66,9 +69,9 @@ describe("gateway server chat", () => {
};
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
const bigText = "x".repeat(155_000);
const bigText = "x".repeat(4_000);
const largeLines: string[] = [];
for (let i = 0; i < 40; i += 1) {
for (let i = 0; i < 60; i += 1) {
largeLines.push(
JSON.stringify({
message: {
@@ -91,7 +94,7 @@ describe("gateway server chat", () => {
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
expect(cappedMsgs.length).toBeLessThan(60);
await writeStore({
@@ -473,6 +476,7 @@ describe("gateway server chat", () => {
: undefined;
expect(run2).toBe("idem-2");
} finally {
__setMaxChatHistoryMessagesBytesForTest();
testState.sessionStorePath = undefined;
sessionStoreSaveDelayMs.value = 0;
ws.close();
+20 -27
View File
@@ -5,7 +5,6 @@ import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
testState,
@@ -36,36 +35,23 @@ async function rmTempDir(dir: string) {
await fs.rm(dir, { recursive: true, force: true });
}
async function waitForCronFinished(
ws: { send: (data: string) => void },
jobId: string,
timeoutMs = 20_000,
) {
await onceMessage(
ws as never,
(o) =>
o.type === "event" &&
o.event === "cron" &&
o.payload?.action === "finished" &&
o.payload?.jobId === jobId,
timeoutMs,
);
}
async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
const startedAt = process.hrtime.bigint();
for (;;) {
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
if (Date.now() >= deadline) {
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
if (elapsedMs >= timeoutMs) {
throw new Error(`timeout waiting for file ${pathname}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
await yieldToEventLoop();
}
}
describe("gateway server cron", () => {
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON;
process.env.CLAWDBOT_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
@@ -269,10 +255,17 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.CLAWDBOT_SKIP_CRON;
} else {
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
}
}
});
test("writes cron run history and auto-runs due jobs", async () => {
const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON;
process.env.CLAWDBOT_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = undefined;
@@ -297,13 +290,10 @@ describe("gateway server cron", () => {
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const finishedP = waitForCronFinished(ws, jobId);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
await finishedP;
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
const raw = await waitForNonEmptyFile(logPath);
const raw = await waitForNonEmptyFile(logPath, 5000);
const line = raw
.split("\n")
.map((l) => l.trim())
@@ -349,9 +339,7 @@ describe("gateway server cron", () => {
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
expect(autoJobId.length > 0).toBe(true);
await waitForCronFinished(ws, autoJobId);
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`));
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`), 5000);
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
| { entries?: Array<{ jobId?: unknown }> }
| undefined;
@@ -364,6 +352,11 @@ describe("gateway server cron", () => {
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.CLAWDBOT_SKIP_CRON;
} else {
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
}
}
}, 45_000);
});
+1 -8
View File
@@ -191,7 +191,7 @@ describe("gateway hot reload", () => {
}
});
it("applies hot reload actions for providers + services", async () => {
it("applies hot reload actions and emits restart signal", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -270,13 +270,6 @@ describe("gateway hot reload", () => {
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage");
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("imessage");
await server.close();
});
it("emits SIGUSR1 on restart plan when listener exists", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const onRestart = hoisted.getOnRestart();
expect(onRestart).toBeTypeOf("function");
+1
View File
@@ -554,3 +554,4 @@ vi.mock("../cli/deps.js", async () => {
});
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
@@ -0,0 +1,578 @@
import { describe, expect, it, vi } from "vitest";
import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
describe("fetchAntigravityUsage", () => {
it("returns 3 windows when both endpoints succeed", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 750,
planInfo: { monthlyPromptCredits: 1000 },
planType: "Standard",
currentTier: { id: "tier1", name: "Standard Tier" },
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-pro-1.5": {
quotaInfo: {
remainingFraction: 0.6,
resetTime: "2026-01-08T00:00:00Z",
isExhausted: false,
},
},
"gemini-flash-2.0": {
quotaInfo: {
remainingFraction: 0.8,
resetTime: "2026-01-08T00:00:00Z",
isExhausted: false,
},
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.provider).toBe("google-antigravity");
expect(snapshot.displayName).toBe("Antigravity");
expect(snapshot.windows).toHaveLength(3);
expect(snapshot.plan).toBe("Standard Tier");
expect(snapshot.error).toBeUndefined();
const creditsWindow = snapshot.windows.find((w) => w.label === "Credits");
expect(creditsWindow?.usedPercent).toBe(25); // (1000 - 750) / 1000 * 100
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5");
expect(proWindow?.usedPercent).toBe(40); // (1 - 0.6) * 100
expect(proWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime());
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0");
expect(flashWindow?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100
expect(flashWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime());
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 250,
planInfo: { monthlyPromptCredits: 1000 },
currentTier: { name: "Free" },
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(403, { error: { message: "Permission denied" } });
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.provider).toBe("google-antigravity");
expect(snapshot.windows).toHaveLength(1);
expect(snapshot.plan).toBe("Free");
expect(snapshot.error).toBeUndefined();
const creditsWindow = snapshot.windows[0];
expect(creditsWindow?.label).toBe("Credits");
expect(creditsWindow?.usedPercent).toBe(75); // (1000 - 250) / 1000 * 100
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Internal server error");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-pro-1.5": {
quotaInfo: { remainingFraction: 0.5, resetTime: "2026-01-08T00:00:00Z" },
},
"gemini-flash-2.0": {
quotaInfo: { remainingFraction: 0.7, resetTime: "2026-01-08T00:00:00Z" },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.provider).toBe("google-antigravity");
expect(snapshot.windows).toHaveLength(2);
expect(snapshot.error).toBeUndefined();
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5");
expect(proWindow?.usedPercent).toBe(50); // (1 - 0.5) * 100
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0");
expect(flashWindow?.usedPercent).toBeCloseTo(30, 1); // (1 - 0.7) * 100
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses cloudaicompanionProject string as project id", async () => {
let capturedBody: string | undefined;
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: "projects/alpha",
});
}
if (url.includes("fetchAvailableModels")) {
capturedBody = init?.body?.toString();
return makeResponse(200, { models: {} });
}
return makeResponse(404, "not found");
},
);
await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(capturedBody).toBe(JSON.stringify({ project: "projects/alpha" }));
});
it("uses cloudaicompanionProject object id when present", async () => {
let capturedBody: string | undefined;
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: { id: "projects/beta" },
});
}
if (url.includes("fetchAvailableModels")) {
capturedBody = init?.body?.toString();
return makeResponse(200, { models: {} });
}
return makeResponse(404, "not found");
},
);
await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(capturedBody).toBe(JSON.stringify({ project: "projects/beta" }));
});
it("returns error snapshot when both endpoints fail", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(403, { error: { message: "Access denied" } });
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(403, "Forbidden");
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.provider).toBe("google-antigravity");
expect(snapshot.windows).toHaveLength(0);
expect(snapshot.error).toBe("Access denied");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Boom");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(401, { error: { message: "Unauthorized" } });
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.error).toBe("Token expired");
expect(snapshot.windows).toHaveLength(0);
});
it("extracts plan info from currentTier.name", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
planInfo: { monthlyPromptCredits: 1000 },
planType: "Basic",
currentTier: { id: "tier2", name: "Premium Tier" },
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(500, "Error");
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.plan).toBe("Premium Tier");
});
it("falls back to planType when currentTier.name is missing", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
planInfo: { monthlyPromptCredits: 1000 },
planType: "Basic Plan",
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(500, "Error");
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.plan).toBe("Basic Plan");
});
it("includes reset times in model windows", async () => {
const resetTime = "2026-01-10T12:00:00Z";
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-pro-experimental": {
quotaInfo: { remainingFraction: 0.3, resetTime },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-experimental");
expect(proWindow?.resetAt).toBe(new Date(resetTime).getTime());
});
it("parses string numbers correctly", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: "600",
planInfo: { monthlyPromptCredits: "1000" },
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-flash-lite": {
quotaInfo: { remainingFraction: "0.9" },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows).toHaveLength(2);
const creditsWindow = snapshot.windows.find((w) => w.label === "Credits");
expect(creditsWindow?.usedPercent).toBe(40); // (1000 - 600) / 1000 * 100
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-lite");
expect(flashWindow?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100
});
it("skips internal models", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: "projects/internal",
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
chat_hidden: { quotaInfo: { remainingFraction: 0.1 } },
tab_hidden: { quotaInfo: { remainingFraction: 0.2 } },
"gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.7 } },
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows.map((w) => w.label)).toEqual(["Credits", "gemini-pro-1.5"]);
});
it("sorts models by usage and shows individual model IDs", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-pro-1.0": {
quotaInfo: { remainingFraction: 0.8 },
},
"gemini-pro-1.5": {
quotaInfo: { remainingFraction: 0.3 },
},
"gemini-flash-1.5": {
quotaInfo: { remainingFraction: 0.6 },
},
"gemini-flash-2.0": {
quotaInfo: { remainingFraction: 0.9 },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows).toHaveLength(4);
// Should be sorted by usage (highest first)
expect(snapshot.windows[0]?.label).toBe("gemini-pro-1.5");
expect(snapshot.windows[0]?.usedPercent).toBe(70); // (1 - 0.3) * 100
expect(snapshot.windows[1]?.label).toBe("gemini-flash-1.5");
expect(snapshot.windows[1]?.usedPercent).toBe(40); // (1 - 0.6) * 100
expect(snapshot.windows[2]?.label).toBe("gemini-pro-1.0");
expect(snapshot.windows[2]?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100
expect(snapshot.windows[3]?.label).toBe("gemini-flash-2.0");
expect(snapshot.windows[3]?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100
});
it("returns Token expired error on 401 from loadCodeAssist", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(401, { error: { message: "Unauthorized" } });
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.error).toBe("Token expired");
expect(snapshot.windows).toHaveLength(0);
expect(mockFetch).toHaveBeenCalledTimes(1); // Should stop early on 401
});
it("handles empty models array gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 800,
planInfo: { monthlyPromptCredits: 1000 },
});
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, { models: {} });
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows).toHaveLength(1);
const creditsWindow = snapshot.windows[0];
expect(creditsWindow?.label).toBe("Credits");
expect(creditsWindow?.usedPercent).toBe(20);
});
it("handles missing credits fields gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(200, { planType: "Free" });
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-flash-experimental": {
quotaInfo: { remainingFraction: 0.5 },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows).toHaveLength(1);
const flashWindow = snapshot.windows[0];
expect(flashWindow?.label).toBe("gemini-flash-experimental");
expect(flashWindow?.usedPercent).toBe(50);
expect(snapshot.plan).toBe("Free");
});
it("handles invalid reset time gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-pro-test": {
quotaInfo: { remainingFraction: 0.4, resetTime: "invalid-date" },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-test");
expect(proWindow?.usedPercent).toBe(60);
expect(proWindow?.resetAt).toBeUndefined();
});
it("handles network errors with graceful degradation", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("loadCodeAssist")) {
throw new Error("Network failure");
}
if (url.includes("fetchAvailableModels")) {
return makeResponse(200, {
models: {
"gemini-flash-stable": {
quotaInfo: { remainingFraction: 0.85 },
},
},
});
}
return makeResponse(404, "not found");
});
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
expect(snapshot.windows).toHaveLength(1);
const flashWindow = snapshot.windows[0];
expect(flashWindow?.label).toBe("gemini-flash-stable");
expect(flashWindow?.usedPercent).toBeCloseTo(15, 1);
expect(snapshot.error).toBeUndefined();
});
});
@@ -0,0 +1,284 @@
import { logDebug } from "../logger.js";
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type LoadCodeAssistResponse = {
availablePromptCredits?: number | string;
planInfo?: { monthlyPromptCredits?: number | string };
planType?: string;
currentTier?: { id?: string; name?: string };
cloudaicompanionProject?: string | { id?: string };
};
type FetchAvailableModelsResponse = {
models?: Record<
string,
{
displayName?: string;
quotaInfo?: {
remainingFraction?: number | string;
resetTime?: string;
isExhausted?: boolean;
};
}
>;
};
type ModelQuota = {
remainingFraction: number;
resetTime?: number;
};
type CreditsInfo = {
available: number;
monthly: number;
};
const BASE_URL = "https://cloudcode-pa.googleapis.com";
const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist";
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
const METADATA = {
ideType: "ANTIGRAVITY",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
};
function parseNumber(value: number | string | undefined): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function parseEpochMs(isoString: string | undefined): number | undefined {
if (!isoString?.trim()) return undefined;
try {
const ms = Date.parse(isoString);
if (Number.isFinite(ms)) return ms;
} catch {
// ignore parse errors
}
return undefined;
}
async function parseErrorMessage(res: Response): Promise<string> {
try {
const data = (await res.json()) as { error?: { message?: string } };
const message = data?.error?.message?.trim();
if (message) return message;
} catch {
// ignore parse errors
}
return `HTTP ${res.status}`;
}
function extractCredits(data: LoadCodeAssistResponse): CreditsInfo | undefined {
const available = parseNumber(data.availablePromptCredits);
const monthly = parseNumber(data.planInfo?.monthlyPromptCredits);
if (available === undefined || monthly === undefined || monthly <= 0) return undefined;
return { available, monthly };
}
function extractPlanInfo(data: LoadCodeAssistResponse): string | undefined {
const tierName = data.currentTier?.name?.trim();
if (tierName) return tierName;
const planType = data.planType?.trim();
if (planType) return planType;
return undefined;
}
function extractProjectId(data: LoadCodeAssistResponse): string | undefined {
const project = data.cloudaicompanionProject;
if (!project) return undefined;
if (typeof project === "string") return project.trim() ? project : undefined;
const projectId = typeof project.id === "string" ? project.id.trim() : undefined;
return projectId || undefined;
}
function extractModelQuotas(data: FetchAvailableModelsResponse): Map<string, ModelQuota> {
const result = new Map<string, ModelQuota>();
if (!data.models || typeof data.models !== "object") return result;
for (const [modelId, modelInfo] of Object.entries(data.models)) {
const quotaInfo = modelInfo.quotaInfo;
if (!quotaInfo) continue;
const remainingFraction = parseNumber(quotaInfo.remainingFraction);
if (remainingFraction === undefined) continue;
const resetTime = parseEpochMs(quotaInfo.resetTime);
result.set(modelId, { remainingFraction, resetTime });
}
return result;
}
function buildUsageWindows(opts: {
credits?: CreditsInfo;
modelQuotas?: Map<string, ModelQuota>;
}): UsageWindow[] {
const windows: UsageWindow[] = [];
// Credits window (overall)
if (opts.credits) {
const { available, monthly } = opts.credits;
const used = monthly - available;
const usedPercent = clampPercent((used / monthly) * 100);
windows.push({ label: "Credits", usedPercent });
}
// Individual model windows
if (opts.modelQuotas && opts.modelQuotas.size > 0) {
const modelWindows: UsageWindow[] = [];
for (const [modelId, quota] of opts.modelQuotas) {
const lowerModelId = modelId.toLowerCase();
// Skip internal models
if (lowerModelId.includes("chat_") || lowerModelId.includes("tab_")) {
continue;
}
const usedPercent = clampPercent((1 - quota.remainingFraction) * 100);
const window: UsageWindow = { label: modelId, usedPercent };
if (quota.resetTime) window.resetAt = quota.resetTime;
modelWindows.push(window);
}
// Sort by usage (highest first) and take top 10
modelWindows.sort((a, b) => b.usedPercent - a.usedPercent);
const topModels = modelWindows.slice(0, 10);
logDebug(
`[antigravity] Built ${topModels.length} model windows from ${opts.modelQuotas.size} total models`,
);
for (const w of topModels) {
logDebug(
`[antigravity] ${w.label}: ${w.usedPercent.toFixed(1)}% used${w.resetAt ? ` (resets at ${new Date(w.resetAt).toISOString()})` : ""}`,
);
}
windows.push(...topModels);
}
return windows;
}
export async function fetchAntigravityUsage(
token: string,
timeoutMs: number,
fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "antigravity",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
};
let credits: CreditsInfo | undefined;
let modelQuotas: Map<string, ModelQuota> | undefined;
let planInfo: string | undefined;
let lastError: string | undefined;
let projectId: string | undefined;
// Fetch loadCodeAssist (credits + plan info)
try {
const res = await fetchJson(
`${BASE_URL}${LOAD_CODE_ASSIST_PATH}`,
{ method: "POST", headers, body: JSON.stringify({ metadata: METADATA }) },
timeoutMs,
fetchFn,
);
if (res.ok) {
const data = (await res.json()) as LoadCodeAssistResponse;
// Extract project ID for subsequent calls
projectId = extractProjectId(data);
credits = extractCredits(data);
planInfo = extractPlanInfo(data);
logDebug(
`[antigravity] Credits: ${credits ? `${credits.available}/${credits.monthly}` : "none"}${planInfo ? ` (plan: ${planInfo})` : ""}`,
);
} else {
lastError = await parseErrorMessage(res);
// Fatal auth errors - stop early
if (res.status === 401) {
return {
provider: "google-antigravity",
displayName: PROVIDER_LABELS["google-antigravity"],
windows: [],
error: "Token expired",
};
}
}
} catch {
lastError = "Network error";
}
// Fetch fetchAvailableModels (model quotas)
if (!projectId) {
logDebug("[antigravity] Missing project id; requesting available models without project");
}
try {
const body = JSON.stringify(projectId ? { project: projectId } : {});
const res = await fetchJson(
`${BASE_URL}${FETCH_AVAILABLE_MODELS_PATH}`,
{ method: "POST", headers, body },
timeoutMs,
fetchFn,
);
if (res.ok) {
const data = (await res.json()) as FetchAvailableModelsResponse;
modelQuotas = extractModelQuotas(data);
logDebug(`[antigravity] Extracted ${modelQuotas.size} model quotas from API`);
for (const [modelId, quota] of modelQuotas) {
logDebug(
`[antigravity] ${modelId}: ${(quota.remainingFraction * 100).toFixed(1)}% remaining${quota.resetTime ? ` (resets ${new Date(quota.resetTime).toISOString()})` : ""}`,
);
}
} else {
const err = await parseErrorMessage(res);
if (res.status === 401) {
lastError = "Token expired";
} else if (!lastError) {
lastError = err;
}
}
} catch {
if (!lastError) lastError = "Network error";
}
// Build windows from available data
const windows = buildUsageWindows({ credits, modelQuotas });
// Return error only if we got nothing
if (windows.length === 0 && lastError) {
logDebug(`[antigravity] Returning error snapshot: ${lastError}`);
return {
provider: "google-antigravity",
displayName: PROVIDER_LABELS["google-antigravity"],
windows: [],
error: lastError,
};
}
const snapshot: ProviderUsageSnapshot = {
provider: "google-antigravity",
displayName: PROVIDER_LABELS["google-antigravity"],
windows,
plan: planInfo,
};
logDebug(
`[antigravity] Returning snapshot with ${windows.length} windows${planInfo ? ` (plan: ${planInfo})` : ""}`,
);
logDebug(`[antigravity] Snapshot: ${JSON.stringify(snapshot, null, 2)}`);
return snapshot;
}
+1
View File
@@ -1,3 +1,4 @@
export { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
+3 -1
View File
@@ -1,5 +1,6 @@
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
import {
fetchAntigravityUsage,
fetchClaudeUsage,
fetchCodexUsage,
fetchCopilotUsage,
@@ -57,8 +58,9 @@ export async function loadProviderUsageSummary(
return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn);
case "github-copilot":
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
case "google-gemini-cli":
case "google-antigravity":
return await fetchAntigravityUsage(auth.token, timeoutMs, fetchFn);
case "google-gemini-cli":
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
case "openai-codex":
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
+22 -81
View File
@@ -1,4 +1,4 @@
import { intro, note, outro, select, spinner, text, isCancel } from "@clack/prompts";
import { intro, note, outro, spinner } from "@clack/prompts";
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
import { updateConfig } from "../commands/models/shared.js";
@@ -6,22 +6,10 @@ import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import { logConfigUpdated } from "../config/logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
normalizeGithubCopilotDomain,
resolveGithubCopilotBaseUrl,
resolveGithubCopilotUserAgent,
} from "./github-copilot-utils.js";
const CLIENT_ID = "Ov23li8tweQw6odWQebz";
const DEFAULT_DOMAIN = "github.com";
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
function getUrls(domain: string) {
return {
deviceCodeUrl: `https://${domain}/login/device/code`,
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
};
}
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
type DeviceCodeResponse = {
device_code: string;
@@ -50,21 +38,17 @@ function parseJsonResponse<T>(value: unknown): T {
return value as T;
}
async function requestDeviceCode(params: {
scope: string;
domain: string;
}): Promise<DeviceCodeResponse> {
const body = JSON.stringify({
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
const body = new URLSearchParams({
client_id: CLIENT_ID,
scope: params.scope,
});
const res = await fetch(getUrls(params.domain).deviceCodeUrl, {
const res = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": resolveGithubCopilotUserAgent(),
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
@@ -81,27 +65,24 @@ async function requestDeviceCode(params: {
}
async function pollForAccessToken(params: {
domain: string;
deviceCode: string;
intervalMs: number;
expiresAt: number;
}): Promise<string> {
const bodyBase = {
const bodyBase = new URLSearchParams({
client_id: CLIENT_ID,
device_code: params.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
};
const urls = getUrls(params.domain);
});
while (Date.now() < params.expiresAt) {
const res = await fetch(urls.accessTokenUrl, {
const res = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": resolveGithubCopilotUserAgent(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: JSON.stringify(bodyBase),
body: bodyBase,
});
if (!res.ok) {
@@ -115,14 +96,11 @@ async function pollForAccessToken(params: {
const err = "error" in json ? json.error : "unknown";
if (err === "authorization_pending") {
await new Promise((r) => setTimeout(r, params.intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS));
await new Promise((r) => setTimeout(r, params.intervalMs));
continue;
}
if (err === "slow_down") {
const serverInterval =
"interval" in json && typeof json.interval === "number" ? json.interval : undefined;
const nextInterval = serverInterval ? serverInterval * 1000 : params.intervalMs + 5000;
await new Promise((r) => setTimeout(r, nextInterval + OAUTH_POLLING_SAFETY_MARGIN_MS));
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
continue;
}
if (err === "expired_token") {
@@ -159,42 +137,9 @@ export async function githubCopilotLoginCommand(
);
}
const deployment = await select({
message: "Select GitHub deployment type",
options: [
{ label: "GitHub.com", value: DEFAULT_DOMAIN, hint: "Public" },
{ label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
],
});
if (isCancel(deployment)) {
throw new Error("GitHub login cancelled");
}
let domain = DEFAULT_DOMAIN;
let enterpriseDomain: string | null = null;
if (deployment === "enterprise") {
const enterpriseInput = await text({
message: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
validate: (value) => {
if (!value) return "URL or domain is required";
return normalizeGithubCopilotDomain(value) ? undefined : "Enter a valid URL or domain";
},
});
if (isCancel(enterpriseInput)) {
throw new Error("GitHub login cancelled");
}
const normalized = normalizeGithubCopilotDomain(enterpriseInput);
if (!normalized) {
throw new Error("Invalid GitHub Enterprise URL/domain");
}
enterpriseDomain = normalized;
domain = normalized;
}
const spin = spinner();
spin.start("Requesting device code from GitHub...");
const device = await requestDeviceCode({ scope: "read:user", domain });
const device = await requestDeviceCode({ scope: "read:user" });
spin.stop("Device code ready");
note(
@@ -208,7 +153,6 @@ export async function githubCopilotLoginCommand(
const polling = spinner();
polling.start("Waiting for GitHub authorization...");
const accessToken = await pollForAccessToken({
domain,
deviceCode: device.device_code,
intervalMs,
expiresAt,
@@ -218,13 +162,11 @@ export async function githubCopilotLoginCommand(
upsertAuthProfile({
profileId,
credential: {
type: "oauth",
type: "token",
provider: "github-copilot",
refresh: accessToken,
access: accessToken,
// Copilot access tokens are treated as non-expiring (see resolveApiKeyForProfile).
expires: 0,
enterpriseUrl: enterpriseDomain ?? undefined,
token: accessToken,
// GitHub device flow token doesn't reliably include expiry here.
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
},
});
@@ -232,13 +174,12 @@ export async function githubCopilotLoginCommand(
applyAuthProfileConfig(cfg, {
provider: "github-copilot",
profileId,
mode: "oauth",
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (github-copilot/oauth)`);
runtime.log(`Base URL: ${resolveGithubCopilotBaseUrl(enterpriseDomain ?? undefined)}`);
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
outro("Done");
}
+1 -2
View File
@@ -2,7 +2,6 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "./github-copilot-utils.js";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
@@ -54,7 +53,7 @@ function parseCopilotTokenResponse(value: unknown): {
return { token, expiresAt: expiresAtMs };
}
export const DEFAULT_COPILOT_API_BASE_URL = DEFAULT_GITHUB_COPILOT_BASE_URL;
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
const trimmed = token.trim();
-24
View File
@@ -1,24 +0,0 @@
export const DEFAULT_GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com";
export function resolveGithubCopilotUserAgent(): string {
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
return `clawdbot/${version}`;
}
export function normalizeGithubCopilotDomain(input: string | null | undefined): string | null {
const trimmed = (input ?? "").trim();
if (!trimmed) return null;
try {
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
return url.hostname;
} catch {
return null;
}
}
export function resolveGithubCopilotBaseUrl(enterpriseDomain?: string | null): string {
if (enterpriseDomain && enterpriseDomain.trim()) {
return `https://copilot-api.${enterpriseDomain.trim()}`;
}
return DEFAULT_GITHUB_COPILOT_BASE_URL;
}
+4
View File
@@ -356,6 +356,10 @@ export async function runOnboardingWizard(
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: opts.tokenProvider,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
nextConfig = authResult.config;
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig, mergeConfig } from "vitest/config";
import baseConfig from "./vitest.config.ts";
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
const exclude = baseTest.exclude ?? [];
export default mergeConfig(
baseConfig,
defineConfig({
test: {
include: ["src/gateway/**/*.test.ts", "extensions/**/*.test.ts"],
exclude,
},
}),
);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, mergeConfig } from "vitest/config";
import baseConfig from "./vitest.config.ts";
const baseTest = (baseConfig as { test?: { include?: string[]; exclude?: string[] } }).test ?? {};
const include = baseTest.include ?? [
"src/**/*.test.ts",
"extensions/**/*.test.ts",
"test/format-error.test.ts",
];
const exclude = baseTest.exclude ?? [];
export default mergeConfig(
baseConfig,
defineConfig({
test: {
include,
exclude: [...exclude, "src/gateway/**", "extensions/**"],
},
}),
);