Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48751a99c | ||
|
|
86e0916fa3 | ||
|
|
0c7e649676 | ||
|
|
45ce07a098 | ||
|
|
aed8dc1ade | ||
|
|
86a341be62 | ||
|
|
ff78e9a564 | ||
|
|
60a60779d7 | ||
|
|
32da00cb2f | ||
|
|
0420f2804c | ||
|
|
4de660bec6 | ||
|
|
58f638463f | ||
|
|
f1afc722da | ||
|
|
bc75d58e9e | ||
|
|
2efd265697 | ||
|
|
9c1f1476bc | ||
|
|
e8352c8d21 |
No files matched your search
@@ -51,6 +51,7 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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.
|
- 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
@@ -5,25 +5,11 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- 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
|
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
|
||||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
- 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.
|
- 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: 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
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- 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.
|
- 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.
|
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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: 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)
|
- 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
|
## 2026.1.21-2
|
||||||
|
|
||||||
@@ -61,6 +47,8 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- 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: 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.
|
- 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: 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.
|
- 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.
|
- 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
|
||||||
- **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:** 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
|
### Fixes
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
- 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)
|
- 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.
|
- 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.
|
- 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.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
|||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||||
against the Copilot API. This is the **default** and simplest path because it
|
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||||
does not require VS Code. Enterprise domains are supported.
|
because it does not require VS Code.
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
### 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
|
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||||
open until it completes.
|
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
|
### Optional flags
|
||||||
|
|
||||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
|||||||
- Requires an interactive TTY; run it directly in a terminal.
|
- Requires an interactive TTY; run it directly in a terminal.
|
||||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||||
another ID (for example `github-copilot/gpt-4.1`).
|
another ID (for example `github-copilot/gpt-4.1`).
|
||||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||||
for Copilot API calls.
|
Copilot API token when Clawdbot runs.
|
||||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
|
||||||
for GitHub Enterprise.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@clawdbot/mattermost",
|
||||||
"version": "2026.1.20-2",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@clawdbot/open-prose",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@
|
|||||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||||
"format:all": "pnpm format && pnpm format:swift",
|
"format:all": "pnpm format && pnpm format:swift",
|
||||||
"format:fix": "oxfmt --write src test",
|
"format:fix": "oxfmt --write src test",
|
||||||
"test": "vitest run",
|
"test": "node scripts/test-parallel.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "node --import tsx scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
const cred = store.profiles[params.profileId];
|
const cred = store.profiles[params.profileId];
|
||||||
if (!cred || cred.type !== "oauth") return null;
|
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) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
|
|||||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||||
if (profileConfig && profileConfig.mode !== cred.type) 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) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type TokenCredential = {
|
|||||||
token: string;
|
token: string;
|
||||||
/** Optional expiry timestamp (ms since epoch). */
|
/** Optional expiry timestamp (ms since epoch). */
|
||||||
expires?: number;
|
expires?: number;
|
||||||
enterpriseUrl?: string;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.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> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -52,6 +51,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
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 { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
const agentDir = path.join(home, "agent-default-base-url");
|
const agentDir = path.join(home, "agent-default-base-url");
|
||||||
@@ -62,55 +71,48 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
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);
|
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
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 () => {
|
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 {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
token: "copilot",
|
||||||
await fs.writeFile(
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
path.join(agentDir, "auth-profiles.json"),
|
source: "mock",
|
||||||
JSON.stringify(
|
baseUrl: "https://api.copilot.example",
|
||||||
{
|
});
|
||||||
version: 1,
|
|
||||||
profiles: {
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
"github-copilot:github": {
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
type: "oauth",
|
resolveCopilotApiToken,
|
||||||
provider: "github-copilot",
|
}));
|
||||||
refresh: "gh-token",
|
|
||||||
access: "gh-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "company.ghe.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
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");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
);
|
||||||
} finally {
|
} 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.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> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -44,7 +43,7 @@ describe("models-config", () => {
|
|||||||
process.env.HOME = previousHome;
|
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 () => {
|
await withTempHome(async () => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||||
@@ -52,6 +51,11 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
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 { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
@@ -63,13 +67,13 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
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 {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
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) => {
|
await withTempHome(async (home) => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
const previousGh = process.env.GH_TOKEN;
|
const previousGh = process.env.GH_TOKEN;
|
||||||
@@ -90,12 +94,9 @@ describe("models-config", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"github-copilot:github": {
|
"github-copilot:github": {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
refresh: "gh-profile-token",
|
token: "gh-profile-token",
|
||||||
access: "gh-profile-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "https://company.ghe.com/",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
@@ -113,9 +124,7 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
normalizeGithubCopilotDomain,
|
DEFAULT_COPILOT_API_BASE_URL,
|
||||||
resolveGithubCopilotBaseUrl,
|
resolveCopilotApiToken,
|
||||||
} from "../providers/github-copilot-utils.js";
|
} from "../providers/github-copilot-token.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
|
|
||||||
if (!hasProfile && !githubToken) return null;
|
if (!hasProfile && !githubToken) return null;
|
||||||
|
|
||||||
let enterpriseDomain: string | null = null;
|
let selectedGithubToken = githubToken;
|
||||||
if (hasProfile) {
|
if (!selectedGithubToken && hasProfile) {
|
||||||
// Use the first available profile as a default for discovery (it will be
|
// Use the first available profile as a default for discovery (it will be
|
||||||
// re-resolved per-run by the embedded runner).
|
// re-resolved per-run by the embedded runner).
|
||||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||||
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
if (profile && profile.type === "token") {
|
||||||
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
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
|
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
// `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
|
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||||
// patterns for OAuth-like providers in pi-coding-agent.
|
// patterns for OAuth-like providers in pi-coding-agent.
|
||||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
// 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.
|
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||||
|
|
||||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.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> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
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");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
@@ -109,6 +117,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
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 { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
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 { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.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;
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
vi.useRealTimers();
|
|
||||||
vi.resetModules();
|
|
||||||
runEmbeddedAttemptMock.mockReset();
|
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
runEmbeddedAttemptMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
const baseUsage = {
|
const baseUsage = {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
`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 {
|
} else {
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
|||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
import { normalizeModelCompat } from "../model-compat.js";
|
import { normalizeModelCompat } from "../model-compat.js";
|
||||||
import { normalizeProviderId } from "../model-selection.js";
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
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(
|
export function buildInlineProviderModels(
|
||||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||||
): InlineModelEntry[] {
|
): InlineModelEntry[] {
|
||||||
@@ -71,7 +60,7 @@ export function resolveModel(
|
|||||||
if (inlineMatch) {
|
if (inlineMatch) {
|
||||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||||
return {
|
return {
|
||||||
model: applyProviderModelOverrides(normalized),
|
model: normalized,
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
@@ -89,7 +78,7 @@ export function resolveModel(
|
|||||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
} as Model<Api>);
|
} as Model<Api>);
|
||||||
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
return { model: fallbackModel, authStorage, modelRegistry };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: `Unknown model: ${provider}/${modelId}`,
|
error: `Unknown model: ${provider}/${modelId}`,
|
||||||
@@ -97,9 +86,5 @@ export function resolveModel(
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
||||||
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
|
||||||
authStorage,
|
|
||||||
modelRegistry,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -184,8 +184,17 @@ export async function runEmbeddedPiAgent(
|
|||||||
lastProfileId = resolvedProfileId;
|
lastProfileId = resolvedProfileId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
if (model.provider === "github-copilot") {
|
||||||
lastProfileId = resolvedProfileId;
|
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> => {
|
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||||
|
|||||||
-212
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+8
-72
@@ -1,74 +1,11 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
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("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", () => {
|
it("preserves action enums in normalized schemas", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||||
|
|
||||||
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
||||||
@@ -88,7 +25,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const name of toolNames) {
|
for (const name of toolNames) {
|
||||||
const tool = tools.find((candidate) => candidate.name === name);
|
const tool = defaultTools.find((candidate) => candidate.name === name);
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
const parameters = tool?.parameters as {
|
const parameters = tool?.parameters as {
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -108,10 +45,9 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("includes exec and process tools by default", () => {
|
it("includes exec and process tools by default", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
|
||||||
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
|
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
|
||||||
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
||||||
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
|
||||||
});
|
});
|
||||||
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
||||||
const config: ClawdbotConfig = {
|
const config: ClawdbotConfig = {
|
||||||
|
|||||||
+5
-68
@@ -1,78 +1,15 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
const defaultTools = createClawdbotCodingTools();
|
||||||
|
|
||||||
describe("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 () => {
|
it("keeps read tool image metadata intact", async () => {
|
||||||
const tools = createClawdbotCodingTools();
|
const readTool = defaultTools.find((tool) => tool.name === "read");
|
||||||
const readTool = tools.find((tool) => tool.name === "read");
|
|
||||||
expect(readTool).toBeDefined();
|
expect(readTool).toBeDefined();
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||||
|
|||||||
-183
@@ -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([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-66
@@ -1,74 +1,10 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
|
|
||||||
describe("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("uses workspaceDir for Read tool path resolution", async () => {
|
it("uses workspaceDir for Read tool path resolution", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
-95
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+272
-2
@@ -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 type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-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("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
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", () => {
|
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const offenders: Array<{
|
const offenders: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -96,7 +239,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of defaultTools) {
|
||||||
walk(tool.parameters, "", tool.name);
|
walk(tool.parameters, "", tool.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,4 +335,131 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
});
|
});
|
||||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,19 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const service = resolveGatewayService();
|
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 {
|
try {
|
||||||
await service.uninstall({ env: process.env, stdout });
|
await service.uninstall({ env: process.env, stdout });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,12 +58,16 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = false;
|
loaded = false;
|
||||||
try {
|
try {
|
||||||
loaded = await service.isLoaded({ env: process.env });
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
} catch {
|
} catch {
|
||||||
loaded = false;
|
loaded = false;
|
||||||
}
|
}
|
||||||
|
if (loaded) {
|
||||||
|
fail("Gateway service still loaded after uninstall.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit({
|
emit({
|
||||||
ok: true,
|
ok: true,
|
||||||
result: "uninstalled",
|
result: "uninstalled",
|
||||||
|
|||||||
@@ -198,10 +198,20 @@ export async function applyAuthChoiceAnthropic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "apiKey") {
|
if (params.authChoice === "apiKey") {
|
||||||
|
if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let nextConfig = params.config;
|
let nextConfig = params.config;
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
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({
|
const useExisting = await params.prompter.confirm({
|
||||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
|
|||||||
@@ -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, {
|
const store = ensureAuthProfileStore(params.agentDir, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
});
|
});
|
||||||
@@ -82,6 +108,11 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
hasCredential = true;
|
hasCredential = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
|
||||||
|
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasCredential) {
|
if (!hasCredential) {
|
||||||
const envKey = resolveEnvApiKey("openrouter");
|
const envKey = resolveEnvApiKey("openrouter");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
@@ -129,8 +160,18 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "ai-gateway-api-key") {
|
if (authChoice === "ai-gateway-api-key") {
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("vercel-ai-gateway");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -171,8 +212,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "moonshot-api-key") {
|
if (authChoice === "moonshot-api-key") {
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("moonshot");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -212,15 +259,22 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "kimi-code-api-key") {
|
if (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",
|
|
||||||
);
|
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("kimi-code");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -261,8 +315,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "gemini-api-key") {
|
if (authChoice === "gemini-api-key") {
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("google");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -302,8 +362,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "zai-api-key") {
|
if (authChoice === "zai-api-key") {
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("zai");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -359,12 +425,16 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "synthetic-api-key") {
|
if (authChoice === "synthetic-api-key") {
|
||||||
const key = await params.prompter.text({
|
if (params.opts?.token && params.opts?.tokenProvider === "synthetic") {
|
||||||
message: "Enter Synthetic API key",
|
await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir);
|
||||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
} else {
|
||||||
});
|
const key = await params.prompter.text({
|
||||||
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
message: "Enter Synthetic API key",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
||||||
|
}
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "synthetic:default",
|
profileId: "synthetic:default",
|
||||||
provider: "synthetic",
|
provider: "synthetic",
|
||||||
@@ -387,16 +457,23 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "opencode-zen") {
|
if (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",
|
|
||||||
);
|
|
||||||
let hasCredential = false;
|
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");
|
const envKey = resolveEnvApiKey("opencode");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
|
|||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "github-copilot:github",
|
profileId: "github-copilot:github",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
mode: "oauth",
|
mode: "token",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import {
|
|||||||
export async function applyAuthChoiceOpenAI(
|
export async function applyAuthChoiceOpenAI(
|
||||||
params: ApplyAuthChoiceParams,
|
params: ApplyAuthChoiceParams,
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
): 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");
|
const envKey = resolveEnvApiKey("openai");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -43,10 +48,16 @@ export async function applyAuthChoiceOpenAI(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await params.prompter.text({
|
let key: string | undefined;
|
||||||
message: "Enter OpenAI API key",
|
if (params.opts?.token && params.opts?.tokenProvider === "openai") {
|
||||||
validate: validateApiKeyInput,
|
key = params.opts.token;
|
||||||
});
|
} else {
|
||||||
|
key = await params.prompter.text({
|
||||||
|
message: "Enter OpenAI API key",
|
||||||
|
validate: validateApiKeyInput,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const trimmed = normalizeApiKeyInput(String(key));
|
const trimmed = normalizeApiKeyInput(String(key));
|
||||||
const result = upsertSharedEnvVar({
|
const result = upsertSharedEnvVar({
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export type ApplyAuthChoiceParams = {
|
|||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
setDefaultModel: boolean;
|
setDefaultModel: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
opts?: {
|
||||||
|
tokenProvider?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApplyAuthChoiceResult = {
|
export type ApplyAuthChoiceResult = {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Client } from "@buape/carbon";
|
import type { Client } from "@buape/carbon";
|
||||||
import { ChannelType, MessageType } from "@buape/carbon";
|
import { ChannelType, MessageType } from "@buape/carbon";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 sendMock = vi.fn();
|
||||||
const reactMock = vi.fn();
|
const reactMock = vi.fn();
|
||||||
@@ -41,12 +44,12 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
vi.resetModules();
|
__resetDiscordChannelInfoCacheForTest();
|
||||||
|
__resetDiscordThreadStarterCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("discord tool result dispatch", () => {
|
describe("discord tool result dispatch", () => {
|
||||||
it("sends status replies with responsePrefix", async () => {
|
it("sends status replies with responsePrefix", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -116,7 +119,6 @@ describe("discord tool result dispatch", () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it("caches channel info lookups between messages", async () => {
|
it("caches channel info lookups between messages", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -189,7 +191,6 @@ describe("discord tool result dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes forwarded message snapshots in body", async () => {
|
it("includes forwarded message snapshots in body", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
|
||||||
let capturedBody = "";
|
let capturedBody = "";
|
||||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||||
capturedBody = ctx.Body ?? "";
|
capturedBody = ctx.Body ?? "";
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ type DiscordThreadParentInfo = {
|
|||||||
|
|
||||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
||||||
|
|
||||||
|
export function __resetDiscordThreadStarterCacheForTest() {
|
||||||
|
DISCORD_THREAD_STARTER_CACHE.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
||||||
return (
|
return (
|
||||||
type === ChannelType.PublicThread ||
|
type === ChannelType.PublicThread ||
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
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_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 DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||||
export const getHandshakeTimeoutMs = () => {
|
export const getHandshakeTimeoutMs = () => {
|
||||||
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
|
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
validateChatInjectParams,
|
validateChatInjectParams,
|
||||||
validateChatSendParams,
|
validateChatSendParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
|
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
|
||||||
import {
|
import {
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
loadSessionEntry,
|
loadSessionEntry,
|
||||||
@@ -66,7 +66,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
const max = Math.min(hardMax, requested);
|
const max = Math.min(hardMax, requested);
|
||||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
const sanitized = stripEnvelopeFromMessages(sliced);
|
const sanitized = stripEnvelopeFromMessages(sliced);
|
||||||
const capped = capArrayByJsonBytes(sanitized, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
|
const capped = capArrayByJsonBytes(sanitized, getMaxChatHistoryMessagesBytes()).items;
|
||||||
let thinkingLevel = entry?.thinkingLevel;
|
let thinkingLevel = entry?.thinkingLevel;
|
||||||
if (!thinkingLevel) {
|
if (!thinkingLevel) {
|
||||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||||
|
|||||||
+209
-188
@@ -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 { WebSocket } from "ws";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { getHandshakeTimeoutMs } from "./server-constants.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", () => {
|
describe("gateway server auth/connect", () => {
|
||||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
describe("default auth", () => {
|
||||||
vi.useRealTimers();
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
let port: number;
|
||||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
|
||||||
try {
|
beforeAll(async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
port = await getFreePort();
|
||||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
server = await startGatewayServer(port);
|
||||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
});
|
||||||
expect(closed).toBe(true);
|
|
||||||
|
afterAll(async () => {
|
||||||
await server.close();
|
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 () => {
|
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||||
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
|
vi.useRealTimers();
|
||||||
const port = await getFreePort();
|
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||||
const server = await startGatewayServer(port);
|
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
try {
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
const ws = await openWs(port);
|
||||||
|
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||||
const res = await connectReq(ws);
|
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
||||||
expect(res.ok).toBe(true);
|
expect(closed).toBe(true);
|
||||||
const payload = res.payload as
|
} finally {
|
||||||
| {
|
if (prevHandshakeTimeout === undefined) {
|
||||||
type?: unknown;
|
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||||
snapshot?: { configPath?: string; stateDir?: string };
|
} 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();
|
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||||
await server.close();
|
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 res = await connectReq(ws);
|
||||||
const port = await getFreePort();
|
expect(res.ok).toBe(true);
|
||||||
const server = await startGatewayServer(port);
|
const payload = res.payload as
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
| {
|
||||||
const evtPromise = onceMessage<{ payload?: unknown }>(
|
type?: unknown;
|
||||||
ws,
|
snapshot?: { configPath?: string; stateDir?: string };
|
||||||
(o) => o.type === "event" && o.event === "connect.challenge",
|
}
|
||||||
|
| 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 () => {
|
describe("password auth", () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
try {
|
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, {
|
const res = await connectReq(ws, {
|
||||||
minProtocol: PROTOCOL_VERSION + 1,
|
token: "secret",
|
||||||
maxProtocol: PROTOCOL_VERSION + 2,
|
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);
|
expect(res.ok).toBe(false);
|
||||||
} catch {
|
expect(res.error?.message ?? "").toContain("secure context");
|
||||||
// If the server closed before we saw the frame, that's acceptable.
|
ws.close();
|
||||||
}
|
|
||||||
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.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 () => {
|
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 () => {
|
// Remaining tests require isolated gateway state.
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
testState,
|
testState,
|
||||||
writeSessionStore,
|
writeSessionStore,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
import { __setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js";
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks({ scope: "suite" });
|
||||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
@@ -52,6 +53,8 @@ describe("gateway server chat", () => {
|
|||||||
spy.mockResolvedValue(undefined);
|
spy.mockResolvedValue(undefined);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const historyMaxBytes = 192 * 1024;
|
||||||
|
__setMaxChatHistoryMessagesBytesForTest(historyMaxBytes);
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
tempDirs.push(sessionDir);
|
tempDirs.push(sessionDir);
|
||||||
@@ -66,9 +69,9 @@ describe("gateway server chat", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
||||||
const bigText = "x".repeat(155_000);
|
const bigText = "x".repeat(4_000);
|
||||||
const largeLines: string[] = [];
|
const largeLines: string[] = [];
|
||||||
for (let i = 0; i < 40; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
largeLines.push(
|
largeLines.push(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: {
|
message: {
|
||||||
@@ -91,7 +94,7 @@ describe("gateway server chat", () => {
|
|||||||
expect(cappedRes.ok).toBe(true);
|
expect(cappedRes.ok).toBe(true);
|
||||||
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
||||||
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||||
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
||||||
expect(cappedMsgs.length).toBeLessThan(60);
|
expect(cappedMsgs.length).toBeLessThan(60);
|
||||||
|
|
||||||
await writeStore({
|
await writeStore({
|
||||||
@@ -473,6 +476,7 @@ describe("gateway server chat", () => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
expect(run2).toBe("idem-2");
|
expect(run2).toBe("idem-2");
|
||||||
} finally {
|
} finally {
|
||||||
|
__setMaxChatHistoryMessagesBytesForTest();
|
||||||
testState.sessionStorePath = undefined;
|
testState.sessionStorePath = undefined;
|
||||||
sessionStoreSaveDelayMs.value = 0;
|
sessionStoreSaveDelayMs.value = 0;
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
|
||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
@@ -36,36 +35,23 @@ async function rmTempDir(dir: string) {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
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) {
|
async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const startedAt = process.hrtime.bigint();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
|
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
|
||||||
if (raw.trim().length > 0) return raw;
|
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}`);
|
throw new Error(`timeout waiting for file ${pathname}`);
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await yieldToEventLoop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server cron", () => {
|
describe("gateway server cron", () => {
|
||||||
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
|
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-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
testState.sessionConfig = { mainKey: "primary" };
|
testState.sessionConfig = { mainKey: "primary" };
|
||||||
@@ -269,10 +255,17 @@ describe("gateway server cron", () => {
|
|||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
testState.sessionConfig = undefined;
|
testState.sessionConfig = undefined;
|
||||||
testState.cronEnabled = 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 () => {
|
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-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
testState.cronEnabled = undefined;
|
testState.cronEnabled = undefined;
|
||||||
@@ -297,13 +290,10 @@ describe("gateway server cron", () => {
|
|||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(jobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const finishedP = waitForCronFinished(ws, jobId);
|
|
||||||
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
|
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
|
||||||
expect(runRes.ok).toBe(true);
|
expect(runRes.ok).toBe(true);
|
||||||
await finishedP;
|
|
||||||
|
|
||||||
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
|
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
|
||||||
const raw = await waitForNonEmptyFile(logPath);
|
const raw = await waitForNonEmptyFile(logPath, 5000);
|
||||||
const line = raw
|
const line = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
@@ -349,9 +339,7 @@ describe("gateway server cron", () => {
|
|||||||
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
||||||
expect(autoJobId.length > 0).toBe(true);
|
expect(autoJobId.length > 0).toBe(true);
|
||||||
|
|
||||||
await waitForCronFinished(ws, autoJobId);
|
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`), 5000);
|
||||||
|
|
||||||
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`));
|
|
||||||
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
|
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
|
||||||
| { entries?: Array<{ jobId?: unknown }> }
|
| { entries?: Array<{ jobId?: unknown }> }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -364,6 +352,11 @@ describe("gateway server cron", () => {
|
|||||||
await rmTempDir(dir);
|
await rmTempDir(dir);
|
||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
testState.cronEnabled = undefined;
|
testState.cronEnabled = undefined;
|
||||||
|
if (prevSkipCron === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_SKIP_CRON;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
});
|
});
|
||||||
@@ -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 port = await getFreePort();
|
||||||
const server = await startGatewayServer(port);
|
const server = await startGatewayServer(port);
|
||||||
|
|
||||||
@@ -270,13 +270,6 @@ describe("gateway hot reload", () => {
|
|||||||
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage");
|
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage");
|
||||||
expect(hoisted.providerManager.startChannel).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();
|
const onRestart = hoisted.getOnRestart();
|
||||||
expect(onRestart).toBeTypeOf("function");
|
expect(onRestart).toBeTypeOf("function");
|
||||||
|
|
||||||
|
|||||||
@@ -554,3 +554,4 @@ vi.mock("../cli/deps.js", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
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,3 +1,4 @@
|
|||||||
|
export { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
|
||||||
export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
|
export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
|
||||||
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
|
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
|
||||||
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
|
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
|
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
|
||||||
import {
|
import {
|
||||||
|
fetchAntigravityUsage,
|
||||||
fetchClaudeUsage,
|
fetchClaudeUsage,
|
||||||
fetchCodexUsage,
|
fetchCodexUsage,
|
||||||
fetchCopilotUsage,
|
fetchCopilotUsage,
|
||||||
@@ -57,8 +58,9 @@ export async function loadProviderUsageSummary(
|
|||||||
return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn);
|
return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn);
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
|
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
|
||||||
case "google-gemini-cli":
|
|
||||||
case "google-antigravity":
|
case "google-antigravity":
|
||||||
|
return await fetchAntigravityUsage(auth.token, timeoutMs, fetchFn);
|
||||||
|
case "google-gemini-cli":
|
||||||
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
|
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
|
||||||
case "openai-codex":
|
case "openai-codex":
|
||||||
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
|
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
|
||||||
|
|||||||
@@ -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 { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||||
import { updateConfig } from "../commands/models/shared.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 { logConfigUpdated } from "../config/logging.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import {
|
|
||||||
normalizeGithubCopilotDomain,
|
|
||||||
resolveGithubCopilotBaseUrl,
|
|
||||||
resolveGithubCopilotUserAgent,
|
|
||||||
} from "./github-copilot-utils.js";
|
|
||||||
|
|
||||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz";
|
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||||
const DEFAULT_DOMAIN = "github.com";
|
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
|
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||||
|
|
||||||
function getUrls(domain: string) {
|
|
||||||
return {
|
|
||||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
|
||||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceCodeResponse = {
|
type DeviceCodeResponse = {
|
||||||
device_code: string;
|
device_code: string;
|
||||||
@@ -50,21 +38,17 @@ function parseJsonResponse<T>(value: unknown): T {
|
|||||||
return value as T;
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestDeviceCode(params: {
|
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
|
||||||
scope: string;
|
const body = new URLSearchParams({
|
||||||
domain: string;
|
|
||||||
}): Promise<DeviceCodeResponse> {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
scope: params.scope,
|
scope: params.scope,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(getUrls(params.domain).deviceCodeUrl, {
|
const res = await fetch(DEVICE_CODE_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": resolveGithubCopilotUserAgent(),
|
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
@@ -81,27 +65,24 @@ async function requestDeviceCode(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollForAccessToken(params: {
|
async function pollForAccessToken(params: {
|
||||||
domain: string;
|
|
||||||
deviceCode: string;
|
deviceCode: string;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const bodyBase = {
|
const bodyBase = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
device_code: params.deviceCode,
|
device_code: params.deviceCode,
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
};
|
});
|
||||||
const urls = getUrls(params.domain);
|
|
||||||
|
|
||||||
while (Date.now() < params.expiresAt) {
|
while (Date.now() < params.expiresAt) {
|
||||||
const res = await fetch(urls.accessTokenUrl, {
|
const res = await fetch(ACCESS_TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": resolveGithubCopilotUserAgent(),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(bodyBase),
|
body: bodyBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -115,14 +96,11 @@ async function pollForAccessToken(params: {
|
|||||||
|
|
||||||
const err = "error" in json ? json.error : "unknown";
|
const err = "error" in json ? json.error : "unknown";
|
||||||
if (err === "authorization_pending") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (err === "slow_down") {
|
if (err === "slow_down") {
|
||||||
const serverInterval =
|
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
|
||||||
"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));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (err === "expired_token") {
|
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();
|
const spin = spinner();
|
||||||
spin.start("Requesting device code from GitHub...");
|
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");
|
spin.stop("Device code ready");
|
||||||
|
|
||||||
note(
|
note(
|
||||||
@@ -208,7 +153,6 @@ export async function githubCopilotLoginCommand(
|
|||||||
const polling = spinner();
|
const polling = spinner();
|
||||||
polling.start("Waiting for GitHub authorization...");
|
polling.start("Waiting for GitHub authorization...");
|
||||||
const accessToken = await pollForAccessToken({
|
const accessToken = await pollForAccessToken({
|
||||||
domain,
|
|
||||||
deviceCode: device.device_code,
|
deviceCode: device.device_code,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
@@ -218,13 +162,11 @@ export async function githubCopilotLoginCommand(
|
|||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId,
|
profileId,
|
||||||
credential: {
|
credential: {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
refresh: accessToken,
|
token: accessToken,
|
||||||
access: accessToken,
|
// GitHub device flow token doesn't reliably include expiry here.
|
||||||
// Copilot access tokens are treated as non-expiring (see resolveApiKeyForProfile).
|
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: enterpriseDomain ?? undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,13 +174,12 @@ export async function githubCopilotLoginCommand(
|
|||||||
applyAuthProfileConfig(cfg, {
|
applyAuthProfileConfig(cfg, {
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
profileId,
|
profileId,
|
||||||
mode: "oauth",
|
mode: "token",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
logConfigUpdated(runtime);
|
||||||
runtime.log(`Auth profile: ${profileId} (github-copilot/oauth)`);
|
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
|
||||||
runtime.log(`Base URL: ${resolveGithubCopilotBaseUrl(enterpriseDomain ?? undefined)}`);
|
|
||||||
|
|
||||||
outro("Done");
|
outro("Done");
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.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";
|
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ function parseCopilotTokenResponse(value: unknown): {
|
|||||||
return { token, expiresAt: expiresAtMs };
|
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 {
|
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -356,6 +356,10 @@ export async function runOnboardingWizard(
|
|||||||
prompter,
|
prompter,
|
||||||
runtime,
|
runtime,
|
||||||
setDefaultModel: true,
|
setDefaultModel: true,
|
||||||
|
opts: {
|
||||||
|
tokenProvider: opts.tokenProvider,
|
||||||
|
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
nextConfig = authResult.config;
|
nextConfig = authResult.config;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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/**"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in new issue
Block a user