chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>main
parent
912ebffc63
commit
c379191f80
|
|
@ -74,9 +74,9 @@ jobs:
|
|||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"indentWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
||||
"extends": ["recommended"]
|
||||
}
|
||||
|
|
@ -25,12 +25,12 @@
|
|||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
|
|
|||
17
biome.json
17
biome.json
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "test/**/*.ts"]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
12
package.json
|
|
@ -87,14 +87,14 @@
|
|||
"mac:restart": "bash scripts/restart-mac.sh",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
"mac:open": "open dist/Clawdbot.app",
|
||||
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"lint": "oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||
"lint:fix": "biome check --write --unsafe src && biome format --write src",
|
||||
"format": "biome format src",
|
||||
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"format": "oxfmt --check src test",
|
||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||
"format:all": "pnpm format && pnpm format:swift",
|
||||
"format:fix": "biome format src --write",
|
||||
"format:fix": "oxfmt --write src test",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "pnpm --dir ui test",
|
||||
|
|
@ -176,7 +176,6 @@
|
|||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@grammyjs/types": "^3.23.0",
|
||||
"@lit-labs/signals": "^0.2.0",
|
||||
"@lit/context": "^1.1.6",
|
||||
|
|
@ -194,7 +193,8 @@
|
|||
"lit": "^3.3.2",
|
||||
"lucide": "^0.562.0",
|
||||
"ollama": "^0.6.3",
|
||||
"oxlint": "^1.38.0",
|
||||
"oxfmt": "0.24.0",
|
||||
"oxlint": "^1.39.0",
|
||||
"oxlint-tsgolint": "^0.11.0",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"rolldown": "1.0.0-beta.59",
|
||||
|
|
|
|||
184
pnpm-lock.yaml
184
pnpm-lock.yaml
|
|
@ -149,9 +149,6 @@ importers:
|
|||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.3.11
|
||||
version: 2.3.11
|
||||
'@grammyjs/types':
|
||||
specifier: ^3.23.0
|
||||
version: 3.23.0
|
||||
|
|
@ -203,8 +200,11 @@ importers:
|
|||
ollama:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
oxfmt:
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0
|
||||
oxlint:
|
||||
specifier: ^1.38.0
|
||||
specifier: ^1.39.0
|
||||
version: 1.39.0(oxlint-tsgolint@0.11.0)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.11.0
|
||||
|
|
@ -452,59 +452,6 @@ packages:
|
|||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@biomejs/biome@2.3.11':
|
||||
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.11':
|
||||
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
||||
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
||||
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.11':
|
||||
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.11':
|
||||
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@borewit/text-codec@0.2.1':
|
||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||
|
||||
|
|
@ -1245,6 +1192,46 @@ packages:
|
|||
'@oxc-project/types@0.107.0':
|
||||
resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==}
|
||||
|
||||
'@oxfmt/darwin-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/darwin-x64@0.24.0':
|
||||
resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/linux-arm64-gnu@0.24.0':
|
||||
resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-arm64-musl@0.24.0':
|
||||
resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-x64-gnu@0.24.0':
|
||||
resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-x64-musl@0.24.0':
|
||||
resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/win32-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/win32-x64@0.24.0':
|
||||
resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.11.0':
|
||||
resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==}
|
||||
cpu: [arm64]
|
||||
|
|
@ -3324,6 +3311,11 @@ packages:
|
|||
resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
oxfmt@0.24.0:
|
||||
resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.11.0:
|
||||
resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==}
|
||||
hasBin: true
|
||||
|
|
@ -3858,6 +3850,10 @@ packages:
|
|||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@2.0.0:
|
||||
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
tinyrainbow@3.0.3:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -4641,41 +4637,6 @@ snapshots:
|
|||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@biomejs/biome@2.3.11':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.3.11
|
||||
'@biomejs/cli-darwin-x64': 2.3.11
|
||||
'@biomejs/cli-linux-arm64': 2.3.11
|
||||
'@biomejs/cli-linux-arm64-musl': 2.3.11
|
||||
'@biomejs/cli-linux-x64': 2.3.11
|
||||
'@biomejs/cli-linux-x64-musl': 2.3.11
|
||||
'@biomejs/cli-win32-arm64': 2.3.11
|
||||
'@biomejs/cli-win32-x64': 2.3.11
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.11':
|
||||
optional: true
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
|
||||
|
|
@ -5420,6 +5381,30 @@ snapshots:
|
|||
|
||||
'@oxc-project/types@0.107.0': {}
|
||||
|
||||
'@oxfmt/darwin-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/darwin-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-arm64-gnu@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-arm64-musl@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-x64-gnu@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-x64-musl@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/win32-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/win32-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.11.0':
|
||||
optional: true
|
||||
|
||||
|
|
@ -7653,6 +7638,19 @@ snapshots:
|
|||
|
||||
osc-progress@0.2.0: {}
|
||||
|
||||
oxfmt@0.24.0:
|
||||
dependencies:
|
||||
tinypool: 2.0.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/darwin-arm64': 0.24.0
|
||||
'@oxfmt/darwin-x64': 0.24.0
|
||||
'@oxfmt/linux-arm64-gnu': 0.24.0
|
||||
'@oxfmt/linux-arm64-musl': 0.24.0
|
||||
'@oxfmt/linux-x64-gnu': 0.24.0
|
||||
'@oxfmt/linux-x64-musl': 0.24.0
|
||||
'@oxfmt/win32-arm64': 0.24.0
|
||||
'@oxfmt/win32-x64': 0.24.0
|
||||
|
||||
oxlint-tsgolint@0.11.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.11.0
|
||||
|
|
@ -8291,6 +8289,8 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinypool@2.0.0: {}
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
|
|
|
|||
|
|
@ -6,15 +6,9 @@ import { resolveUserPath } from "../utils.js";
|
|||
|
||||
export function resolveClawdbotAgentDir(): string {
|
||||
const override =
|
||||
process.env.CLAWDBOT_AGENT_DIR?.trim() ||
|
||||
process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (override) return resolveUserPath(override);
|
||||
const defaultAgentDir = path.join(
|
||||
resolveStateDir(),
|
||||
"agents",
|
||||
DEFAULT_AGENT_ID,
|
||||
"agent",
|
||||
);
|
||||
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
|
||||
return resolveUserPath(defaultAgentDir);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,12 +72,8 @@ describe("resolveAgentConfig", () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
|
||||
"anthropic/claude-opus-4",
|
||||
);
|
||||
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
|
||||
"openai/gpt-5.2",
|
||||
]);
|
||||
expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
|
||||
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
|
||||
|
||||
// If fallbacks isn't present, we don't override the global fallbacks.
|
||||
const cfgNoOverride: ClawdbotConfig = {
|
||||
|
|
@ -92,9 +88,7 @@ describe("resolveAgentConfig", () => {
|
|||
],
|
||||
},
|
||||
};
|
||||
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined);
|
||||
|
||||
// Explicit empty list disables global fallbacks for that agent.
|
||||
const cfgDisable: ClawdbotConfig = {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
|
|||
|
||||
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
type AgentEntry = NonNullable<
|
||||
NonNullable<ClawdbotConfig["agents"]>["list"]
|
||||
>[number];
|
||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||
|
||||
type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
|
|
@ -36,9 +34,7 @@ let defaultAgentWarned = false;
|
|||
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((entry): entry is AgentEntry =>
|
||||
Boolean(entry && typeof entry === "object"),
|
||||
);
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
|
||||
|
|
@ -47,24 +43,20 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
|
|||
const defaults = agents.filter((agent) => agent?.default);
|
||||
if (defaults.length > 1 && !defaultAgentWarned) {
|
||||
defaultAgentWarned = true;
|
||||
console.warn(
|
||||
"Multiple agents marked default=true; using the first entry as default.",
|
||||
);
|
||||
console.warn("Multiple agents marked default=true; using the first entry as default.");
|
||||
}
|
||||
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
|
||||
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function resolveSessionAgentIds(params: {
|
||||
sessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): { defaultAgentId: string; sessionAgentId: string } {
|
||||
export function resolveSessionAgentIds(params: { sessionKey?: string; config?: ClawdbotConfig }): {
|
||||
defaultAgentId: string;
|
||||
sessionAgentId: string;
|
||||
} {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
|
||||
const sessionAgentId = parsed?.agentId
|
||||
? normalizeAgentId(parsed.agentId)
|
||||
: defaultAgentId;
|
||||
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
|
||||
return { defaultAgentId, sessionAgentId };
|
||||
}
|
||||
|
||||
|
|
@ -75,10 +67,7 @@ export function resolveSessionAgentId(params: {
|
|||
return resolveSessionAgentIds(params).sessionAgentId;
|
||||
}
|
||||
|
||||
function resolveAgentEntry(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): AgentEntry | undefined {
|
||||
function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
|
@ -92,31 +81,23 @@ export function resolveAgentConfig(
|
|||
if (!entry) return undefined;
|
||||
return {
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
workspace:
|
||||
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
|
||||
model:
|
||||
typeof entry.model === "string" ||
|
||||
(entry.model && typeof entry.model === "object")
|
||||
typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
|
||||
? entry.model
|
||||
: undefined,
|
||||
memorySearch: entry.memorySearch,
|
||||
humanDelay: entry.humanDelay,
|
||||
identity: entry.identity,
|
||||
groupChat: entry.groupChat,
|
||||
subagents:
|
||||
typeof entry.subagents === "object" && entry.subagents
|
||||
? entry.subagents
|
||||
: undefined,
|
||||
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
||||
sandbox: entry.sandbox,
|
||||
tools: entry.tools,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentModelPrimary(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string | undefined {
|
||||
export function resolveAgentModelPrimary(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
||||
if (!raw) return undefined;
|
||||
if (typeof raw === "string") return raw.trim() || undefined;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANTHROPIC_SETUP_TOKEN_PREFIX,
|
||||
|
|
@ -26,15 +23,11 @@ import { ensureClawdbotModelsJson } from "./models-config.js";
|
|||
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE =
|
||||
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE =
|
||||
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
const SETUP_TOKEN_MODEL =
|
||||
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
const SETUP_TOKEN_MODEL = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
|
||||
|
||||
const ENABLED =
|
||||
LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
|
||||
const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
|
||||
const describeLive = ENABLED ? describe : describe.skip;
|
||||
|
||||
type TokenSource = {
|
||||
|
|
@ -60,11 +53,7 @@ function listSetupTokenProfiles(store: {
|
|||
}
|
||||
|
||||
function pickSetupTokenProfile(candidates: string[]): string {
|
||||
const preferred = [
|
||||
"anthropic:setup-token-test",
|
||||
"anthropic:setup-token",
|
||||
"anthropic:default",
|
||||
];
|
||||
const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
|
||||
for (const id of preferred) {
|
||||
if (candidates.includes(id)) return id;
|
||||
}
|
||||
|
|
@ -73,17 +62,14 @@ function pickSetupTokenProfile(candidates: string[]): string {
|
|||
|
||||
async function resolveTokenSource(): Promise<TokenSource> {
|
||||
const explicitToken =
|
||||
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") ||
|
||||
SETUP_TOKEN_VALUE;
|
||||
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
|
||||
|
||||
if (explicitToken) {
|
||||
const error = validateAnthropicSetupToken(explicitToken);
|
||||
if (error) {
|
||||
throw new Error(`Invalid setup-token: ${error}`);
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-setup-token-"),
|
||||
);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-setup-token-"));
|
||||
const profileId = `anthropic:setup-token-live-${randomUUID()}`;
|
||||
const store = ensureAuthProfileStore(tempDir, {
|
||||
allowKeychainPrompt: false,
|
||||
|
|
@ -111,8 +97,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
|
|||
const candidates = listSetupTokenProfiles(store);
|
||||
if (SETUP_TOKEN_PROFILE) {
|
||||
if (!candidates.includes(SETUP_TOKEN_PROFILE)) {
|
||||
const available =
|
||||
candidates.length > 0 ? candidates.join(", ") : "(none)";
|
||||
const available = candidates.length > 0 ? candidates.join(", ") : "(none)";
|
||||
throw new Error(
|
||||
`Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`,
|
||||
);
|
||||
|
|
@ -120,11 +105,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
|
|||
return { agentDir, profileId: SETUP_TOKEN_PROFILE };
|
||||
}
|
||||
|
||||
if (
|
||||
SETUP_TOKEN_RAW &&
|
||||
SETUP_TOKEN_RAW !== "1" &&
|
||||
SETUP_TOKEN_RAW !== "auto"
|
||||
) {
|
||||
if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
|
||||
throw new Error(
|
||||
"CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
|
||||
);
|
||||
|
|
@ -146,8 +127,7 @@ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
|
|||
return (
|
||||
models.find(
|
||||
(model) =>
|
||||
normalizeProviderId(model.provider) === parsed.provider &&
|
||||
model.id === parsed.model,
|
||||
normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
|
@ -176,9 +156,7 @@ describeLive("live anthropic setup-token", () => {
|
|||
|
||||
const authStorage = discoverAuthStorage(tokenSource.agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
|
||||
const all = Array.isArray(modelRegistry)
|
||||
? modelRegistry
|
||||
: modelRegistry.getAll();
|
||||
const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll();
|
||||
const candidates = all.filter(
|
||||
(model) => normalizeProviderId(model.provider) === "anthropic",
|
||||
) as Array<Model<Api>>;
|
||||
|
|
@ -201,9 +179,7 @@ describeLive("live anthropic setup-token", () => {
|
|||
});
|
||||
const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey);
|
||||
if (tokenError) {
|
||||
throw new Error(
|
||||
`Resolved profile is not a setup-token: ${tokenError}`,
|
||||
);
|
||||
throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
|
||||
}
|
||||
|
||||
const res = await completeSimple(
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ export async function applyUpdateHunk(
|
|||
});
|
||||
|
||||
const originalLines = originalContents.split("\n");
|
||||
if (
|
||||
originalLines.length > 0 &&
|
||||
originalLines[originalLines.length - 1] === ""
|
||||
) {
|
||||
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
|
||||
originalLines.pop();
|
||||
}
|
||||
|
||||
|
|
@ -41,24 +38,16 @@ function computeReplacements(
|
|||
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.changeContext) {
|
||||
const ctxIndex = seekSequence(
|
||||
originalLines,
|
||||
[chunk.changeContext],
|
||||
lineIndex,
|
||||
false,
|
||||
);
|
||||
const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
||||
if (ctxIndex === null) {
|
||||
throw new Error(
|
||||
`Failed to find context '${chunk.changeContext}' in ${filePath}`,
|
||||
);
|
||||
throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
|
||||
}
|
||||
lineIndex = ctxIndex + 1;
|
||||
}
|
||||
|
||||
if (chunk.oldLines.length === 0) {
|
||||
const insertionIndex =
|
||||
originalLines.length > 0 &&
|
||||
originalLines[originalLines.length - 1] === ""
|
||||
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
|
||||
? originalLines.length - 1
|
||||
: originalLines.length;
|
||||
replacements.push([insertionIndex, 0, chunk.newLines]);
|
||||
|
|
@ -67,24 +56,14 @@ function computeReplacements(
|
|||
|
||||
let pattern = chunk.oldLines;
|
||||
let newSlice = chunk.newLines;
|
||||
let found = seekSequence(
|
||||
originalLines,
|
||||
pattern,
|
||||
lineIndex,
|
||||
chunk.isEndOfFile,
|
||||
);
|
||||
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
||||
|
||||
if (found === null && pattern[pattern.length - 1] === "") {
|
||||
pattern = pattern.slice(0, -1);
|
||||
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
||||
newSlice = newSlice.slice(0, -1);
|
||||
}
|
||||
found = seekSequence(
|
||||
originalLines,
|
||||
pattern,
|
||||
lineIndex,
|
||||
chunk.isEndOfFile,
|
||||
);
|
||||
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
||||
}
|
||||
|
||||
if (found === null) {
|
||||
|
|
@ -142,11 +121,7 @@ function seekSequence(
|
|||
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
|
||||
}
|
||||
for (let i = searchStart; i <= maxStart; i += 1) {
|
||||
if (
|
||||
linesMatch(lines, pattern, i, (value) =>
|
||||
normalizePunctuation(value.trim()),
|
||||
)
|
||||
) {
|
||||
if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,10 +282,7 @@ function checkPatchBoundariesLenient(lines: string[]): string[] {
|
|||
}
|
||||
const first = lines[0];
|
||||
const last = lines[lines.length - 1];
|
||||
if (
|
||||
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
|
||||
last.endsWith("EOF")
|
||||
) {
|
||||
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
|
||||
const inner = lines.slice(1, lines.length - 1);
|
||||
const innerError = checkPatchBoundariesStrict(inner);
|
||||
if (!innerError) return inner;
|
||||
|
|
@ -308,10 +305,7 @@ function checkPatchBoundariesStrict(lines: string[]): string | null {
|
|||
return "The last line of the patch must be '*** End Patch'";
|
||||
}
|
||||
|
||||
function parseOneHunk(
|
||||
lines: string[],
|
||||
lineNumber: number,
|
||||
): { hunk: Hunk; consumed: number } {
|
||||
function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
} from "./auth-health.js";
|
||||
import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
|
||||
|
||||
describe("buildAuthHealthSummary", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
|
|
@ -59,9 +56,7 @@ describe("buildAuthHealthSummary", () => {
|
|||
expect(statuses["anthropic:expired"]).toBe("expired");
|
||||
expect(statuses["anthropic:api"]).toBe("static");
|
||||
|
||||
const provider = summary.providers.find(
|
||||
(entry) => entry.provider === "anthropic",
|
||||
);
|
||||
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
|
||||
expect(provider?.status).toBe("expired");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,12 +9,7 @@ import {
|
|||
|
||||
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
|
||||
|
||||
export type AuthProfileHealthStatus =
|
||||
| "ok"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "missing"
|
||||
| "static";
|
||||
export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
|
||||
|
||||
export type AuthProfileHealth = {
|
||||
profileId: string;
|
||||
|
|
@ -27,12 +22,7 @@ export type AuthProfileHealth = {
|
|||
label: string;
|
||||
};
|
||||
|
||||
export type AuthProviderHealthStatus =
|
||||
| "ok"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "missing"
|
||||
| "static";
|
||||
export type AuthProviderHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
|
||||
|
||||
export type AuthProviderHealth = {
|
||||
provider: string;
|
||||
|
|
@ -111,8 +101,7 @@ function buildProfileHealth(params: {
|
|||
|
||||
if (credential.type === "token") {
|
||||
const expiresAt =
|
||||
typeof credential.expires === "number" &&
|
||||
Number.isFinite(credential.expires)
|
||||
typeof credential.expires === "number" && Number.isFinite(credential.expires)
|
||||
? credential.expires
|
||||
: undefined;
|
||||
if (!expiresAt || expiresAt <= 0) {
|
||||
|
|
@ -125,11 +114,7 @@ function buildProfileHealth(params: {
|
|||
label,
|
||||
};
|
||||
}
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
expiresAt,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
const { status, remainingMs } = resolveOAuthStatus(expiresAt, now, warnAfterMs);
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
|
|
@ -142,11 +127,7 @@ function buildProfileHealth(params: {
|
|||
};
|
||||
}
|
||||
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
credential.expires,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs);
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
|
|
@ -172,9 +153,7 @@ export function buildAuthHealthSummary(params: {
|
|||
: null;
|
||||
|
||||
const profiles = Object.entries(params.store.profiles)
|
||||
.filter(([_, cred]) =>
|
||||
providerFilter ? providerFilter.has(cred.provider) : true,
|
||||
)
|
||||
.filter(([_, cred]) => (providerFilter ? providerFilter.has(cred.provider) : true))
|
||||
.map(([profileId, credential]) =>
|
||||
buildProfileHealth({
|
||||
profileId,
|
||||
|
|
@ -226,9 +205,7 @@ export function buildAuthHealthSummary(params: {
|
|||
|
||||
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
||||
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
|
||||
const apiKeyProfiles = provider.profiles.filter(
|
||||
(p) => p.type === "api_key",
|
||||
);
|
||||
const apiKeyProfiles = provider.profiles.filter((p) => p.type === "api_key");
|
||||
|
||||
const expirable = [...oauthProfiles, ...tokenProfiles];
|
||||
if (expirable.length === 0) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ import {
|
|||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
} from "./auth-profiles.js";
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
type ChutesStoredOAuth,
|
||||
} from "./chutes-oauth.js";
|
||||
import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
|
||||
|
||||
describe("auth-profiles (chutes)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
|
|
@ -30,32 +27,19 @@ describe("auth-profiles (chutes)", () => {
|
|||
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;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
if (previousChutesClientId === undefined)
|
||||
delete process.env.CHUTES_CLIENT_ID;
|
||||
if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
|
||||
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
});
|
||||
|
||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(
|
||||
tempDir,
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
);
|
||||
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",
|
||||
);
|
||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
|
|
@ -75,8 +59,7 @@ describe("auth-profiles (chutes)", () => {
|
|||
|
||||
const fetchSpy = vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||
return new Response("not found", { status: 404 });
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
|
|
@ -96,9 +79,7 @@ describe("auth-profiles (chutes)", () => {
|
|||
expect(resolved?.apiKey).toBe("at_new");
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await fs.readFile(authProfilePath, "utf8"),
|
||||
) as {
|
||||
const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
|
||||
profiles?: Record<string, { access?: string }>;
|
||||
};
|
||||
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import { ensureAuthProfileStore } from "./auth-profiles.js";
|
|||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-auth-profiles-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-profiles-"));
|
||||
try {
|
||||
const legacyPath = path.join(agentDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
|
|
|
|||
|
|
@ -3,16 +3,11 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("does not overwrite API keys when syncing external CLI creds", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-no-overwrite-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -26,10 +21,7 @@ describe("external CLI credential sync", () => {
|
|||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify(claudeCreds),
|
||||
);
|
||||
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
|
||||
|
||||
// Create auth-profiles.json with an API key
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
|
|
@ -50,9 +42,7 @@ describe("external CLI credential sync", () => {
|
|||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
// Should keep the store's API key and still add the CLI profile.
|
||||
expect(
|
||||
(store.profiles["anthropic:default"] as { key: string }).key,
|
||||
).toBe("sk-store");
|
||||
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-store");
|
||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
|
|
@ -62,9 +52,7 @@ describe("external CLI credential sync", () => {
|
|||
}
|
||||
});
|
||||
it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -103,9 +91,7 @@ describe("external CLI credential sync", () => {
|
|||
// OAuth should be preferred over token because it can auto-refresh
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("oauth");
|
||||
expect((cliProfile as { access: string }).access).toBe(
|
||||
"cli-oauth-access",
|
||||
);
|
||||
expect((cliProfile as { access: string }).access).toBe("cli-oauth-access");
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,11 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("does not overwrite fresher store oauth with older CLI oauth", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -52,9 +47,7 @@ describe("external CLI credential sync", () => {
|
|||
// Fresher store oauth should be kept
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("oauth");
|
||||
expect((cliProfile as { access: string }).access).toBe(
|
||||
"store-oauth-access",
|
||||
);
|
||||
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
@ -63,9 +56,7 @@ describe("external CLI credential sync", () => {
|
|||
}
|
||||
});
|
||||
it("does not downgrade store oauth to token when CLI lacks refresh token", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -104,9 +95,7 @@ describe("external CLI credential sync", () => {
|
|||
// Keep oauth to preserve auto-refresh capability
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("oauth");
|
||||
expect((cliProfile as { access: string }).access).toBe(
|
||||
"store-oauth-access",
|
||||
);
|
||||
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,11 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("syncs Claude CLI OAuth credentials into anthropic:claude-cli", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-sync-"));
|
||||
try {
|
||||
// Create a temp home with Claude CLI credentials
|
||||
await withTempHome(
|
||||
|
|
@ -27,10 +22,7 @@ describe("external CLI credential sync", () => {
|
|||
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify(claudeCreds),
|
||||
);
|
||||
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
|
||||
|
||||
// Create empty auth-profiles.json
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
|
|
@ -52,22 +44,14 @@ describe("external CLI credential sync", () => {
|
|||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles["anthropic:default"]).toBeDefined();
|
||||
expect(
|
||||
(store.profiles["anthropic:default"] as { key: string }).key,
|
||||
).toBe("sk-default");
|
||||
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-default");
|
||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||
// Should be stored as OAuth credential (type: "oauth") for auto-refresh
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("oauth");
|
||||
expect((cliProfile as { access: string }).access).toBe(
|
||||
"fresh-access-token",
|
||||
);
|
||||
expect((cliProfile as { refresh: string }).refresh).toBe(
|
||||
"fresh-refresh-token",
|
||||
);
|
||||
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(
|
||||
Date.now(),
|
||||
);
|
||||
expect((cliProfile as { access: string }).access).toBe("fresh-access-token");
|
||||
expect((cliProfile as { refresh: string }).refresh).toBe("fresh-refresh-token");
|
||||
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(Date.now());
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
@ -76,9 +60,7 @@ describe("external CLI credential sync", () => {
|
|||
}
|
||||
});
|
||||
it("syncs Claude CLI credentials without refreshToken as token type", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-token-sync-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-token-sync-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -92,16 +74,10 @@ describe("external CLI credential sync", () => {
|
|||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify(claudeCreds),
|
||||
);
|
||||
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({ version: 1, profiles: {} }),
|
||||
);
|
||||
fs.writeFileSync(authPath, JSON.stringify({ version: 1, profiles: {} }));
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
|
|
@ -109,9 +85,7 @@ describe("external CLI credential sync", () => {
|
|||
// Should be stored as token type (no refresh capability)
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("token");
|
||||
expect((cliProfile as { token: string }).token).toBe(
|
||||
"access-only-token",
|
||||
);
|
||||
expect((cliProfile as { token: string }).token).toBe("access-only-token");
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,11 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -48,10 +43,9 @@ describe("external CLI credential sync", () => {
|
|||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(
|
||||
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string })
|
||||
.refresh,
|
||||
).toBe("new-refresh");
|
||||
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh).toBe(
|
||||
"new-refresh",
|
||||
);
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import {
|
|||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("upgrades token to oauth when Claude CLI gets refreshToken", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-upgrade-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-upgrade-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -53,12 +51,8 @@ describe("external CLI credential sync", () => {
|
|||
// Should upgrade from token to oauth
|
||||
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
expect(cliProfile.type).toBe("oauth");
|
||||
expect((cliProfile as { access: string }).access).toBe(
|
||||
"new-oauth-access",
|
||||
);
|
||||
expect((cliProfile as { refresh: string }).refresh).toBe(
|
||||
"new-refresh-token",
|
||||
);
|
||||
expect((cliProfile as { access: string }).access).toBe("new-oauth-access");
|
||||
expect((cliProfile as { refresh: string }).refresh).toBe("new-refresh-token");
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
@ -67,9 +61,7 @@ describe("external CLI credential sync", () => {
|
|||
}
|
||||
});
|
||||
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
|
||||
);
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-sync-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
|
@ -98,9 +90,9 @@ describe("external CLI credential sync", () => {
|
|||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
|
||||
expect(
|
||||
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
|
||||
).toBe("codex-access-token");
|
||||
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
|
||||
"codex-access-token",
|
||||
);
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import fs from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
markAuthProfileFailure,
|
||||
} from "./auth-profiles.js";
|
||||
import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.js";
|
||||
|
||||
describe("markAuthProfileFailure", () => {
|
||||
it("disables billing failures for ~5 hours by default", async () => {
|
||||
|
|
@ -35,8 +32,7 @@ describe("markAuthProfileFailure", () => {
|
|||
agentDir,
|
||||
});
|
||||
|
||||
const disabledUntil =
|
||||
store.usageStats?.["anthropic:default"]?.disabledUntil;
|
||||
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
|
||||
expect(typeof disabledUntil).toBe("number");
|
||||
const remainingMs = (disabledUntil as number) - startedAt;
|
||||
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
|
||||
|
|
@ -80,8 +76,7 @@ describe("markAuthProfileFailure", () => {
|
|||
} as never,
|
||||
});
|
||||
|
||||
const disabledUntil =
|
||||
store.usageStats?.["anthropic:default"]?.disabledUntil;
|
||||
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
|
||||
expect(typeof disabledUntil).toBe("number");
|
||||
const remainingMs = (disabledUntil as number) - startedAt;
|
||||
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
|
||||
|
|
@ -128,9 +123,7 @@ describe("markAuthProfileFailure", () => {
|
|||
});
|
||||
|
||||
expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(1);
|
||||
expect(
|
||||
store.usageStats?.["anthropic:default"]?.failureCounts?.billing,
|
||||
).toBe(1);
|
||||
expect(store.usageStats?.["anthropic:default"]?.failureCounts?.billing).toBe(1);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,10 +91,6 @@ describe("resolveAuthProfileOrder", () => {
|
|||
},
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual([
|
||||
"anthropic:ready",
|
||||
"anthropic:cool2",
|
||||
"anthropic:cool1",
|
||||
]);
|
||||
expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
export {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
} from "./auth-profiles/constants.js";
|
||||
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
|
||||
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
|
||||
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
|
||||
export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ export function resolveAuthProfileDisplayLabel(params: {
|
|||
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
|
||||
const email =
|
||||
configEmail ||
|
||||
(profile && "email" in profile
|
||||
? (profile.email as string | undefined)?.trim()
|
||||
: undefined);
|
||||
(profile && "email" in profile ? (profile.email as string | undefined)?.trim() : undefined);
|
||||
if (email) return `${profileId} (${email})`;
|
||||
return profileId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ export function formatAuthDoctorHint(params: {
|
|||
"Doctor hint (for GitHub issue):",
|
||||
`- provider: ${providerKey}`,
|
||||
`- config: ${legacyProfileId}${
|
||||
cfgProvider || cfgMode
|
||||
? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})`
|
||||
: ""
|
||||
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
|
||||
}`,
|
||||
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
|
||||
`- suggested profile: ${suggested}`,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ import type {
|
|||
TokenCredential,
|
||||
} from "./types.js";
|
||||
|
||||
function shallowEqualOAuthCredentials(
|
||||
a: OAuthCredential | undefined,
|
||||
b: OAuthCredential,
|
||||
): boolean {
|
||||
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "oauth") return false;
|
||||
return (
|
||||
|
|
@ -34,10 +31,7 @@ function shallowEqualOAuthCredentials(
|
|||
);
|
||||
}
|
||||
|
||||
function shallowEqualTokenCredentials(
|
||||
a: TokenCredential | undefined,
|
||||
b: TokenCredential,
|
||||
): boolean {
|
||||
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "token") return false;
|
||||
return (
|
||||
|
|
@ -48,10 +42,7 @@ function shallowEqualTokenCredentials(
|
|||
);
|
||||
}
|
||||
|
||||
function isExternalProfileFresh(
|
||||
cred: AuthProfileCredential | undefined,
|
||||
now: number,
|
||||
): boolean {
|
||||
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
|
||||
if (!cred) return false;
|
||||
if (cred.type !== "oauth" && cred.type !== "token") return false;
|
||||
if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") {
|
||||
|
|
@ -104,8 +95,7 @@ export function syncExternalCliCredentials(
|
|||
!existingOAuth ||
|
||||
existingOAuth.provider !== "anthropic" ||
|
||||
existingOAuth.expires <= now ||
|
||||
(claudeCredsExpires > now &&
|
||||
claudeCredsExpires > existingOAuth.expires);
|
||||
(claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires);
|
||||
} else {
|
||||
const existingToken = existing?.type === "token" ? existing : undefined;
|
||||
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
|
||||
|
|
@ -114,8 +104,7 @@ export function syncExternalCliCredentials(
|
|||
!existingToken ||
|
||||
existingToken.provider !== "anthropic" ||
|
||||
(existingToken.expires ?? 0) <= now ||
|
||||
(claudeCredsExpires > now &&
|
||||
claudeCredsExpires > (existingToken.expires ?? 0));
|
||||
(claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0));
|
||||
}
|
||||
|
||||
// Also update if credential type changed (token -> oauth upgrade)
|
||||
|
|
@ -166,10 +155,7 @@ export function syncExternalCliCredentials(
|
|||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
|
||||
if (
|
||||
shouldUpdate &&
|
||||
!shallowEqualOAuthCredentials(existingOAuth, codexCreds)
|
||||
) {
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
|
||||
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
|
||||
mutated = true;
|
||||
log.info("synced openai-codex credentials from codex cli", {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
getOAuthApiKey,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
|
|
@ -15,12 +11,8 @@ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
|||
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
function buildOAuthApiKey(
|
||||
provider: string,
|
||||
credentials: OAuthCredentials,
|
||||
): string {
|
||||
const needsProjectId =
|
||||
provider === "google-gemini-cli" || provider === "google-antigravity";
|
||||
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
|
||||
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
|
||||
return needsProjectId
|
||||
? JSON.stringify({
|
||||
token: credentials.access,
|
||||
|
|
@ -76,10 +68,7 @@ async function refreshOAuthTokenWithLock(params: {
|
|||
|
||||
// Sync refreshed credentials back to Claude CLI if this is the claude-cli profile
|
||||
// This ensures Claude Code continues to work after ClawdBot refreshes the token
|
||||
if (
|
||||
params.profileId === CLAUDE_CLI_PROFILE_ID &&
|
||||
cred.provider === "anthropic"
|
||||
) {
|
||||
if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
|
||||
writeClaudeCliCredentials(result.newCredentials);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,17 +43,12 @@ export function resolveAuthProfileOrder(params: {
|
|||
const explicitOrder = storedOrder ?? configuredOrder;
|
||||
const explicitProfiles = cfg?.auth?.profiles
|
||||
? Object.entries(cfg.auth.profiles)
|
||||
.filter(
|
||||
([, profile]) =>
|
||||
normalizeProviderId(profile.provider) === providerKey,
|
||||
)
|
||||
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
|
||||
.map(([profileId]) => profileId)
|
||||
: [];
|
||||
const baseOrder =
|
||||
explicitOrder ??
|
||||
(explicitProfiles.length > 0
|
||||
? explicitProfiles
|
||||
: listProfilesForProvider(store, providerKey));
|
||||
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
|
||||
if (baseOrder.length === 0) return [];
|
||||
|
||||
const filtered = baseOrder.filter((profileId) => {
|
||||
|
|
@ -66,8 +61,7 @@ export function resolveAuthProfileOrder(params: {
|
|||
return false;
|
||||
}
|
||||
if (profileConfig.mode !== cred.type) {
|
||||
const oauthCompatible =
|
||||
profileConfig.mode === "oauth" && cred.type === "token";
|
||||
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
|
||||
if (!oauthCompatible) return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -104,8 +98,7 @@ export function resolveAuthProfileOrder(params: {
|
|||
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
|
||||
|
||||
for (const profileId of deduped) {
|
||||
const cooldownUntil =
|
||||
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
|
||||
const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
|
||||
if (
|
||||
typeof cooldownUntil === "number" &&
|
||||
Number.isFinite(cooldownUntil) &&
|
||||
|
|
@ -126,10 +119,7 @@ export function resolveAuthProfileOrder(params: {
|
|||
|
||||
// Still put preferredProfile first if specified
|
||||
if (preferredProfile && ordered.includes(preferredProfile)) {
|
||||
return [
|
||||
preferredProfile,
|
||||
...ordered.filter((e) => e !== preferredProfile),
|
||||
];
|
||||
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
|
@ -146,10 +136,7 @@ export function resolveAuthProfileOrder(params: {
|
|||
return sorted;
|
||||
}
|
||||
|
||||
function orderProfilesByMode(
|
||||
order: string[],
|
||||
store: AuthProfileStore,
|
||||
): string[] {
|
||||
function orderProfilesByMode(order: string[], store: AuthProfileStore): string[] {
|
||||
const now = Date.now();
|
||||
|
||||
// Partition into available and in-cooldown
|
||||
|
|
@ -168,8 +155,7 @@ function orderProfilesByMode(
|
|||
// Then by lastUsed (oldest first = round-robin within type)
|
||||
const scored = available.map((profileId) => {
|
||||
const type = store.profiles[profileId]?.type;
|
||||
const typeScore =
|
||||
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
|
||||
const typeScore = type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
|
||||
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
|
||||
return { profileId, typeScore, lastUsed };
|
||||
});
|
||||
|
|
@ -189,8 +175,7 @@ function orderProfilesByMode(
|
|||
const cooldownSorted = inCooldown
|
||||
.map((profileId) => ({
|
||||
profileId,
|
||||
cooldownUntil:
|
||||
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
|
||||
cooldownUntil: resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
|
||||
}))
|
||||
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
|
||||
.map((entry) => entry.profileId);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ import path from "node:path";
|
|||
import { saveJsonFile } from "../../infra/json-file.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import {
|
||||
AUTH_PROFILE_FILENAME,
|
||||
AUTH_STORE_VERSION,
|
||||
LEGACY_AUTH_FILENAME,
|
||||
} from "./constants.js";
|
||||
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
export function resolveAuthStorePath(agentDir?: string): string {
|
||||
|
|
|
|||
|
|
@ -50,10 +50,7 @@ export function upsertAuthProfile(params: {
|
|||
saveAuthProfileStore(store, params.agentDir);
|
||||
}
|
||||
|
||||
export function listProfilesForProvider(
|
||||
store: AuthProfileStore,
|
||||
provider: string,
|
||||
): string[] {
|
||||
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
return Object.entries(store.profiles)
|
||||
.filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)
|
||||
|
|
|
|||
|
|
@ -35,10 +35,9 @@ export function suggestOAuthProfileIdForLegacyDefault(params: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const oauthProfiles = listProfilesForProvider(
|
||||
params.store,
|
||||
providerKey,
|
||||
).filter((id) => params.store.profiles[id]?.type === "oauth");
|
||||
const oauthProfiles = listProfilesForProvider(params.store, providerKey).filter(
|
||||
(id) => params.store.profiles[id]?.type === "oauth",
|
||||
);
|
||||
if (oauthProfiles.length === 0) return null;
|
||||
|
||||
const configuredEmail = legacyCfg?.email?.trim();
|
||||
|
|
@ -47,16 +46,12 @@ export function suggestOAuthProfileIdForLegacyDefault(params: {
|
|||
const cred = params.store.profiles[id];
|
||||
if (!cred || cred.type !== "oauth") return false;
|
||||
const email = (cred.email as string | undefined)?.trim();
|
||||
return (
|
||||
email === configuredEmail || id === `${providerKey}:${configuredEmail}`
|
||||
);
|
||||
return email === configuredEmail || id === `${providerKey}:${configuredEmail}`;
|
||||
});
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
const lastGood =
|
||||
params.store.lastGood?.[providerKey] ??
|
||||
params.store.lastGood?.[params.provider];
|
||||
const lastGood = params.store.lastGood?.[providerKey] ?? params.store.lastGood?.[params.provider];
|
||||
if (lastGood && oauthProfiles.includes(lastGood)) return lastGood;
|
||||
|
||||
const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId);
|
||||
|
|
@ -83,10 +78,7 @@ export function repairOAuthProfileIdMismatch(params: {
|
|||
if (legacyCfg.mode !== "oauth") {
|
||||
return { config: params.cfg, changes: [], migrated: false };
|
||||
}
|
||||
if (
|
||||
normalizeProviderId(legacyCfg.provider) !==
|
||||
normalizeProviderId(params.provider)
|
||||
) {
|
||||
if (normalizeProviderId(legacyCfg.provider) !== normalizeProviderId(params.provider)) {
|
||||
return { config: params.cfg, changes: [], migrated: false };
|
||||
}
|
||||
|
||||
|
|
@ -102,14 +94,10 @@ export function repairOAuthProfileIdMismatch(params: {
|
|||
|
||||
const toCred = params.store.profiles[toProfileId];
|
||||
const toEmail =
|
||||
toCred?.type === "oauth"
|
||||
? (toCred.email as string | undefined)?.trim()
|
||||
: undefined;
|
||||
toCred?.type === "oauth" ? (toCred.email as string | undefined)?.trim() : undefined;
|
||||
|
||||
const nextProfiles = {
|
||||
...(params.cfg.auth?.profiles as
|
||||
| Record<string, AuthProfileConfig>
|
||||
| undefined),
|
||||
...(params.cfg.auth?.profiles as Record<string, AuthProfileConfig> | undefined),
|
||||
} as Record<string, AuthProfileConfig>;
|
||||
delete nextProfiles[legacyProfileId];
|
||||
nextProfiles[toProfileId] = {
|
||||
|
|
@ -121,17 +109,13 @@ export function repairOAuthProfileIdMismatch(params: {
|
|||
const nextOrder = (() => {
|
||||
const order = params.cfg.auth?.order;
|
||||
if (!order) return undefined;
|
||||
const resolvedKey = Object.keys(order).find(
|
||||
(key) => normalizeProviderId(key) === providerKey,
|
||||
);
|
||||
const resolvedKey = Object.keys(order).find((key) => normalizeProviderId(key) === providerKey);
|
||||
if (!resolvedKey) return order;
|
||||
const existing = order[resolvedKey];
|
||||
if (!Array.isArray(existing)) return order;
|
||||
const replaced = existing
|
||||
.map((id) => (id === legacyProfileId ? toProfileId : id))
|
||||
.filter(
|
||||
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||
);
|
||||
.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
||||
const deduped: string[] = [];
|
||||
for (const entry of replaced) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
|
|
@ -148,9 +132,7 @@ export function repairOAuthProfileIdMismatch(params: {
|
|||
},
|
||||
};
|
||||
|
||||
const changes = [
|
||||
`Auth: migrate ${legacyProfileId} → ${toProfileId} (OAuth profile id)`,
|
||||
];
|
||||
const changes = [`Auth: migrate ${legacyProfileId} → ${toProfileId} (OAuth profile id)`];
|
||||
|
||||
return {
|
||||
config: nextCfg,
|
||||
|
|
|
|||
|
|
@ -3,29 +3,14 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
|||
import lockfile from "proper-lockfile";
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import {
|
||||
AUTH_STORE_LOCK_OPTIONS,
|
||||
AUTH_STORE_VERSION,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
|
||||
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import {
|
||||
ensureAuthStoreFile,
|
||||
resolveAuthStorePath,
|
||||
resolveLegacyAuthStorePath,
|
||||
} from "./paths.js";
|
||||
import type {
|
||||
AuthProfileCredential,
|
||||
AuthProfileStore,
|
||||
ProfileUsageStats,
|
||||
} from "./types.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
function _syncAuthProfileStore(
|
||||
target: AuthProfileStore,
|
||||
source: AuthProfileStore,
|
||||
): void {
|
||||
function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void {
|
||||
target.version = source.version;
|
||||
target.profiles = source.profiles;
|
||||
target.order = source.order;
|
||||
|
|
@ -70,11 +55,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
|||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
|
||||
continue;
|
||||
}
|
||||
entries[key] = {
|
||||
|
|
@ -94,11 +75,7 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
|||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
|
||||
continue;
|
||||
}
|
||||
if (!typed.provider) continue;
|
||||
|
|
@ -188,9 +165,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
|||
type: "token",
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
|
|
@ -253,9 +228,7 @@ export function ensureAuthProfileStore(
|
|||
type: "token",
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
|
|
@ -301,10 +274,7 @@ export function ensureAuthProfileStore(
|
|||
return store;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(
|
||||
store: AuthProfileStore,
|
||||
agentDir?: string,
|
||||
): void {
|
||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
|
|
|
|||
|
|
@ -29,10 +29,7 @@ export type OAuthCredential = OAuthCredentials & {
|
|||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthProfileCredential =
|
||||
| ApiKeyCredential
|
||||
| TokenCredential
|
||||
| OAuthCredential;
|
||||
export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential;
|
||||
|
||||
export type AuthProfileFailureReason =
|
||||
| "auth"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import {
|
||||
saveAuthProfileStore,
|
||||
updateAuthProfileStoreWithLock,
|
||||
} from "./store.js";
|
||||
import type {
|
||||
AuthProfileFailureReason,
|
||||
AuthProfileStore,
|
||||
ProfileUsageStats,
|
||||
} from "./types.js";
|
||||
import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
|
||||
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
|
||||
const values = [stats.cooldownUntil, stats.disabledUntil]
|
||||
|
|
@ -21,10 +14,7 @@ function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
|
|||
/**
|
||||
* Check if a profile is currently in cooldown (due to rate limiting or errors).
|
||||
*/
|
||||
export function isProfileInCooldown(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
): boolean {
|
||||
export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
|
||||
const stats = store.usageStats?.[profileId];
|
||||
if (!stats) return false;
|
||||
const unusableUntil = resolveProfileUnusableUntil(stats);
|
||||
|
|
@ -102,9 +92,7 @@ function resolveAuthCooldownConfig(params: {
|
|||
} as const;
|
||||
|
||||
const resolveHours = (value: unknown, fallback: number) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: fallback;
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
|
||||
const cooldowns = params.cfg?.auth?.cooldowns;
|
||||
const billingOverride = (() => {
|
||||
|
|
@ -120,10 +108,7 @@ function resolveAuthCooldownConfig(params: {
|
|||
billingOverride ?? cooldowns?.billingBackoffHours,
|
||||
defaults.billingBackoffHours,
|
||||
);
|
||||
const billingMaxHours = resolveHours(
|
||||
cooldowns?.billingMaxHours,
|
||||
defaults.billingMaxHours,
|
||||
);
|
||||
const billingMaxHours = resolveHours(cooldowns?.billingMaxHours, defaults.billingMaxHours);
|
||||
const failureWindowHours = resolveHours(
|
||||
cooldowns?.failureWindowHours,
|
||||
defaults.failureWindowHours,
|
||||
|
|
@ -172,9 +157,7 @@ function computeNextProfileUsageStats(params: {
|
|||
|
||||
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
|
||||
const nextErrorCount = baseErrorCount + 1;
|
||||
const failureCounts = windowExpired
|
||||
? {}
|
||||
: { ...params.existing.failureCounts };
|
||||
const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
|
||||
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
|
||||
|
||||
const updatedStats: ProfileUsageStats = {
|
||||
|
|
@ -246,9 +229,7 @@ export async function markAuthProfileFailure(params: {
|
|||
store.usageStats = store.usageStats ?? {};
|
||||
const existing = store.usageStats[profileId] ?? {};
|
||||
const now = Date.now();
|
||||
const providerKey = normalizeProviderId(
|
||||
store.profiles[profileId]?.provider ?? "",
|
||||
);
|
||||
const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
|
||||
const cfgResolved = resolveAuthCooldownConfig({
|
||||
cfg,
|
||||
providerId: providerKey,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ function clampTtl(value: number | undefined) {
|
|||
return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
|
||||
}
|
||||
|
||||
let jobTtlMs = clampTtl(
|
||||
Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10),
|
||||
);
|
||||
let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10));
|
||||
|
||||
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
|
||||
|
||||
|
|
@ -75,24 +73,15 @@ export function deleteSession(id: string) {
|
|||
finishedSessions.delete(id);
|
||||
}
|
||||
|
||||
export function appendOutput(
|
||||
session: ProcessSession,
|
||||
stream: "stdout" | "stderr",
|
||||
chunk: string,
|
||||
) {
|
||||
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
|
||||
session.pendingStdout ??= [];
|
||||
session.pendingStderr ??= [];
|
||||
const buffer =
|
||||
stream === "stdout" ? session.pendingStdout : session.pendingStderr;
|
||||
const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
|
||||
buffer.push(chunk);
|
||||
session.totalOutputChars += chunk.length;
|
||||
const aggregated = trimWithCap(
|
||||
session.aggregated + chunk,
|
||||
session.maxOutputChars,
|
||||
);
|
||||
const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
|
||||
session.truncated =
|
||||
session.truncated ||
|
||||
aggregated.length < session.aggregated.length + chunk.length;
|
||||
session.truncated || aggregated.length < session.aggregated.length + chunk.length;
|
||||
session.aggregated = aggregated;
|
||||
session.tail = tail(session.aggregated, 2000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
|||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
addSession,
|
||||
appendOutput,
|
||||
markBackgrounded,
|
||||
markExited,
|
||||
} from "./bash-process-registry.js";
|
||||
import { addSession, appendOutput, markBackgrounded, markExited } from "./bash-process-registry.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import {
|
||||
buildDockerExecArgs,
|
||||
|
|
@ -32,8 +27,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
|||
150_000,
|
||||
);
|
||||
const DEFAULT_PATH =
|
||||
process.env.PATH ??
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
export type ExecToolDefaults = {
|
||||
backgroundMs?: number;
|
||||
|
|
@ -55,18 +49,14 @@ export type ExecElevatedDefaults = {
|
|||
|
||||
const execSchema = Type.Object({
|
||||
command: Type.String({ description: "Shell command to execute" }),
|
||||
workdir: Type.Optional(
|
||||
Type.String({ description: "Working directory (defaults to cwd)" }),
|
||||
),
|
||||
workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
|
||||
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
yieldMs: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Milliseconds to wait before backgrounding (default 10000)",
|
||||
}),
|
||||
),
|
||||
background: Type.Optional(
|
||||
Type.Boolean({ description: "Run in background immediately" }),
|
||||
),
|
||||
background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
|
||||
timeout: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Timeout in seconds (optional, kills process on expiry)",
|
||||
|
|
@ -140,19 +130,12 @@ export function createExecTool(
|
|||
const backgroundRequested = params.background === true;
|
||||
const yieldRequested = typeof params.yieldMs === "number";
|
||||
if (!allowBackground && (backgroundRequested || yieldRequested)) {
|
||||
warnings.push(
|
||||
"Warning: background execution is disabled; running synchronously.",
|
||||
);
|
||||
warnings.push("Warning: background execution is disabled; running synchronously.");
|
||||
}
|
||||
const yieldWindow = allowBackground
|
||||
? backgroundRequested
|
||||
? 0
|
||||
: clampNumber(
|
||||
params.yieldMs ?? defaultBackgroundMs,
|
||||
defaultBackgroundMs,
|
||||
10,
|
||||
120_000,
|
||||
)
|
||||
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
||||
: null;
|
||||
const elevatedDefaults = defaults?.elevated;
|
||||
const elevatedDefaultOn =
|
||||
|
|
@ -160,17 +143,13 @@ export function createExecTool(
|
|||
elevatedDefaults.enabled &&
|
||||
elevatedDefaults.allowed;
|
||||
const elevatedRequested =
|
||||
typeof params.elevated === "boolean"
|
||||
? params.elevated
|
||||
: elevatedDefaultOn;
|
||||
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
|
||||
if (elevatedRequested) {
|
||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
||||
const gates: string[] = [];
|
||||
if (!elevatedDefaults?.enabled) {
|
||||
gates.push(
|
||||
"enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)",
|
||||
);
|
||||
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
|
||||
} else {
|
||||
gates.push(
|
||||
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)",
|
||||
|
|
@ -197,8 +176,7 @@ export function createExecTool(
|
|||
}
|
||||
|
||||
const sandbox = elevatedRequested ? undefined : defaults?.sandbox;
|
||||
const rawWorkdir =
|
||||
params.workdir?.trim() || defaults?.cwd || process.cwd();
|
||||
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
|
||||
let workdir = rawWorkdir;
|
||||
let containerWorkdir = sandbox?.containerWorkdir;
|
||||
if (sandbox) {
|
||||
|
|
@ -335,121 +313,111 @@ export function createExecTool(
|
|||
}
|
||||
});
|
||||
|
||||
return new Promise<AgentToolResult<ExecToolDetails>>(
|
||||
(resolve, reject) => {
|
||||
const resolveRunning = () => {
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
|
||||
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "running",
|
||||
sessionId,
|
||||
pid: session.pid ?? undefined,
|
||||
startedAt,
|
||||
cwd: session.cwd,
|
||||
tail: session.tail,
|
||||
return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
|
||||
const resolveRunning = () => {
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
|
||||
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
],
|
||||
details: {
|
||||
status: "running",
|
||||
sessionId,
|
||||
pid: session.pid ?? undefined,
|
||||
startedAt,
|
||||
cwd: session.cwd,
|
||||
tail: session.tail,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onYieldNow = () => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
};
|
||||
const onYieldNow = () => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
};
|
||||
|
||||
if (allowBackground && yieldWindow !== null) {
|
||||
if (yieldWindow === 0) {
|
||||
onYieldNow();
|
||||
} else {
|
||||
yieldTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
}, yieldWindow);
|
||||
}
|
||||
if (allowBackground && yieldWindow !== null) {
|
||||
if (yieldWindow === 0) {
|
||||
onYieldNow();
|
||||
} else {
|
||||
yieldTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
yielded = true;
|
||||
markBackgrounded(session);
|
||||
resolveRunning();
|
||||
}, yieldWindow);
|
||||
}
|
||||
}
|
||||
|
||||
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const wasSignal = exitSignal != null;
|
||||
const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut;
|
||||
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
|
||||
markExited(session, code, exitSignal, status);
|
||||
|
||||
if (yielded || session.backgrounded) return;
|
||||
|
||||
const aggregated = session.aggregated.trim();
|
||||
if (!isSuccess) {
|
||||
const reason = timedOut
|
||||
? `Command timed out after ${effectiveTimeout} seconds`
|
||||
: wasSignal && exitSignal
|
||||
? `Command aborted by signal ${exitSignal}`
|
||||
: code === null
|
||||
? "Command aborted before exit code was captured"
|
||||
: `Command exited with code ${code}`;
|
||||
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
|
||||
settle(() => reject(new Error(message)));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleExit = (
|
||||
code: number | null,
|
||||
exitSignal: NodeJS.Signals | number | null,
|
||||
) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const wasSignal = exitSignal != null;
|
||||
const isSuccess =
|
||||
code === 0 && !wasSignal && !signal?.aborted && !timedOut;
|
||||
const status: "completed" | "failed" = isSuccess
|
||||
? "completed"
|
||||
: "failed";
|
||||
markExited(session, code, exitSignal, status);
|
||||
|
||||
if (yielded || session.backgrounded) return;
|
||||
|
||||
const aggregated = session.aggregated.trim();
|
||||
if (!isSuccess) {
|
||||
const reason = timedOut
|
||||
? `Command timed out after ${effectiveTimeout} seconds`
|
||||
: wasSignal && exitSignal
|
||||
? `Command aborted by signal ${exitSignal}`
|
||||
: code === null
|
||||
? "Command aborted before exit code was captured"
|
||||
: `Command exited with code ${code}`;
|
||||
const message = aggregated
|
||||
? `${aggregated}\n\n${reason}`
|
||||
: reason;
|
||||
settle(() => reject(new Error(message)));
|
||||
return;
|
||||
}
|
||||
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
(aggregated || "(no output)"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "completed",
|
||||
exitCode: code ?? 0,
|
||||
durationMs,
|
||||
aggregated,
|
||||
cwd: session.cwd,
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
(aggregated || "(no output)"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
],
|
||||
details: {
|
||||
status: "completed",
|
||||
exitCode: code ?? 0,
|
||||
durationMs,
|
||||
aggregated,
|
||||
cwd: session.cwd,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// `exit` can fire before stdio fully flushes (notably on Windows).
|
||||
// `close` waits for streams to close, so aggregated output is complete.
|
||||
child.once("close", (code, exitSignal) => {
|
||||
handleExit(code, exitSignal);
|
||||
});
|
||||
// `exit` can fire before stdio fully flushes (notably on Windows).
|
||||
// `close` waits for streams to close, so aggregated output is complete.
|
||||
child.once("close", (code, exitSignal) => {
|
||||
handleExit(code, exitSignal);
|
||||
});
|
||||
|
||||
child.once("error", (err) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
markExited(session, null, null, "failed");
|
||||
settle(() => reject(err));
|
||||
});
|
||||
},
|
||||
);
|
||||
child.once("error", (err) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
markExited(session, null, null, "failed");
|
||||
settle(() => reject(err));
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ export type ProcessToolDefaults = {
|
|||
|
||||
const processSchema = Type.Object({
|
||||
action: Type.String({ description: "Process action" }),
|
||||
sessionId: Type.Optional(
|
||||
Type.String({ description: "Session id for actions other than list" }),
|
||||
),
|
||||
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
|
||||
data: Type.Optional(Type.String({ description: "Data to write for write" })),
|
||||
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
|
||||
offset: Type.Optional(Type.Number({ description: "Log offset" })),
|
||||
|
|
@ -96,9 +94,7 @@ export function createProcessTool(
|
|||
const lines = [...running, ...finished]
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
const label = s.name
|
||||
? truncateMiddle(s.name, 80)
|
||||
: truncateMiddle(s.command, 120);
|
||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||
return `${s.sessionId.slice(0, 8)} ${pad(
|
||||
s.status,
|
||||
9,
|
||||
|
|
@ -117,9 +113,7 @@ export function createProcessTool(
|
|||
|
||||
if (!params.sessionId) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "sessionId is required for this action." },
|
||||
],
|
||||
content: [{ type: "text", text: "sessionId is required for this action." }],
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
|
|
@ -150,10 +144,7 @@ export function createProcessTool(
|
|||
},
|
||||
],
|
||||
details: {
|
||||
status:
|
||||
scopedFinished.status === "completed"
|
||||
? "completed"
|
||||
: "failed",
|
||||
status: scopedFinished.status === "completed" ? "completed" : "failed",
|
||||
sessionId: params.sessionId,
|
||||
exitCode: scopedFinished.exitCode ?? undefined,
|
||||
aggregated: scopedFinished.aggregated,
|
||||
|
|
@ -187,8 +178,7 @@ export function createProcessTool(
|
|||
const exitCode = scopedSession.exitCode ?? 0;
|
||||
const exitSignal = scopedSession.exitSignal ?? undefined;
|
||||
if (exited) {
|
||||
const status =
|
||||
exitCode === 0 && exitSignal == null ? "completed" : "failed";
|
||||
const status = exitCode === 0 && exitSignal == null ? "completed" : "failed";
|
||||
markExited(
|
||||
scopedSession,
|
||||
scopedSession.exitCode ?? null,
|
||||
|
|
@ -201,10 +191,7 @@ export function createProcessTool(
|
|||
? "completed"
|
||||
: "failed"
|
||||
: "running";
|
||||
const output = [stdout.trimEnd(), stderr.trimEnd()]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
|
@ -265,12 +252,9 @@ export function createProcessTool(
|
|||
params.offset,
|
||||
params.limit,
|
||||
);
|
||||
const status =
|
||||
scopedFinished.status === "completed" ? "completed" : "failed";
|
||||
const status = scopedFinished.status === "completed" ? "completed" : "failed";
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: slice || "(no output recorded)" },
|
||||
],
|
||||
content: [{ type: "text", text: slice || "(no output recorded)" }],
|
||||
details: {
|
||||
status,
|
||||
sessionId: params.sessionId,
|
||||
|
|
@ -318,10 +302,7 @@ export function createProcessTool(
|
|||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (
|
||||
!scopedSession.child?.stdin ||
|
||||
scopedSession.child.stdin.destroyed
|
||||
) {
|
||||
if (!scopedSession.child?.stdin || scopedSession.child.stdin.destroyed) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
|
@ -353,9 +334,7 @@ export function createProcessTool(
|
|||
details: {
|
||||
status: "running",
|
||||
sessionId: params.sessionId,
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -386,14 +365,10 @@ export function createProcessTool(
|
|||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Killed session ${params.sessionId}.` },
|
||||
],
|
||||
content: [{ type: "text", text: `Killed session ${params.sessionId}.` }],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -402,9 +377,7 @@ export function createProcessTool(
|
|||
if (scopedFinished) {
|
||||
deleteSession(params.sessionId);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Cleared session ${params.sessionId}.` },
|
||||
],
|
||||
content: [{ type: "text", text: `Cleared session ${params.sessionId}.` }],
|
||||
details: { status: "completed" },
|
||||
};
|
||||
}
|
||||
|
|
@ -424,23 +397,17 @@ export function createProcessTool(
|
|||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Removed session ${params.sessionId}.` },
|
||||
],
|
||||
content: [{ type: "text", text: `Removed session ${params.sessionId}.` }],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: scopedSession
|
||||
? deriveSessionName(scopedSession.command)
|
||||
: undefined,
|
||||
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (scopedFinished) {
|
||||
deleteSession(params.sessionId);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Removed session ${params.sessionId}.` },
|
||||
],
|
||||
content: [{ type: "text", text: `Removed session ${params.sessionId}.` }],
|
||||
details: { status: "completed" },
|
||||
};
|
||||
}
|
||||
|
|
@ -457,9 +424,7 @@ export function createProcessTool(
|
|||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Unknown action ${params.action as string}` },
|
||||
],
|
||||
content: [{ type: "text", text: `Unknown action ${params.action as string}` }],
|
||||
details: { status: "failed" },
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -98,10 +98,7 @@ export async function resolveSandboxWorkdir(params: {
|
|||
}
|
||||
}
|
||||
|
||||
export function killSession(session: {
|
||||
pid?: number;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
}) {
|
||||
export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) {
|
||||
const pid = session.pid ?? session.child?.pid;
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
|
|
@ -117,9 +114,7 @@ export function resolveWorkdir(workdir: string, warnings: string[]) {
|
|||
} catch {
|
||||
// ignore, fallback below
|
||||
}
|
||||
warnings.push(
|
||||
`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`,
|
||||
);
|
||||
warnings.push(`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
|
@ -177,9 +172,7 @@ export function sliceLogLines(
|
|||
const totalLines = lines.length;
|
||||
const totalChars = text.length;
|
||||
let start =
|
||||
typeof offset === "number" && Number.isFinite(offset)
|
||||
? Math.max(0, Math.floor(offset))
|
||||
: 0;
|
||||
typeof offset === "number" && Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
||||
if (limit !== undefined && offset === undefined) {
|
||||
const tailCount = Math.max(0, Math.floor(limit));
|
||||
start = Math.max(totalLines - tailCount, 0);
|
||||
|
|
@ -203,8 +196,7 @@ export function deriveSessionName(command: string): string | undefined {
|
|||
}
|
||||
|
||||
function tokenizeCommand(command: string): string[] {
|
||||
const matches =
|
||||
command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? [];
|
||||
const matches = command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? [];
|
||||
return matches.map((token) => stripQuotes(token)).filter(Boolean);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import {
|
||||
createExecTool,
|
||||
createProcessTool,
|
||||
execTool,
|
||||
processTool,
|
||||
} from "./bash-tools.js";
|
||||
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
|
||||
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
|
|
@ -15,10 +10,8 @@ const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
|||
const longDelayCmd = isWin ? "Start-Sleep -Seconds 2" : "sleep 2";
|
||||
// Both PowerShell and bash use ; for command separation
|
||||
const joinCommands = (commands: string[]) => commands.join("; ");
|
||||
const echoAfterDelay = (message: string) =>
|
||||
joinCommands([shortDelayCmd, `echo ${message}`]);
|
||||
const echoLines = (lines: string[]) =>
|
||||
joinCommands(lines.map((line) => `echo ${line}`));
|
||||
const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]);
|
||||
const echoLines = (lines: string[]) => joinCommands(lines.map((line) => `echo ${line}`));
|
||||
const normalizeText = (value?: string) =>
|
||||
sanitizeBinaryOutput(value ?? "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
|
|
@ -74,8 +67,7 @@ describe("exec tool backgrounding", () => {
|
|||
|
||||
let status = "running";
|
||||
let output = "";
|
||||
const deadline =
|
||||
Date.now() + (process.platform === "win32" ? 8000 : 2000);
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000);
|
||||
|
||||
while (Date.now() < deadline && status === "running") {
|
||||
const poll = await processTool.execute("call2", {
|
||||
|
|
@ -106,9 +98,7 @@ describe("exec tool backgrounding", () => {
|
|||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
|
||||
const list = await processTool.execute("call2", { action: "list" });
|
||||
const sessions = (
|
||||
list.details as { sessions: Array<{ sessionId: string }> }
|
||||
).sessions;
|
||||
const sessions = (list.details as { sessions: Array<{ sessionId: string }> }).sessions;
|
||||
expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -121,9 +111,8 @@ describe("exec tool backgrounding", () => {
|
|||
await sleep(25);
|
||||
|
||||
const list = await processTool.execute("call2", { action: "list" });
|
||||
const sessions = (
|
||||
list.details as { sessions: Array<{ sessionId: string; name?: string }> }
|
||||
).sessions;
|
||||
const sessions = (list.details as { sessions: Array<{ sessionId: string; name?: string }> })
|
||||
.sessions;
|
||||
const entry = sessions.find((s) => s.sessionId === sessionId);
|
||||
expect(entry?.name).toBe("echo hello");
|
||||
});
|
||||
|
|
@ -239,9 +228,7 @@ describe("exec tool backgrounding", () => {
|
|||
const sessionB = (resultB.details as { sessionId: string }).sessionId;
|
||||
|
||||
const listA = await processA.execute("call3", { action: "list" });
|
||||
const sessionsA = (
|
||||
listA.details as { sessions: Array<{ sessionId: string }> }
|
||||
).sessions;
|
||||
const sessionsA = (listA.details as { sessions: Array<{ sessionId: string }> }).sessions;
|
||||
expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true);
|
||||
expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|||
import type { ChannelAgentTool } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
export function listChannelAgentTools(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
}): ChannelAgentTool[] {
|
||||
export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): ChannelAgentTool[] {
|
||||
// Channel docking: aggregate channel-owned tools (login, etc.).
|
||||
const tools: ChannelAgentTool[] = [];
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ describe("chutes-oauth", () => {
|
|||
if (url === CHUTES_TOKEN_ENDPOINT) {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(
|
||||
String(
|
||||
init?.headers &&
|
||||
(init.headers as Record<string, string>)["Content-Type"],
|
||||
),
|
||||
String(init?.headers && (init.headers as Record<string, string>)["Content-Type"]),
|
||||
).toContain("application/x-www-form-urlencoded");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
|
@ -30,18 +27,12 @@ describe("chutes-oauth", () => {
|
|||
}
|
||||
if (url === CHUTES_USERINFO_ENDPOINT) {
|
||||
expect(
|
||||
String(
|
||||
init?.headers &&
|
||||
(init.headers as Record<string, string>).Authorization,
|
||||
),
|
||||
String(init?.headers && (init.headers as Record<string, string>).Authorization),
|
||||
).toBe("Bearer at_123");
|
||||
return new Response(
|
||||
JSON.stringify({ username: "fred", sub: "sub_1" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
|
@ -62,20 +53,15 @@ describe("chutes-oauth", () => {
|
|||
expect(creds.access).toBe("at_123");
|
||||
expect(creds.refresh).toBe("rt_123");
|
||||
expect(creds.email).toBe("fred");
|
||||
expect((creds as unknown as { accountId?: string }).accountId).toBe(
|
||||
"sub_1",
|
||||
);
|
||||
expect((creds as unknown as { clientId?: string }).clientId).toBe(
|
||||
"cid_test",
|
||||
);
|
||||
expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1");
|
||||
expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test");
|
||||
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||
return new Response("not found", { status: 404 });
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as URLSearchParams;
|
||||
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ export function parseOAuthCallbackInput(
|
|||
}
|
||||
|
||||
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
||||
const value =
|
||||
now +
|
||||
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
|
||||
DEFAULT_EXPIRES_BUFFER_MS;
|
||||
const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
|
||||
return Math.max(value, now + 30_000);
|
||||
}
|
||||
|
||||
|
|
@ -122,8 +119,7 @@ export async function exchangeChutesCodeForTokens(params: {
|
|||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access)
|
||||
throw new Error("Chutes token exchange returned no access_token");
|
||||
if (!access) throw new Error("Chutes token exchange returned no access_token");
|
||||
if (!refresh) {
|
||||
throw new Error("Chutes token exchange returned no refresh_token");
|
||||
}
|
||||
|
|
@ -153,12 +149,9 @@ export async function refreshChutesTokens(params: {
|
|||
throw new Error("Chutes OAuth credential is missing refresh token");
|
||||
}
|
||||
|
||||
const clientId =
|
||||
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
|
||||
const clientId = params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
|
||||
);
|
||||
throw new Error("Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).");
|
||||
}
|
||||
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,7 @@ function createDeferred<T>() {
|
|||
};
|
||||
}
|
||||
|
||||
async function waitForCalls(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
count: number,
|
||||
) {
|
||||
async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: number) {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (mockFn.mock.calls.length >= count) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
|
@ -30,8 +27,7 @@ async function waitForCalls(
|
|||
}
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) =>
|
||||
runCommandWithTimeoutMock(...args),
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
describe("runClaudeCliAgent", () => {
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ describe("gateway tool", () => {
|
|||
payload?: { kind?: string; doctorHint?: string | null };
|
||||
};
|
||||
expect(parsed.payload?.kind).toBe("restart");
|
||||
expect(parsed.payload?.doctorHint).toBe(
|
||||
"Run: clawdbot doctor --non-interactive",
|
||||
);
|
||||
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
|
||||
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
await vi.runAllTimersAsync();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -41,8 +39,7 @@ describe("agents_list", () => {
|
|||
requester: "main",
|
||||
allowAny: false,
|
||||
});
|
||||
const agents = (result.details as { agents?: Array<{ id: string }> })
|
||||
.agents;
|
||||
const agents = (result.details as { agents?: Array<{ id: string }> }).agents;
|
||||
expect(agents?.map((agent) => agent.id)).toEqual(["main"]);
|
||||
});
|
||||
|
||||
|
|
@ -123,11 +120,7 @@ describe("agents_list", () => {
|
|||
agents?: Array<{ id: string }>;
|
||||
}
|
||||
).agents;
|
||||
expect(agents?.map((agent) => agent.id)).toEqual([
|
||||
"main",
|
||||
"coder",
|
||||
"research",
|
||||
]);
|
||||
expect(agents?.map((agent) => agent.id)).toEqual(["main", "coder", "research"]);
|
||||
});
|
||||
|
||||
it("marks allowlisted-but-unconfigured agents", async () => {
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@ describe("nodes camera_snap", () => {
|
|||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find(
|
||||
(candidate) => candidate.name === "nodes",
|
||||
);
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) throw new Error("missing nodes tool");
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
|
|
@ -46,9 +44,7 @@ describe("nodes camera_snap", () => {
|
|||
facing: "front",
|
||||
});
|
||||
|
||||
const images = (result.content ?? []).filter(
|
||||
(block) => block.type === "image",
|
||||
);
|
||||
const images = (result.content ?? []).filter((block) => block.type === "image");
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]?.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
|
@ -75,9 +71,7 @@ describe("nodes camera_snap", () => {
|
|||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find(
|
||||
(candidate) => candidate.name === "nodes",
|
||||
);
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) throw new Error("missing nodes tool");
|
||||
|
||||
await tool.execute("call1", {
|
||||
|
|
@ -118,9 +112,7 @@ describe("nodes run", () => {
|
|||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find(
|
||||
(candidate) => candidate.name === "nodes",
|
||||
);
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) throw new Error("missing nodes tool");
|
||||
|
||||
await tool.execute("call1", {
|
||||
|
|
|
|||
|
|
@ -54,9 +54,7 @@ describe("sessions tools", () => {
|
|||
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
|
||||
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
|
||||
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
|
||||
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe(
|
||||
"number",
|
||||
);
|
||||
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
|
||||
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
|
||||
});
|
||||
|
||||
|
|
@ -108,9 +106,7 @@ describe("sessions tools", () => {
|
|||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find(
|
||||
(candidate) => candidate.name === "sessions_list",
|
||||
);
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_list");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_list tool");
|
||||
|
||||
|
|
@ -147,9 +143,7 @@ describe("sessions tools", () => {
|
|||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find(
|
||||
(candidate) => candidate.name === "sessions_history",
|
||||
);
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_history tool");
|
||||
|
||||
|
|
@ -181,9 +175,7 @@ describe("sessions tools", () => {
|
|||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as
|
||||
| { message?: string; sessionKey?: string }
|
||||
| undefined;
|
||||
const params = request.params as { message?: string; sessionKey?: string } | undefined;
|
||||
const message = params?.message ?? "";
|
||||
let reply = "REPLY_SKIP";
|
||||
if (message === "ping" || message === "wait") {
|
||||
|
|
@ -207,8 +199,7 @@ describe("sessions tools", () => {
|
|||
}
|
||||
if (request.method === "chat.history") {
|
||||
_historyCallCount += 1;
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -268,9 +259,7 @@ describe("sessions tools", () => {
|
|||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
const waitCalls = calls.filter((call) => call.method === "agent.wait");
|
||||
const historyOnlyCalls = calls.filter(
|
||||
(call) => call.method === "chat.history",
|
||||
);
|
||||
const historyOnlyCalls = calls.filter((call) => call.method === "chat.history");
|
||||
expect(agentCalls).toHaveLength(8);
|
||||
for (const call of agentCalls) {
|
||||
expect(call.params).toMatchObject({
|
||||
|
|
@ -281,31 +270,28 @@ describe("sessions tools", () => {
|
|||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
|
||||
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
|
||||
"Agent-to-agent message context",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
||||
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
|
||||
"Agent-to-agent reply step",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
|
||||
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
|
||||
"Agent-to-agent announce step",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(waitCalls).toHaveLength(8);
|
||||
|
|
@ -339,9 +325,7 @@ describe("sessions tools", () => {
|
|||
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
|
||||
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
|
||||
}
|
||||
if (
|
||||
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
|
||||
) {
|
||||
if (params?.extraSystemPrompt?.includes("Agent-to-agent announce step")) {
|
||||
reply = "announce now";
|
||||
}
|
||||
replyByRunId.set(runId, reply);
|
||||
|
|
@ -357,8 +341,7 @@ describe("sessions tools", () => {
|
|||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -414,11 +397,10 @@ describe("sessions tools", () => {
|
|||
const replySteps = calls.filter(
|
||||
(call) =>
|
||||
call.method === "agent" &&
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
||||
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
|
||||
"Agent-to-agent reply step",
|
||||
),
|
||||
);
|
||||
expect(replySteps).toHaveLength(2);
|
||||
expect(sendParams).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -78,9 +76,7 @@ describe("clawdbot-tools: subagents", () => {
|
|||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as
|
||||
| { runId?: string; timeoutMs?: number }
|
||||
| undefined;
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
return {
|
||||
runId: params?.runId ?? "run-1",
|
||||
|
|
@ -91,8 +87,7 @@ describe("clawdbot-tools: subagents", () => {
|
|||
}
|
||||
if (request.method === "chat.history") {
|
||||
const params = request.params as { sessionKey?: string } | undefined;
|
||||
const text =
|
||||
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -79,17 +77,14 @@ describe("clawdbot-tools: subagents", () => {
|
|||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as
|
||||
| { runId?: string; timeoutMs?: number }
|
||||
| undefined;
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
const status = params?.runId === childRunId ? "timeout" : "ok";
|
||||
return { runId: params?.runId ?? "run-1", status };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const params = request.params as { sessionKey?: string } | undefined;
|
||||
const text =
|
||||
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -83,9 +81,7 @@ describe("clawdbot-tools: subagents", () => {
|
|||
modelApplied: true,
|
||||
});
|
||||
|
||||
const patchIndex = calls.findIndex(
|
||||
(call) => call.method === "sessions.patch",
|
||||
);
|
||||
const patchIndex = calls.findIndex((call) => call.method === "sessions.patch");
|
||||
const agentIndex = calls.findIndex((call) => call.method === "agent");
|
||||
expect(patchIndex).toBeGreaterThan(-1);
|
||||
expect(agentIndex).toBeGreaterThan(-1);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -124,9 +122,9 @@ describe("clawdbot-tools: subagents", () => {
|
|||
status: "accepted",
|
||||
modelApplied: false,
|
||||
});
|
||||
expect(
|
||||
String((result.details as { warning?: string }).warning ?? ""),
|
||||
).toContain("invalid model");
|
||||
expect(String((result.details as { warning?: string }).warning ?? "")).toContain(
|
||||
"invalid model",
|
||||
);
|
||||
expect(calls.some((call) => call.method === "agent")).toBe(true);
|
||||
});
|
||||
it("sessions_spawn supports legacy timeoutSeconds alias", async () => {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
let configOverride: ReturnType<
|
||||
typeof import("../config/config.js")["loadConfig"]
|
||||
> = {
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
|
|
@ -85,17 +83,14 @@ describe("clawdbot-tools: subagents", () => {
|
|||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as
|
||||
| { runId?: string; timeoutMs?: number }
|
||||
| undefined;
|
||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||
waitCalls.push(params ?? {});
|
||||
const status = params?.runId === childRunId ? "timeout" : "ok";
|
||||
return { runId: params?.runId ?? "run-1", status };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const params = request.params as { sessionKey?: string } | undefined;
|
||||
const text =
|
||||
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ import type { AnyAgentTool } from "./tools/common.js";
|
|||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
} from "./tools/memory-tool.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./tools/memory-tool.js";
|
||||
import { createMessageTool } from "./tools/message-tool.js";
|
||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
||||
|
|
@ -105,9 +102,7 @@ export function createClawdbotTools(options?: {
|
|||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
...(memorySearchTool && memoryGetTool
|
||||
? [memorySearchTool, memoryGetTool]
|
||||
: []),
|
||||
...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -34,12 +34,7 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
|||
modelAliases: CLAUDE_MODEL_ALIASES,
|
||||
sessionArg: "--session-id",
|
||||
sessionMode: "always",
|
||||
sessionIdFields: [
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
],
|
||||
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
|
||||
systemPromptArg: "--append-system-prompt",
|
||||
systemPromptMode: "append",
|
||||
systemPromptWhen: "first",
|
||||
|
|
@ -49,15 +44,7 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
|||
|
||||
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
|
||||
command: "codex",
|
||||
args: [
|
||||
"exec",
|
||||
"--json",
|
||||
"--color",
|
||||
"never",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--skip-git-repo-check",
|
||||
],
|
||||
args: ["exec", "--json", "--color", "never", "--sandbox", "read-only", "--skip-git-repo-check"],
|
||||
resumeArgs: [
|
||||
"exec",
|
||||
"resume",
|
||||
|
|
@ -93,10 +80,7 @@ function pickBackendConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function mergeBackendConfig(
|
||||
base: CliBackendConfig,
|
||||
override?: CliBackendConfig,
|
||||
): CliBackendConfig {
|
||||
function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig {
|
||||
if (!override) return { ...base };
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -104,9 +88,7 @@ function mergeBackendConfig(
|
|||
args: override.args ?? base.args,
|
||||
env: { ...base.env, ...override.env },
|
||||
modelAliases: { ...base.modelAliases, ...override.modelAliases },
|
||||
clearEnv: Array.from(
|
||||
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
|
||||
),
|
||||
clearEnv: Array.from(new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])])),
|
||||
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
|
||||
sessionArgs: override.sessionArgs ?? base.sessionArgs,
|
||||
resumeArgs: override.resumeArgs ?? base.resumeArgs,
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ describe("cli credentials", () => {
|
|||
return "";
|
||||
});
|
||||
|
||||
const { writeClaudeCliKeychainCredentials } = await import(
|
||||
"./cli-credentials.js"
|
||||
);
|
||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
||||
|
||||
const ok = writeClaudeCliKeychainCredentials({
|
||||
access: "new-access",
|
||||
|
|
@ -53,13 +51,9 @@ describe("cli credentials", () => {
|
|||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(
|
||||
commands.some((cmd) => cmd.includes("delete-generic-password")),
|
||||
).toBe(false);
|
||||
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
|
||||
|
||||
const updateCommand = commands.find((cmd) =>
|
||||
cmd.includes("add-generic-password"),
|
||||
);
|
||||
const updateCommand = commands.find((cmd) => cmd.includes("add-generic-password"));
|
||||
expect(updateCommand).toContain("-U");
|
||||
});
|
||||
|
||||
|
|
@ -130,9 +124,7 @@ describe("cli credentials", () => {
|
|||
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
|
||||
const { readClaudeCliCredentialsCached } = await import(
|
||||
"./cli-credentials.js"
|
||||
);
|
||||
const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js");
|
||||
|
||||
const first = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: true,
|
||||
|
|
@ -163,9 +155,7 @@ describe("cli credentials", () => {
|
|||
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
|
||||
const { readClaudeCliCredentialsCached } = await import(
|
||||
"./cli-credentials.js"
|
||||
);
|
||||
const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js");
|
||||
|
||||
const first = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: true,
|
||||
|
|
|
|||
|
|
@ -56,10 +56,7 @@ type ClaudeCliFileOptions = {
|
|||
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
||||
platform?: NodeJS.Platform;
|
||||
writeKeychain?: (credentials: OAuthCredentials) => boolean;
|
||||
writeFile?: (
|
||||
credentials: OAuthCredentials,
|
||||
options?: ClaudeCliFileOptions,
|
||||
) => boolean;
|
||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
||||
|
|
@ -73,9 +70,7 @@ function resolveCodexCliAuthPath() {
|
|||
|
||||
function resolveCodexHomePath() {
|
||||
const configured = process.env.CODEX_HOME;
|
||||
const home = configured
|
||||
? resolveUserPath(configured)
|
||||
: resolveUserPath("~/.codex");
|
||||
const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex");
|
||||
try {
|
||||
return fs.realpathSync.native(home);
|
||||
} catch {
|
||||
|
|
@ -98,10 +93,11 @@ function readCodexKeychainCredentials(options?: {
|
|||
const account = computeCodexKeychainAccount(codexHome);
|
||||
|
||||
try {
|
||||
const secret = execSync(
|
||||
`security find-generic-password -s "Codex Auth" -a "${account}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
).trim();
|
||||
const secret = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
|
|
@ -253,9 +249,7 @@ export function readClaudeCliCredentialsCached(options?: {
|
|||
return value;
|
||||
}
|
||||
|
||||
export function writeClaudeCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
): boolean {
|
||||
export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean {
|
||||
try {
|
||||
const existingResult = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
|
||||
|
|
@ -309,9 +303,7 @@ export function writeClaudeCliFileCredentials(
|
|||
if (!raw || typeof raw !== "object") return false;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const existingOauth = data.claudeAiOauth as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const existingOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!existingOauth || typeof existingOauth !== "object") return false;
|
||||
|
||||
data.claudeAiOauth = {
|
||||
|
|
@ -339,12 +331,10 @@ export function writeClaudeCliCredentials(
|
|||
options?: ClaudeCliWriteOptions,
|
||||
): boolean {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const writeKeychain =
|
||||
options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
|
||||
const writeKeychain = options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
|
||||
const writeFile =
|
||||
options?.writeFile ??
|
||||
((credentials, fileOptions) =>
|
||||
writeClaudeCliFileCredentials(credentials, fileOptions));
|
||||
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
|
||||
|
||||
if (platform === "darwin") {
|
||||
const didWriteKeychain = writeKeychain(newCredentials);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ const runCommandWithTimeoutMock = vi.fn();
|
|||
const runExecMock = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) =>
|
||||
runCommandWithTimeoutMock(...args),
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
runExec: (...args: unknown[]) => runExecMock(...args),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,7 @@ import {
|
|||
resolveBootstrapMaxChars,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
import {
|
||||
filterBootstrapFilesForSession,
|
||||
loadWorkspaceBootstrapFiles,
|
||||
} from "./workspace.js";
|
||||
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||
|
||||
const log = createSubsystemLogger("agent/claude-cli");
|
||||
|
||||
|
|
@ -58,10 +55,7 @@ export async function runCliAgent(params: {
|
|||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const workspaceDir = resolvedWorkspace;
|
||||
|
||||
const backendResolved = resolveCliBackendConfig(
|
||||
params.provider,
|
||||
params.config,
|
||||
);
|
||||
const backendResolved = resolveCliBackendConfig(params.provider, params.config);
|
||||
if (!backendResolved) {
|
||||
throw new Error(`Unknown CLI backend: ${params.provider}`);
|
||||
}
|
||||
|
|
@ -92,9 +86,7 @@ export async function runCliAgent(params: {
|
|||
});
|
||||
const heartbeatPrompt =
|
||||
sessionAgentId === defaultAgentId
|
||||
? resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
)
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
: undefined;
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
|
|
@ -114,14 +106,12 @@ export async function runCliAgent(params: {
|
|||
});
|
||||
const useResume = Boolean(
|
||||
params.cliSessionId &&
|
||||
cliSessionIdToSend &&
|
||||
backend.resumeArgs &&
|
||||
backend.resumeArgs.length > 0,
|
||||
cliSessionIdToSend &&
|
||||
backend.resumeArgs &&
|
||||
backend.resumeArgs.length > 0,
|
||||
);
|
||||
const sessionIdSent = cliSessionIdToSend
|
||||
? useResume ||
|
||||
Boolean(backend.sessionArg) ||
|
||||
Boolean(backend.sessionArgs?.length)
|
||||
? useResume || Boolean(backend.sessionArg) || Boolean(backend.sessionArgs?.length)
|
||||
? cliSessionIdToSend
|
||||
: undefined
|
||||
: undefined;
|
||||
|
|
@ -148,13 +138,9 @@ export async function runCliAgent(params: {
|
|||
prompt,
|
||||
});
|
||||
const stdinPayload = stdin ?? "";
|
||||
const baseArgs = useResume
|
||||
? (backend.resumeArgs ?? backend.args ?? [])
|
||||
: (backend.args ?? []);
|
||||
const baseArgs = useResume ? (backend.resumeArgs ?? backend.args ?? []) : (backend.args ?? []);
|
||||
const resolvedArgs = useResume
|
||||
? baseArgs.map((entry) =>
|
||||
entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""),
|
||||
)
|
||||
? baseArgs.map((entry) => entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""))
|
||||
: baseArgs;
|
||||
const args = buildCliArgs({
|
||||
backend,
|
||||
|
|
@ -168,9 +154,7 @@ export async function runCliAgent(params: {
|
|||
});
|
||||
|
||||
const serialize = backend.serialize ?? true;
|
||||
const queueKey = serialize
|
||||
? backendResolved.id
|
||||
: `${backendResolved.id}:${params.runId}`;
|
||||
const queueKey = serialize ? backendResolved.id : `${backendResolved.id}:${params.runId}`;
|
||||
|
||||
try {
|
||||
const output = await enqueueCliRun(queueKey, async () => {
|
||||
|
|
@ -184,10 +168,7 @@ export async function runCliAgent(params: {
|
|||
const arg = args[i] ?? "";
|
||||
if (arg === backend.systemPromptArg) {
|
||||
const systemPromptValue = args[i + 1] ?? "";
|
||||
logArgs.push(
|
||||
arg,
|
||||
`<systemPrompt:${systemPromptValue.length} chars>`,
|
||||
);
|
||||
logArgs.push(arg, `<systemPrompt:${systemPromptValue.length} chars>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -259,9 +240,7 @@ export async function runCliAgent(params: {
|
|||
});
|
||||
}
|
||||
|
||||
const outputMode = useResume
|
||||
? (backend.resumeOutput ?? backend.output)
|
||||
: backend.output;
|
||||
const outputMode = useResume ? (backend.resumeOutput ?? backend.output) : backend.output;
|
||||
|
||||
if (outputMode === "text") {
|
||||
return { text: stdout, sessionId: undefined };
|
||||
|
|
@ -283,8 +262,7 @@ export async function runCliAgent(params: {
|
|||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta: {
|
||||
sessionId:
|
||||
output.sessionId ?? sessionIdSent ?? params.sessionId ?? "",
|
||||
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId ?? "",
|
||||
provider: params.provider,
|
||||
model: modelId,
|
||||
usage: output.usage,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ export async function cleanupResumeProcesses(
|
|||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) return;
|
||||
|
||||
const resumeTokens = resumeArgs.map((arg) =>
|
||||
arg.replaceAll("{sessionId}", sessionId),
|
||||
);
|
||||
const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId));
|
||||
const pattern = [commandToken, ...resumeTokens]
|
||||
.filter(Boolean)
|
||||
.map((token) => escapeRegex(token))
|
||||
|
|
@ -45,10 +43,7 @@ export async function cleanupResumeProcesses(
|
|||
}
|
||||
}
|
||||
|
||||
export function enqueueCliRun<T>(
|
||||
key: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||
const chained = prior.catch(() => undefined).then(task);
|
||||
const tracked = chained.finally(() => {
|
||||
|
|
@ -78,9 +73,7 @@ function resolveUserTimezone(configured?: string): string {
|
|||
const trimmed = configured?.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
|
||||
new Date(),
|
||||
);
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||
return trimmed;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
|
|
@ -106,14 +99,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
|
|||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
if (
|
||||
!map.weekday ||
|
||||
!map.year ||
|
||||
!map.month ||
|
||||
!map.day ||
|
||||
!map.hour ||
|
||||
!map.minute
|
||||
)
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
|
||||
return undefined;
|
||||
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
|
||||
} catch {
|
||||
|
|
@ -127,9 +113,7 @@ function buildModelAliasLines(cfg?: ClawdbotConfig) {
|
|||
for (const [keyRaw, entryRaw] of Object.entries(models)) {
|
||||
const model = String(keyRaw ?? "").trim();
|
||||
if (!model) continue;
|
||||
const alias = String(
|
||||
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
|
||||
).trim();
|
||||
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
|
||||
if (!alias) continue;
|
||||
entries.push({ alias, model });
|
||||
}
|
||||
|
|
@ -149,9 +133,7 @@ export function buildSystemPrompt(params: {
|
|||
contextFiles?: EmbeddedContextFile[];
|
||||
modelDisplay: string;
|
||||
}) {
|
||||
const userTimezone = resolveUserTimezone(
|
||||
params.config?.agents?.defaults?.userTimezone,
|
||||
);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
|
@ -175,10 +157,7 @@ export function buildSystemPrompt(params: {
|
|||
});
|
||||
}
|
||||
|
||||
export function normalizeCliModel(
|
||||
modelId: string,
|
||||
backend: CliBackendConfig,
|
||||
): string {
|
||||
export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const direct = backend.modelAliases?.[trimmed];
|
||||
|
|
@ -191,19 +170,14 @@ export function normalizeCliModel(
|
|||
|
||||
function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
|
||||
const pick = (key: string) =>
|
||||
typeof raw[key] === "number" && raw[key] > 0
|
||||
? (raw[key] as number)
|
||||
: undefined;
|
||||
typeof raw[key] === "number" && raw[key] > 0 ? (raw[key] as number) : undefined;
|
||||
const input = pick("input_tokens") ?? pick("inputTokens");
|
||||
const output = pick("output_tokens") ?? pick("outputTokens");
|
||||
const cacheRead =
|
||||
pick("cache_read_input_tokens") ??
|
||||
pick("cached_input_tokens") ??
|
||||
pick("cacheRead");
|
||||
pick("cache_read_input_tokens") ?? pick("cached_input_tokens") ?? pick("cacheRead");
|
||||
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
|
||||
const total = pick("total_tokens") ?? pick("total");
|
||||
if (!input && !output && !cacheRead && !cacheWrite && !total)
|
||||
return undefined;
|
||||
if (!input && !output && !cacheRead && !cacheWrite && !total) return undefined;
|
||||
return { input, output, cacheRead, cacheWrite, total };
|
||||
}
|
||||
|
||||
|
|
@ -214,8 +188,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
function collectText(value: unknown): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value))
|
||||
return value.map((entry) => collectText(entry)).join("");
|
||||
if (Array.isArray(value)) return value.map((entry) => collectText(entry)).join("");
|
||||
if (!isRecord(value)) return "";
|
||||
if (typeof value.text === "string") return value.text;
|
||||
if (typeof value.content === "string") return value.content;
|
||||
|
|
@ -242,10 +215,7 @@ function pickSessionId(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function parseCliJson(
|
||||
raw: string,
|
||||
backend: CliBackendConfig,
|
||||
): CliOutput | null {
|
||||
export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
let parsed: unknown;
|
||||
|
|
@ -265,10 +235,7 @@ export function parseCliJson(
|
|||
return { text: text.trim(), sessionId, usage };
|
||||
}
|
||||
|
||||
export function parseCliJsonl(
|
||||
raw: string,
|
||||
backend: CliBackendConfig,
|
||||
): CliOutput | null {
|
||||
export function parseCliJsonl(raw: string, backend: CliBackendConfig): CliOutput | null {
|
||||
const lines = raw
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
|
|
@ -331,18 +298,15 @@ export function resolveSessionIdToSend(params: {
|
|||
return { sessionId: crypto.randomUUID(), isNew: true };
|
||||
}
|
||||
|
||||
export function resolvePromptInput(params: {
|
||||
backend: CliBackendConfig;
|
||||
prompt: string;
|
||||
}): { argsPrompt?: string; stdin?: string } {
|
||||
export function resolvePromptInput(params: { backend: CliBackendConfig; prompt: string }): {
|
||||
argsPrompt?: string;
|
||||
stdin?: string;
|
||||
} {
|
||||
const inputMode = params.backend.input ?? "arg";
|
||||
if (inputMode === "stdin") {
|
||||
return { stdin: params.prompt };
|
||||
}
|
||||
if (
|
||||
params.backend.maxPromptArgChars &&
|
||||
params.prompt.length > params.backend.maxPromptArgChars
|
||||
) {
|
||||
if (params.backend.maxPromptArgChars && params.prompt.length > params.backend.maxPromptArgChars) {
|
||||
return { stdin: params.prompt };
|
||||
}
|
||||
return { argsPrompt: params.prompt };
|
||||
|
|
@ -357,10 +321,7 @@ function resolveImageExtension(mimeType: string): string {
|
|||
return "bin";
|
||||
}
|
||||
|
||||
export function appendImagePathsToPrompt(
|
||||
prompt: string,
|
||||
paths: string[],
|
||||
): string {
|
||||
export function appendImagePathsToPrompt(prompt: string, paths: string[]): string {
|
||||
if (!paths.length) return prompt;
|
||||
const trimmed = prompt.trimEnd();
|
||||
const separator = trimmed ? "\n\n" : "";
|
||||
|
|
@ -370,9 +331,7 @@ export function appendImagePathsToPrompt(
|
|||
export async function writeCliImages(
|
||||
images: ImageContent[],
|
||||
): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {
|
||||
const tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-images-"),
|
||||
);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cli-images-"));
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < images.length; i += 1) {
|
||||
const image = images[i];
|
||||
|
|
@ -402,11 +361,7 @@ export function buildCliArgs(params: {
|
|||
if (!params.useResume && params.backend.modelArg && params.modelId) {
|
||||
args.push(params.backend.modelArg, params.modelId);
|
||||
}
|
||||
if (
|
||||
!params.useResume &&
|
||||
params.systemPrompt &&
|
||||
params.backend.systemPromptArg
|
||||
) {
|
||||
if (!params.useResume && params.systemPrompt && params.backend.systemPromptArg) {
|
||||
args.push(params.backend.systemPromptArg, params.systemPrompt);
|
||||
}
|
||||
if (!params.useResume && params.sessionId) {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@ export function getCliSessionId(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function setCliSessionId(
|
||||
entry: SessionEntry,
|
||||
provider: string,
|
||||
sessionId: string,
|
||||
): void {
|
||||
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) return;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
|
||||
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
|
||||
|
||||
export type ContextWindowSource =
|
||||
| "model"
|
||||
| "modelsConfig"
|
||||
| "agentContextTokens"
|
||||
| "default";
|
||||
export type ContextWindowSource = "model" | "modelsConfig" | "agentContextTokens" | "default";
|
||||
|
||||
export type ContextWindowInfo = {
|
||||
tokens: number;
|
||||
|
|
@ -32,26 +28,17 @@ export function resolveContextWindowInfo(params: {
|
|||
|
||||
const fromModelsConfig = (() => {
|
||||
const providers = params.cfg?.models?.providers as
|
||||
| Record<
|
||||
string,
|
||||
{ models?: Array<{ id?: string; contextWindow?: number }> }
|
||||
>
|
||||
| Record<string, { models?: Array<{ id?: string; contextWindow?: number }> }>
|
||||
| undefined;
|
||||
const providerEntry = providers?.[params.provider];
|
||||
const models = Array.isArray(providerEntry?.models)
|
||||
? providerEntry.models
|
||||
: [];
|
||||
const models = Array.isArray(providerEntry?.models) ? providerEntry.models : [];
|
||||
const match = models.find((m) => m?.id === params.modelId);
|
||||
return normalizePositiveInt(match?.contextWindow);
|
||||
})();
|
||||
if (fromModelsConfig)
|
||||
return { tokens: fromModelsConfig, source: "modelsConfig" };
|
||||
if (fromModelsConfig) return { tokens: fromModelsConfig, source: "modelsConfig" };
|
||||
|
||||
const fromAgentConfig = normalizePositiveInt(
|
||||
params.cfg?.agents?.defaults?.contextTokens,
|
||||
);
|
||||
if (fromAgentConfig)
|
||||
return { tokens: fromAgentConfig, source: "agentContextTokens" };
|
||||
const fromAgentConfig = normalizePositiveInt(params.cfg?.agents?.defaults?.contextTokens);
|
||||
if (fromAgentConfig) return { tokens: fromAgentConfig, source: "agentContextTokens" };
|
||||
|
||||
return { tokens: Math.floor(params.defaultTokens), source: "default" };
|
||||
}
|
||||
|
|
@ -70,10 +57,7 @@ export function evaluateContextWindowGuard(params: {
|
|||
1,
|
||||
Math.floor(params.warnBelowTokens ?? CONTEXT_WINDOW_WARN_BELOW_TOKENS),
|
||||
);
|
||||
const hardMin = Math.max(
|
||||
1,
|
||||
Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS),
|
||||
);
|
||||
const hardMin = Math.max(1, Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS));
|
||||
const tokens = Math.max(0, Math.floor(params.info.tokens));
|
||||
return {
|
||||
...params.info,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ type ModelEntry = { id: string; contextWindow?: number };
|
|||
const MODEL_CACHE = new Map<string, number>();
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const { discoverAuthStorage, discoverModels } = await import(
|
||||
"@mariozechner/pi-coding-agent"
|
||||
);
|
||||
const { discoverAuthStorage, discoverModels } = await import("@mariozechner/pi-coding-agent");
|
||||
const cfg = loadConfig();
|
||||
await ensureClawdbotModelsJson(cfg);
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {
|
|||
describe("failover-error", () => {
|
||||
it("infers failover reason from HTTP status", () => {
|
||||
expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing");
|
||||
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
|
||||
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
|
||||
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
|
||||
});
|
||||
|
|
@ -24,12 +22,8 @@ describe("failover-error", () => {
|
|||
});
|
||||
|
||||
it("infers timeout from common node error codes", () => {
|
||||
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe(
|
||||
"timeout",
|
||||
);
|
||||
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe(
|
||||
"timeout",
|
||||
);
|
||||
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe("timeout");
|
||||
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe("timeout");
|
||||
});
|
||||
|
||||
it("coerces failover-worthy errors into FailoverError with metadata", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
classifyFailoverReason,
|
||||
type FailoverReason,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
|
||||
|
||||
export class FailoverError extends Error {
|
||||
readonly reason: FailoverReason;
|
||||
|
|
@ -38,9 +35,7 @@ export function isFailoverError(err: unknown): err is FailoverError {
|
|||
return err instanceof FailoverError;
|
||||
}
|
||||
|
||||
export function resolveFailoverStatus(
|
||||
reason: FailoverReason,
|
||||
): number | undefined {
|
||||
export function resolveFailoverStatus(reason: FailoverReason): number | undefined {
|
||||
switch (reason) {
|
||||
case "billing":
|
||||
return 402;
|
||||
|
|
@ -80,11 +75,7 @@ function getErrorCode(err: unknown): string | undefined {
|
|||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
if (
|
||||
typeof err === "number" ||
|
||||
typeof err === "boolean" ||
|
||||
typeof err === "bigint"
|
||||
) {
|
||||
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
return String(err);
|
||||
}
|
||||
if (typeof err === "symbol") return err.description ?? "";
|
||||
|
|
@ -95,9 +86,7 @@ function getErrorMessage(err: unknown): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
export function resolveFailoverReasonFromError(
|
||||
err: unknown,
|
||||
): FailoverReason | null {
|
||||
export function resolveFailoverReasonFromError(err: unknown): FailoverReason | null {
|
||||
if (isFailoverError(err)) return err.reason;
|
||||
|
||||
const status = getStatusCode(err);
|
||||
|
|
@ -107,11 +96,7 @@ export function resolveFailoverReasonFromError(
|
|||
if (status === 408) return "timeout";
|
||||
|
||||
const code = (getErrorCode(err) ?? "").toUpperCase();
|
||||
if (
|
||||
["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(
|
||||
code,
|
||||
)
|
||||
) {
|
||||
if (["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(code)) {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
ClawdbotConfig,
|
||||
HumanDelayConfig,
|
||||
IdentityConfig,
|
||||
} from "../config/config.js";
|
||||
import type { ClawdbotConfig, HumanDelayConfig, IdentityConfig } from "../config/config.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
const DEFAULT_ACK_REACTION = "👀";
|
||||
|
|
@ -14,10 +10,7 @@ export function resolveAgentIdentity(
|
|||
return resolveAgentConfig(cfg, agentId)?.identity;
|
||||
}
|
||||
|
||||
export function resolveAckReaction(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string {
|
||||
export function resolveAckReaction(cfg: ClawdbotConfig, agentId: string): string {
|
||||
const configured = cfg.messages?.ackReaction;
|
||||
if (configured !== undefined) return configured.trim();
|
||||
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
|
||||
|
|
@ -44,15 +37,10 @@ export function resolveMessagePrefix(
|
|||
const hasAllowFrom = opts?.hasAllowFrom === true;
|
||||
if (hasAllowFrom) return "";
|
||||
|
||||
return (
|
||||
resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]"
|
||||
);
|
||||
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]";
|
||||
}
|
||||
|
||||
export function resolveResponsePrefix(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string | undefined {
|
||||
export function resolveResponsePrefix(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||
const configured = cfg.messages?.responsePrefix;
|
||||
if (configured !== undefined) {
|
||||
if (configured === "auto") {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ export type ModelRef = {
|
|||
id?: string | null;
|
||||
};
|
||||
|
||||
const ANTHROPIC_PREFIXES = [
|
||||
"claude-opus-4-5",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
const ANTHROPIC_PREFIXES = ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
|
||||
const OPENAI_MODELS = ["gpt-5.2", "gpt-5.0"];
|
||||
const CODEX_MODELS = [
|
||||
"gpt-5.2",
|
||||
|
|
@ -55,10 +51,7 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
|||
}
|
||||
|
||||
if (provider === "google-antigravity") {
|
||||
return (
|
||||
matchesPrefix(id, GOOGLE_PREFIXES) ||
|
||||
matchesPrefix(id, ANTHROPIC_PREFIXES)
|
||||
);
|
||||
return matchesPrefix(id, GOOGLE_PREFIXES) || matchesPrefix(id, ANTHROPIC_PREFIXES);
|
||||
}
|
||||
|
||||
if (provider === "zai") {
|
||||
|
|
|
|||
|
|
@ -52,9 +52,7 @@ function resolveStorePath(agentId: string, raw?: string): string {
|
|||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`);
|
||||
if (!raw) return fallback;
|
||||
const withToken = raw.includes("{agentId}")
|
||||
? raw.replaceAll("{agentId}", agentId)
|
||||
: raw;
|
||||
const withToken = raw.includes("{agentId}") ? raw.replaceAll("{agentId}", agentId) : raw;
|
||||
return resolveUserPath(withToken);
|
||||
}
|
||||
|
||||
|
|
@ -77,47 +75,29 @@ function mergeConfig(
|
|||
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
|
||||
const local = {
|
||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||
modelCacheDir:
|
||||
overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||
};
|
||||
const store = {
|
||||
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
|
||||
path: resolveStorePath(
|
||||
agentId,
|
||||
overrides?.store?.path ?? defaults?.store?.path,
|
||||
),
|
||||
path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
|
||||
};
|
||||
const chunking = {
|
||||
tokens:
|
||||
overrides?.chunking?.tokens ??
|
||||
defaults?.chunking?.tokens ??
|
||||
DEFAULT_CHUNK_TOKENS,
|
||||
overlap:
|
||||
overrides?.chunking?.overlap ??
|
||||
defaults?.chunking?.overlap ??
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
|
||||
overlap: overrides?.chunking?.overlap ?? defaults?.chunking?.overlap ?? DEFAULT_CHUNK_OVERLAP,
|
||||
};
|
||||
const sync = {
|
||||
onSessionStart:
|
||||
overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
|
||||
onSessionStart: overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
|
||||
onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true,
|
||||
watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true,
|
||||
watchDebounceMs:
|
||||
overrides?.sync?.watchDebounceMs ??
|
||||
defaults?.sync?.watchDebounceMs ??
|
||||
DEFAULT_WATCH_DEBOUNCE_MS,
|
||||
intervalMinutes:
|
||||
overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
|
||||
intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
|
||||
};
|
||||
const query = {
|
||||
maxResults:
|
||||
overrides?.query?.maxResults ??
|
||||
defaults?.query?.maxResults ??
|
||||
DEFAULT_MAX_RESULTS,
|
||||
minScore:
|
||||
overrides?.query?.minScore ??
|
||||
defaults?.query?.minScore ??
|
||||
DEFAULT_MIN_SCORE,
|
||||
maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
|
||||
minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE,
|
||||
};
|
||||
|
||||
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ export async function minimaxUnderstandImage(params: {
|
|||
const imageDataUrl = params.imageDataUrl.trim();
|
||||
if (!imageDataUrl) throw new Error("MiniMax VLM: imageDataUrl required");
|
||||
if (!/^data:image\/(png|jpeg|webp);base64,/i.test(imageDataUrl)) {
|
||||
throw new Error(
|
||||
"MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL",
|
||||
);
|
||||
throw new Error("MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL");
|
||||
}
|
||||
|
||||
const host = coerceApiHost({
|
||||
|
|
@ -92,17 +90,12 @@ export async function minimaxUnderstandImage(params: {
|
|||
throw new Error(`MiniMax VLM response was not JSON.${trace}`);
|
||||
}
|
||||
|
||||
const baseResp = isRecord(json.base_resp)
|
||||
? (json.base_resp as MinimaxBaseResp)
|
||||
: {};
|
||||
const code =
|
||||
typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
|
||||
const baseResp = isRecord(json.base_resp) ? (json.base_resp as MinimaxBaseResp) : {};
|
||||
const code = typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
|
||||
if (code !== 0) {
|
||||
const msg = (baseResp.status_msg ?? "").trim();
|
||||
const trace = traceId ? ` Trace-Id: ${traceId}` : "";
|
||||
throw new Error(
|
||||
`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`,
|
||||
);
|
||||
throw new Error(`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`);
|
||||
}
|
||||
|
||||
const content = pickString(json, "content").trim();
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { completeSimple, type Model } from "@mariozechner/pi-ai";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
|
||||
const MINIMAX_BASE_URL =
|
||||
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
|
||||
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
|
|
|
|||
|
|
@ -107,11 +107,7 @@ describe("getApiKeyForModel", () => {
|
|||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilesPath = path.join(
|
||||
tempDir,
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
);
|
||||
const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilesPath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ import {
|
|||
} from "./auth-profiles.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
export {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
|
||||
export function getCustomProviderApiKey(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
|
|
@ -109,16 +106,12 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
|||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = process.env[envVar]?.trim();
|
||||
if (!value) return null;
|
||||
const source = applied.has(envVar)
|
||||
? `shell env: ${envVar}`
|
||||
: `env: ${envVar}`;
|
||||
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
|
||||
return { apiKey: value, source };
|
||||
};
|
||||
|
||||
if (normalized === "github-copilot") {
|
||||
return (
|
||||
pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN")
|
||||
);
|
||||
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
|
||||
}
|
||||
|
||||
if (normalized === "anthropic") {
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ export async function loadModelCatalog(params?: {
|
|||
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
|
||||
? entry.contextWindow
|
||||
: undefined;
|
||||
const reasoning =
|
||||
typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||
models.push({ id, name, provider, contextWindow, reasoning });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@ function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
|||
describe("runWithModelFallback", () => {
|
||||
it("does not fall back on non-auth errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("bad request"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
|
|
@ -60,9 +57,7 @@ describe("runWithModelFallback", () => {
|
|||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("payment required"), { status: 402 }),
|
||||
)
|
||||
.mockRejectedValueOnce(Object.assign(new Error("payment required"), { status: 402 }))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
|
|
@ -106,9 +101,7 @@ describe("runWithModelFallback", () => {
|
|||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new Error('No credentials found for profile "anthropic:claude-cli".'),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:claude-cli".'))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
|
|
@ -136,9 +129,7 @@ describe("runWithModelFallback", () => {
|
|||
});
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
|
||||
);
|
||||
.mockImplementation(() => Promise.reject(Object.assign(new Error("nope"), { status: 401 })));
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
|
|
@ -219,9 +210,7 @@ describe("runWithModelFallback", () => {
|
|||
}),
|
||||
).rejects.toThrow("primary failed");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ provider: "anthropic", model: "claude-opus-4-5" },
|
||||
]);
|
||||
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
|
||||
});
|
||||
|
||||
it("falls back on missing API key errors", async () => {
|
||||
|
|
@ -277,9 +266,7 @@ describe("runWithModelFallback", () => {
|
|||
});
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
|
||||
)
|
||||
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
coerceToFailoverError,
|
||||
describeFailoverError,
|
||||
isFailoverError,
|
||||
} from "./failover-error.js";
|
||||
import { coerceToFailoverError, describeFailoverError, isFailoverError } from "./failover-error.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
|
|
@ -33,9 +29,7 @@ function isAbortError(err: unknown): boolean {
|
|||
const name = "name" in err ? String(err.name) : "";
|
||||
if (name === "AbortError") return true;
|
||||
const message =
|
||||
"message" in err && typeof err.message === "string"
|
||||
? err.message.toLowerCase()
|
||||
: "";
|
||||
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
|
||||
return message.includes("aborted");
|
||||
}
|
||||
|
||||
|
|
@ -70,10 +64,7 @@ function resolveImageFallbackCandidates(params: {
|
|||
const seen = new Set<string>();
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (
|
||||
candidate: ModelCandidate,
|
||||
enforceAllowlist: boolean,
|
||||
) => {
|
||||
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
|
|
@ -99,8 +90,7 @@ function resolveImageFallbackCandidates(params: {
|
|||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
const primary =
|
||||
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
if (primary?.trim()) addRaw(primary, false);
|
||||
}
|
||||
|
||||
|
|
@ -146,10 +136,7 @@ function resolveFallbackCandidates(params: {
|
|||
const seen = new Set<string>();
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (
|
||||
candidate: ModelCandidate,
|
||||
enforceAllowlist: boolean,
|
||||
) => {
|
||||
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
|
|
@ -180,11 +167,7 @@ function resolveFallbackCandidates(params: {
|
|||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
if (
|
||||
params.fallbacksOverride === undefined &&
|
||||
primary?.provider &&
|
||||
primary.model
|
||||
) {
|
||||
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
|
||||
addCandidate({ provider: primary.provider, model: primary.model }, false);
|
||||
}
|
||||
|
||||
|
|
@ -271,10 +254,9 @@ export async function runWithModelFallback<T>(params: {
|
|||
)
|
||||
.join(" | ")
|
||||
: "unknown";
|
||||
throw new Error(
|
||||
`All models failed (${attempts.length || candidates.length}): ${summary}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined },
|
||||
);
|
||||
throw new Error(`All models failed (${attempts.length || candidates.length}): ${summary}`, {
|
||||
cause: lastError instanceof Error ? lastError : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWithImageModelFallback<T>(params: {
|
||||
|
|
@ -340,14 +322,10 @@ export async function runWithImageModelFallback<T>(params: {
|
|||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
.map(
|
||||
(attempt) =>
|
||||
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
|
||||
)
|
||||
.map((attempt) => `${attempt.provider}/${attempt.model}: ${attempt.error}`)
|
||||
.join(" | ")
|
||||
: "unknown";
|
||||
throw new Error(
|
||||
`All image models failed (${attempts.length || candidates.length}): ${summary}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined },
|
||||
);
|
||||
throw new Error(`All image models failed (${attempts.length || candidates.length}): ${summary}`, {
|
||||
cause: lastError instanceof Error ? lastError : undefined,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,11 +79,7 @@ export type OpenRouterScanOptions = {
|
|||
maxAgeDays?: number;
|
||||
providerFilter?: string;
|
||||
probe?: boolean;
|
||||
onProgress?: (update: {
|
||||
phase: "catalog" | "probe";
|
||||
completed: number;
|
||||
total: number;
|
||||
}) => void;
|
||||
onProgress?: (update: { phase: "catalog" | "probe"; completed: number; total: number }) => void;
|
||||
};
|
||||
|
||||
type OpenAIModel = Model<"openai-completions">;
|
||||
|
|
@ -97,9 +93,7 @@ function normalizeCreatedAtMs(value: unknown): number | null {
|
|||
|
||||
function inferParamBFromIdOrName(text: string): number | null {
|
||||
const raw = text.toLowerCase();
|
||||
const matches = raw.matchAll(
|
||||
/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g,
|
||||
);
|
||||
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
|
||||
let best: number | null = null;
|
||||
for (const match of matches) {
|
||||
const numRaw = match[1];
|
||||
|
|
@ -169,9 +163,7 @@ async function withTimeout<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchOpenRouterModels(
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<OpenRouterModelMeta[]> {
|
||||
async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRouterModelMeta[]> {
|
||||
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
|
@ -187,21 +179,17 @@ async function fetchOpenRouterModels(
|
|||
const obj = entry as Record<string, unknown>;
|
||||
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
||||
if (!id) return null;
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
|
||||
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
|
||||
|
||||
const contextLength =
|
||||
typeof obj.context_length === "number" &&
|
||||
Number.isFinite(obj.context_length)
|
||||
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
|
||||
? obj.context_length
|
||||
: null;
|
||||
|
||||
const maxCompletionTokens =
|
||||
typeof obj.max_completion_tokens === "number" &&
|
||||
Number.isFinite(obj.max_completion_tokens)
|
||||
typeof obj.max_completion_tokens === "number" && Number.isFinite(obj.max_completion_tokens)
|
||||
? obj.max_completion_tokens
|
||||
: typeof obj.max_output_tokens === "number" &&
|
||||
Number.isFinite(obj.max_output_tokens)
|
||||
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
|
||||
? obj.max_output_tokens
|
||||
: null;
|
||||
|
||||
|
|
@ -216,9 +204,7 @@ async function fetchOpenRouterModels(
|
|||
const supportsToolsMeta = supportedParameters.includes("tools");
|
||||
|
||||
const modality =
|
||||
typeof obj.modality === "string" && obj.modality.trim()
|
||||
? obj.modality.trim()
|
||||
: null;
|
||||
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
|
||||
|
||||
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
|
||||
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
|
||||
|
|
@ -268,9 +254,7 @@ async function probeTool(
|
|||
} satisfies OpenAICompletionsOptions),
|
||||
);
|
||||
|
||||
const hasToolCall = message.content.some(
|
||||
(block) => block.type === "toolCall",
|
||||
);
|
||||
const hasToolCall = message.content.some((block) => block.type === "toolCall");
|
||||
if (!hasToolCall) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -361,9 +345,7 @@ async function mapWithConcurrency<T, R>(
|
|||
return results;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
||||
);
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
|
|
@ -374,19 +356,11 @@ export async function scanOpenRouterModels(
|
|||
const probe = options.probe ?? true;
|
||||
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
|
||||
if (probe && !apiKey) {
|
||||
throw new Error(
|
||||
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
|
||||
);
|
||||
throw new Error("Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.");
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
||||
);
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY),
|
||||
);
|
||||
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
|
||||
const concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY));
|
||||
const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0));
|
||||
const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0));
|
||||
const providerFilter = options.providerFilter?.trim().toLowerCase() ?? "";
|
||||
|
|
|
|||
|
|
@ -39,9 +39,7 @@ describe("buildAllowedModelSet", () => {
|
|||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes the default model when no allowlist is set", () => {
|
||||
|
|
@ -58,9 +56,7 @@ describe("buildAllowedModelSet", () => {
|
|||
|
||||
expect(allowed.allowAny).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows explicit custom providers from models.providers", () => {
|
||||
|
|
@ -93,9 +89,7 @@ describe("buildAllowedModelSet", () => {
|
|||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(
|
||||
allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview")),
|
||||
).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,7 @@ export type ModelRef = {
|
|||
model: string;
|
||||
};
|
||||
|
||||
export type ThinkLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh";
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export type ModelAliasIndex = {
|
||||
byAlias: Map<string, { alias: string; ref: ModelRef }>;
|
||||
|
|
@ -40,9 +34,7 @@ export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
|
|||
if (normalized === "claude-cli") return true;
|
||||
if (normalized === "codex-cli") return true;
|
||||
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
|
||||
return Object.keys(backends).some(
|
||||
(key) => normalizeProviderId(key) === normalized,
|
||||
);
|
||||
return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized);
|
||||
}
|
||||
|
||||
function normalizeAnthropicModelId(model: string): string {
|
||||
|
|
@ -60,10 +52,7 @@ function normalizeProviderModelId(provider: string, model: string): string {
|
|||
return model;
|
||||
}
|
||||
|
||||
export function parseModelRef(
|
||||
raw: string,
|
||||
defaultProvider: string,
|
||||
): ModelRef | null {
|
||||
export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
|
|
@ -91,9 +80,7 @@ export function buildModelAliasIndex(params: {
|
|||
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
|
||||
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
const alias = String(
|
||||
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
|
||||
).trim();
|
||||
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
|
||||
if (!alias) continue;
|
||||
const aliasKey = normalizeAliasKey(alias);
|
||||
byAlias.set(aliasKey, { alias, ref: parsed });
|
||||
|
|
@ -131,10 +118,7 @@ export function resolveConfiguredModelRef(params: {
|
|||
defaultModel: string;
|
||||
}): ModelRef {
|
||||
const rawModel = (() => {
|
||||
const raw = params.cfg.agents?.defaults?.model as
|
||||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
const raw = params.cfg.agents?.defaults?.model as { primary?: string } | string | undefined;
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
return raw?.primary?.trim() ?? "";
|
||||
})();
|
||||
|
|
@ -176,9 +160,7 @@ export function buildAllowedModelSet(params: {
|
|||
defaultModel && params.defaultProvider
|
||||
? modelKey(params.defaultProvider, defaultModel)
|
||||
: undefined;
|
||||
const catalogKeys = new Set(
|
||||
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
|
||||
);
|
||||
const catalogKeys = new Set(params.catalog.map((entry) => modelKey(entry.provider, entry.id)));
|
||||
|
||||
if (allowAny) {
|
||||
if (defaultKey) catalogKeys.add(defaultKey);
|
||||
|
|
@ -190,10 +172,7 @@ export function buildAllowedModelSet(params: {
|
|||
}
|
||||
|
||||
const allowedKeys = new Set<string>();
|
||||
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<string, unknown>;
|
||||
for (const raw of rawAllowlist) {
|
||||
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
|
|
@ -253,9 +232,7 @@ export function getModelRefStatus(params: {
|
|||
const key = modelKey(params.ref.provider, params.ref.model);
|
||||
return {
|
||||
key,
|
||||
inCatalog: params.catalog.some(
|
||||
(entry) => modelKey(entry.provider, entry.id) === key,
|
||||
),
|
||||
inCatalog: params.catalog.some((entry) => modelKey(entry.provider, entry.id) === key),
|
||||
allowAny: allowed.allowAny,
|
||||
allowed: allowed.allowAny || allowed.allowedKeys.has(key),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ describe("models-config", () => {
|
|||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
|
|
@ -67,17 +66,12 @@ describe("models-config", () => {
|
|||
const agentDir = path.join(home, "agent-default-base-url");
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
"utf8",
|
||||
);
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://api.copilot.example",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
|
|
@ -104,8 +98,7 @@ describe("models-config", () => {
|
|||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -62,17 +62,12 @@ describe("models-config", () => {
|
|||
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const raw = await fs.readFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
"utf8",
|
||||
);
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://api.default.test",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
|
|
@ -111,8 +106,7 @@ describe("models-config", () => {
|
|||
);
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
|
|
@ -125,17 +119,12 @@ describe("models-config", () => {
|
|||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
"utf8",
|
||||
);
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://api.copilot.example",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
|
|
|
|||
|
|
@ -79,10 +79,7 @@ describe("models-config", () => {
|
|||
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<
|
||||
string,
|
||||
{ apiKey?: string; models?: Array<{ id: string }> }
|
||||
>;
|
||||
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
|
||||
};
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
|
|
@ -138,12 +135,8 @@ describe("models-config", () => {
|
|||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers.existing?.baseUrl).toBe(
|
||||
"http://localhost:1234/v1",
|
||||
);
|
||||
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe(
|
||||
"http://localhost:4000/v1",
|
||||
);
|
||||
expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1");
|
||||
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import {
|
|||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "./auth-profiles.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
|
|
@ -104,8 +101,7 @@ export function normalizeProviders(params: {
|
|||
// Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR".
|
||||
if (
|
||||
normalizedProvider.apiKey &&
|
||||
normalizeApiKeyConfig(normalizedProvider.apiKey) !==
|
||||
normalizedProvider.apiKey
|
||||
normalizeApiKeyConfig(normalizedProvider.apiKey) !== normalizedProvider.apiKey
|
||||
) {
|
||||
mutated = true;
|
||||
normalizedProvider = {
|
||||
|
|
@ -117,8 +113,7 @@ export function normalizeProviders(params: {
|
|||
// If a provider defines models, pi's ModelRegistry requires apiKey to be set.
|
||||
// Fill it from the environment or auth profiles when possible.
|
||||
const hasModels =
|
||||
Array.isArray(normalizedProvider.models) &&
|
||||
normalizedProvider.models.length > 0;
|
||||
Array.isArray(normalizedProvider.models) && normalizedProvider.models.length > 0;
|
||||
if (hasModels && !normalizedProvider.apiKey?.trim()) {
|
||||
const fromEnv = resolveEnvApiKeyVarName(normalizedKey);
|
||||
const fromProfiles = resolveApiKeyFromProfiles({
|
||||
|
|
@ -197,9 +192,7 @@ function buildSyntheticProvider(): ProviderConfig {
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitProviders(params: {
|
||||
agentDir: string;
|
||||
}): ModelsConfig["providers"] {
|
||||
export function resolveImplicitProviders(params: { agentDir: string }): ModelsConfig["providers"] {
|
||||
const providers: Record<string, ProviderConfig> = {};
|
||||
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
|
|
@ -235,8 +228,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||
}): Promise<ProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const authStore = ensureAuthProfileStore(params.agentDir);
|
||||
const hasProfile =
|
||||
listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||
const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||
const githubToken = (envToken ?? "").trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ describe("models-config", () => {
|
|||
agentDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.stat(path.join(agentDir, "models.json")),
|
||||
).rejects.toThrow();
|
||||
await expect(fs.stat(path.join(agentDir, "models.json"))).rejects.toThrow();
|
||||
expect(result.wrote).toBe(false);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
|
|
@ -85,8 +83,7 @@ describe("models-config", () => {
|
|||
else process.env.MINIMAX_API_KEY = previousMinimax;
|
||||
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
|
||||
else process.env.MOONSHOT_API_KEY = previousMoonshot;
|
||||
if (previousSynthetic === undefined)
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
}
|
||||
});
|
||||
|
|
@ -105,9 +102,7 @@ describe("models-config", () => {
|
|||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe(
|
||||
"http://localhost:4000/v1",
|
||||
);
|
||||
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
|
||||
});
|
||||
});
|
||||
it("adds minimax provider when MINIMAX_API_KEY is set", async () => {
|
||||
|
|
@ -133,9 +128,7 @@ describe("models-config", () => {
|
|||
}
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe(
|
||||
"https://api.minimax.io/anthropic",
|
||||
);
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-M2.1");
|
||||
|
|
@ -169,13 +162,9 @@ describe("models-config", () => {
|
|||
}
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers.synthetic?.baseUrl).toBe(
|
||||
"https://api.synthetic.new/anthropic",
|
||||
);
|
||||
expect(parsed.providers.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
|
||||
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
|
||||
const ids = parsed.providers.synthetic?.models?.map(
|
||||
(model) => model.id,
|
||||
);
|
||||
const ids = parsed.providers.synthetic?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
|
||||
} finally {
|
||||
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function mergeProviderModels(
|
||||
implicit: ProviderConfig,
|
||||
explicit: ProviderConfig,
|
||||
): ProviderConfig {
|
||||
function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
|
||||
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
|
||||
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
|
||||
if (implicitModels.length === 0) return { ...implicit, ...explicit };
|
||||
|
|
@ -55,16 +52,12 @@ function mergeProviders(params: {
|
|||
implicit?: Record<string, ProviderConfig> | null;
|
||||
explicit?: Record<string, ProviderConfig> | null;
|
||||
}): Record<string, ProviderConfig> {
|
||||
const out: Record<string, ProviderConfig> = params.implicit
|
||||
? { ...params.implicit }
|
||||
: {};
|
||||
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
|
||||
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
|
||||
const providerKey = key.trim();
|
||||
if (!providerKey) continue;
|
||||
const implicit = out[providerKey];
|
||||
out[providerKey] = implicit
|
||||
? mergeProviderModels(implicit, explicit)
|
||||
: explicit;
|
||||
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -83,14 +76,9 @@ export async function ensureClawdbotModelsJson(
|
|||
agentDirOverride?: string,
|
||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||
const cfg = config ?? loadConfig();
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir();
|
||||
|
||||
const explicitProviders = (cfg.models?.providers ?? {}) as Record<
|
||||
string,
|
||||
ProviderConfig
|
||||
>;
|
||||
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
|
||||
const implicitProviders = resolveImplicitProviders({ agentDir });
|
||||
const providers: Record<string, ProviderConfig> = mergeProviders({
|
||||
implicit: implicitProviders,
|
||||
|
|
|
|||
|
|
@ -88,8 +88,7 @@ describe("models-config", () => {
|
|||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
|
|
@ -119,8 +118,7 @@ describe("models-config", () => {
|
|||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
|
|
@ -145,17 +143,12 @@ describe("models-config", () => {
|
|||
});
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const raw = await fs.readFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
"utf8",
|
||||
);
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://copilot.local",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://copilot.local");
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
|
|
@ -19,8 +16,7 @@ import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
|
|||
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
|
||||
const REQUIRE_PROFILE_KEYS =
|
||||
process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
|
||||
const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
|
||||
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
|
|
@ -62,8 +58,7 @@ function isModelNotFoundErrorMessage(raw: string): boolean {
|
|||
if (!msg) return false;
|
||||
if (/\b404\b/.test(msg) && /not[_-]?found/i.test(msg)) return true;
|
||||
if (/not_found_error/i.test(msg)) return true;
|
||||
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg))
|
||||
return true;
|
||||
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -156,9 +151,7 @@ describeLive("live models (profile keys)", () => {
|
|||
const anthropicKeys = collectAnthropicApiKeys();
|
||||
if (anthropicKeys.length > 0) {
|
||||
process.env.ANTHROPIC_API_KEY = anthropicKeys[0];
|
||||
logProgress(
|
||||
`[live-models] anthropic keys loaded: ${anthropicKeys.length}`,
|
||||
);
|
||||
logProgress(`[live-models] anthropic keys loaded: ${anthropicKeys.length}`);
|
||||
}
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
|
|
@ -171,13 +164,8 @@ describeLive("live models (profile keys)", () => {
|
|||
const useExplicit = Boolean(rawModels) && !useModern;
|
||||
const filter = useExplicit ? parseModelFilter(rawModels) : null;
|
||||
const allowNotFoundSkip = useModern;
|
||||
const providers = parseProviderFilter(
|
||||
process.env.CLAWDBOT_LIVE_PROVIDERS,
|
||||
);
|
||||
const perModelTimeoutMs = toInt(
|
||||
process.env.CLAWDBOT_LIVE_MODEL_TIMEOUT_MS,
|
||||
30_000,
|
||||
);
|
||||
const providers = parseProviderFilter(process.env.CLAWDBOT_LIVE_PROVIDERS);
|
||||
const perModelTimeoutMs = toInt(process.env.CLAWDBOT_LIVE_MODEL_TIMEOUT_MS, 30_000);
|
||||
|
||||
const failures: Array<{ model: string; error: string }> = [];
|
||||
const skipped: Array<{ model: string; reason: string }> = [];
|
||||
|
|
@ -197,10 +185,7 @@ describeLive("live models (profile keys)", () => {
|
|||
}
|
||||
try {
|
||||
const apiKeyInfo = await getApiKeyForModel({ model, cfg });
|
||||
if (
|
||||
REQUIRE_PROFILE_KEYS &&
|
||||
!apiKeyInfo.source.startsWith("profile:")
|
||||
) {
|
||||
if (REQUIRE_PROFILE_KEYS && !apiKeyInfo.source.startsWith("profile:")) {
|
||||
skipped.push({
|
||||
model: id,
|
||||
reason: `non-profile credential source: ${apiKeyInfo.source}`,
|
||||
|
|
@ -218,9 +203,7 @@ describeLive("live models (profile keys)", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
logProgress(
|
||||
`[live-models] selection=${useExplicit ? "explicit" : "modern"}`,
|
||||
);
|
||||
logProgress(`[live-models] selection=${useExplicit ? "explicit" : "modern"}`);
|
||||
logProgress(`[live-models] running ${candidates.length} models`);
|
||||
const total = candidates.length;
|
||||
|
||||
|
|
@ -229,9 +212,7 @@ describeLive("live models (profile keys)", () => {
|
|||
const id = `${model.provider}/${model.id}`;
|
||||
const progressLabel = `[live-models] ${index + 1}/${total} ${id}`;
|
||||
const attemptMax =
|
||||
model.provider === "anthropic" && anthropicKeys.length > 0
|
||||
? anthropicKeys.length
|
||||
: 1;
|
||||
model.provider === "anthropic" && anthropicKeys.length > 0 ? anthropicKeys.length : 1;
|
||||
for (let attempt = 0; attempt < attemptMax; attempt += 1) {
|
||||
if (model.provider === "anthropic" && anthropicKeys.length > 0) {
|
||||
process.env.ANTHROPIC_API_KEY = anthropicKeys[attempt];
|
||||
|
|
@ -254,8 +235,7 @@ describeLive("live models (profile keys)", () => {
|
|||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
};
|
||||
|
||||
let firstUserContent =
|
||||
"Call the tool `noop` with {}. Do not write any other text.";
|
||||
let firstUserContent = "Call the tool `noop` with {}. Do not write any other text.";
|
||||
let firstUser = {
|
||||
role: "user" as const,
|
||||
content: firstUserContent,
|
||||
|
|
@ -282,11 +262,7 @@ describeLive("live models (profile keys)", () => {
|
|||
|
||||
// Occasional flake: model answers in text instead of tool call (or adds text).
|
||||
// Retry a couple times with a stronger instruction so we still exercise the tool-only replay path.
|
||||
for (
|
||||
let i = 0;
|
||||
i < 2 && (!toolCall || firstText.length > 0);
|
||||
i += 1
|
||||
) {
|
||||
for (let i = 0; i < 2 && (!toolCall || firstText.length > 0); i += 1) {
|
||||
firstUserContent =
|
||||
"Call the tool `noop` with {}. IMPORTANT: respond ONLY with the tool call; no other text.";
|
||||
firstUser = {
|
||||
|
|
@ -405,29 +381,19 @@ describeLive("live models (profile keys)", () => {
|
|||
isAnthropicRateLimitError(message) &&
|
||||
attempt + 1 < attemptMax
|
||||
) {
|
||||
logProgress(
|
||||
`${progressLabel}: rate limit, retrying with next key`,
|
||||
);
|
||||
logProgress(`${progressLabel}: rate limit, retrying with next key`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
model.provider === "anthropic" &&
|
||||
isAnthropicBillingError(message)
|
||||
) {
|
||||
if (model.provider === "anthropic" && isAnthropicBillingError(message)) {
|
||||
if (attempt + 1 < attemptMax) {
|
||||
logProgress(
|
||||
`${progressLabel}: billing issue, retrying with next key`,
|
||||
);
|
||||
logProgress(`${progressLabel}: billing issue, retrying with next key`);
|
||||
continue;
|
||||
}
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (anthropic billing)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
model.provider === "google" &&
|
||||
isGoogleModelNotFoundError(err)
|
||||
) {
|
||||
if (model.provider === "google" && isGoogleModelNotFoundError(err)) {
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (google model not found)`);
|
||||
break;
|
||||
|
|
@ -462,9 +428,7 @@ describeLive("live models (profile keys)", () => {
|
|||
.slice(0, 10)
|
||||
.map((f) => `- ${f.model}: ${f.error}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`live model failures (${failures.length}):\n${preview}`,
|
||||
);
|
||||
throw new Error(`live model failures (${failures.length}):\n${preview}`);
|
||||
}
|
||||
|
||||
void skipped;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
AssistantMessage,
|
||||
Model,
|
||||
ToolResultMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AssistantMessage, Model, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { streamOpenAIResponses } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
|
@ -31,8 +27,7 @@ function installFailingFetchCapture() {
|
|||
const bodyText = (() => {
|
||||
if (!rawBody) return "";
|
||||
if (typeof rawBody === "string") return rawBody;
|
||||
if (rawBody instanceof Uint8Array)
|
||||
return Buffer.from(rawBody).toString("utf8");
|
||||
if (rawBody instanceof Uint8Array) return Buffer.from(rawBody).toString("utf8");
|
||||
if (rawBody instanceof ArrayBuffer)
|
||||
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
|
||||
return String(rawBody);
|
||||
|
|
@ -135,17 +130,13 @@ describe("openai-responses reasoning replay", () => {
|
|||
const input = Array.isArray(body?.input) ? body?.input : [];
|
||||
const types = input
|
||||
.map((item) =>
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>).type
|
||||
: undefined,
|
||||
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
|
||||
expect(types).toContain("reasoning");
|
||||
expect(types).toContain("function_call");
|
||||
expect(types.indexOf("reasoning")).toBeLessThan(
|
||||
types.indexOf("function_call"),
|
||||
);
|
||||
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
|
|
@ -204,9 +195,7 @@ describe("openai-responses reasoning replay", () => {
|
|||
const input = Array.isArray(body?.input) ? body?.input : [];
|
||||
const types = input
|
||||
.map((item) =>
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>).type
|
||||
: undefined,
|
||||
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ describe("resolveOpencodeZenAlias", () => {
|
|||
});
|
||||
|
||||
it("returns input if no alias exists", () => {
|
||||
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe(
|
||||
"some-unknown-model",
|
||||
);
|
||||
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe("some-unknown-model");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
|
|
@ -42,22 +40,12 @@ describe("resolveOpencodeZenAlias", () => {
|
|||
|
||||
describe("resolveOpencodeZenModelApi", () => {
|
||||
it("maps APIs by model family", () => {
|
||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe(
|
||||
"anthropic-messages",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe(
|
||||
"anthropic-messages",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe(
|
||||
"google-generative-ai",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -91,11 +91,7 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
|
|||
*/
|
||||
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (
|
||||
lower.startsWith("claude-") ||
|
||||
lower.startsWith("minimax") ||
|
||||
lower.startsWith("alpha-gd4")
|
||||
) {
|
||||
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
|
||||
return "anthropic-messages";
|
||||
}
|
||||
if (lower.startsWith("gemini-")) {
|
||||
|
|
@ -274,9 +270,7 @@ interface ZenModelsResponse {
|
|||
* @param apiKey - OpenCode Zen API key for authentication
|
||||
* @returns Array of model definitions, or static fallback on failure
|
||||
*/
|
||||
export async function fetchOpencodeZenModels(
|
||||
apiKey?: string,
|
||||
): Promise<ModelDefinitionConfig[]> {
|
||||
export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
|
||||
// Return cached models if still valid
|
||||
const now = Date.now();
|
||||
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
|
||||
|
|
@ -298,9 +292,7 @@ export async function fetchOpencodeZenModels(
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`API returned ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ZenModelsResponse;
|
||||
|
|
@ -316,9 +308,7 @@ export async function fetchOpencodeZenModels(
|
|||
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`,
|
||||
);
|
||||
console.warn(`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`);
|
||||
return getOpencodeZenStaticFallbackModels();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
findFenceSpanAt,
|
||||
isSafeFenceBreak,
|
||||
parseFenceSpans,
|
||||
} from "../markdown/fences.js";
|
||||
import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js";
|
||||
|
||||
export type BlockReplyChunking = {
|
||||
minChars: number;
|
||||
|
|
@ -61,10 +57,7 @@ export class EmbeddedBlockChunker {
|
|||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
this.#buffer.length >= minChars ||
|
||||
(force && this.#buffer.length > 0)
|
||||
) {
|
||||
while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) {
|
||||
const breakResult =
|
||||
force && this.#buffer.length <= maxChars
|
||||
? this.#pickSoftBreakIndex(this.#buffer, 1)
|
||||
|
|
@ -80,9 +73,7 @@ export class EmbeddedBlockChunker {
|
|||
const breakIdx = breakResult.index;
|
||||
let rawChunk = this.#buffer.slice(0, breakIdx);
|
||||
if (rawChunk.trim().length === 0) {
|
||||
this.#buffer = stripLeadingNewlines(
|
||||
this.#buffer.slice(breakIdx),
|
||||
).trimStart();
|
||||
this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -118,10 +109,7 @@ export class EmbeddedBlockChunker {
|
|||
}
|
||||
|
||||
#pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
|
||||
const minChars = Math.max(
|
||||
1,
|
||||
Math.floor(minCharsOverride ?? this.#chunking.minChars),
|
||||
);
|
||||
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
|
||||
if (buffer.length < minChars) return { index: -1 };
|
||||
const fenceSpans = parseFenceSpans(buffer);
|
||||
const preference = this.#chunking.breakPreference ?? "paragraph";
|
||||
|
|
@ -144,10 +132,7 @@ export class EmbeddedBlockChunker {
|
|||
if (preference === "paragraph" || preference === "newline") {
|
||||
let newlineIdx = buffer.indexOf("\n");
|
||||
while (newlineIdx !== -1) {
|
||||
if (
|
||||
newlineIdx >= minChars &&
|
||||
isSafeFenceBreak(fenceSpans, newlineIdx)
|
||||
) {
|
||||
if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) {
|
||||
return { index: newlineIdx };
|
||||
}
|
||||
newlineIdx = buffer.indexOf("\n", newlineIdx + 1);
|
||||
|
|
@ -172,10 +157,7 @@ export class EmbeddedBlockChunker {
|
|||
}
|
||||
|
||||
#pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
|
||||
const minChars = Math.max(
|
||||
1,
|
||||
Math.floor(minCharsOverride ?? this.#chunking.minChars),
|
||||
);
|
||||
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
if (buffer.length < minChars) return { index: -1 };
|
||||
const window = buffer.slice(0, Math.min(maxChars, buffer.length));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
DEFAULT_BOOTSTRAP_MAX_CHARS,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
@ -40,9 +35,7 @@ describe("buildBootstrapContextFiles", () => {
|
|||
maxChars,
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
expect(result?.content).toContain(
|
||||
"[...truncated, read TOOLS.md for full content...]",
|
||||
);
|
||||
expect(result?.content).toContain("[...truncated, read TOOLS.md for full content...]");
|
||||
expect(result?.content.length).toBeLessThan(long.length);
|
||||
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
|
||||
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
|
||||
|
|
@ -55,8 +48,6 @@ describe("buildBootstrapContextFiles", () => {
|
|||
const files = [makeFile({ content: long })];
|
||||
const [result] = buildBootstrapContextFiles(files);
|
||||
expect(result?.content).toBe(long);
|
||||
expect(result?.content).not.toContain(
|
||||
"[...truncated, read AGENTS.md for full content...]",
|
||||
);
|
||||
expect(result?.content).not.toContain("[...truncated, read AGENTS.md for full content...]");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { classifyFailoverReason } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
@ -17,9 +15,7 @@ describe("classifyFailoverReason", () => {
|
|||
expect(classifyFailoverReason("no credentials found")).toBe("auth");
|
||||
expect(classifyFailoverReason("no api key found")).toBe("auth");
|
||||
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
|
||||
expect(classifyFailoverReason("resource has been exhausted")).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit");
|
||||
expect(
|
||||
classifyFailoverReason(
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||
|
|
@ -28,16 +24,12 @@ describe("classifyFailoverReason", () => {
|
|||
expect(classifyFailoverReason("invalid request format")).toBe("format");
|
||||
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
|
||||
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||
expect(classifyFailoverReason("string should match pattern")).toBe(
|
||||
"format",
|
||||
);
|
||||
expect(classifyFailoverReason("string should match pattern")).toBe("format");
|
||||
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||
});
|
||||
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
||||
expect(
|
||||
classifyFailoverReason(
|
||||
"You have hit your ChatGPT usage limit (plus plan)",
|
||||
),
|
||||
).toBe("rate_limit");
|
||||
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
@ -24,12 +22,8 @@ describe("formatAssistantErrorText", () => {
|
|||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||
});
|
||||
it("returns a friendly message for Anthropic role ordering", () => {
|
||||
const msg = makeAssistantError(
|
||||
'messages: roles must alternate between "user" and "assistant"',
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toContain(
|
||||
"Message ordering conflict",
|
||||
);
|
||||
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
|
||||
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
|
||||
});
|
||||
it("returns a friendly message for Anthropic overload errors", () => {
|
||||
const msg = makeAssistantError(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { isAuthErrorMessage } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { isBillingErrorMessage } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { isCloudCodeAssistFormatError } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { isCompactionFailureError } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (
|
||||
overrides: Partial<WorkspaceBootstrapFile>,
|
||||
): WorkspaceBootstrapFile => ({
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
|
|
@ -23,9 +21,7 @@ describe("isCompactionFailureError", () => {
|
|||
}
|
||||
});
|
||||
it("ignores non-compaction overflow errors", () => {
|
||||
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false);
|
||||
expect(isCompactionFailureError("rate limit exceeded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue