darkfire514 commited on
Commit
fb4d8fe
·
verified ·
1 Parent(s): 904d6be

Upload 2526 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/acp/client.ts +191 -0
  2. src/acp/commands.ts +40 -0
  3. src/acp/event-mapper.test.ts +31 -0
  4. src/acp/event-mapper.ts +95 -0
  5. src/acp/index.ts +4 -0
  6. src/acp/meta.ts +47 -0
  7. src/acp/server.ts +144 -0
  8. src/acp/session-mapper.test.ts +56 -0
  9. src/acp/session-mapper.ts +98 -0
  10. src/acp/session.test.ts +25 -0
  11. src/acp/session.ts +94 -0
  12. src/acp/translator.ts +454 -0
  13. src/acp/types.ts +29 -0
  14. src/agents/agent-paths.test.ts +56 -0
  15. src/agents/agent-paths.ts +25 -0
  16. src/agents/agent-scope.test.ts +203 -0
  17. src/agents/agent-scope.ts +178 -0
  18. src/agents/anthropic-payload-log.ts +229 -0
  19. src/agents/anthropic.setup-token.live.test.ts +226 -0
  20. src/agents/apply-patch-update.ts +199 -0
  21. src/agents/apply-patch.test.ts +73 -0
  22. src/agents/apply-patch.ts +503 -0
  23. src/agents/auth-health.test.ts +89 -0
  24. src/agents/auth-health.ts +252 -0
  25. src/agents/auth-profiles.auth-profile-cooldowns.test.ts +12 -0
  26. src/agents/auth-profiles.chutes.test.ts +100 -0
  27. src/agents/auth-profiles.ensureauthprofilestore.test.ts +125 -0
  28. src/agents/auth-profiles.markauthprofilefailure.test.ts +131 -0
  29. src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +234 -0
  30. src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts +142 -0
  31. src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts +96 -0
  32. src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts +169 -0
  33. src/agents/auth-profiles.ts +40 -0
  34. src/agents/auth-profiles/constants.ts +26 -0
  35. src/agents/auth-profiles/display.ts +17 -0
  36. src/agents/auth-profiles/doctor.ts +47 -0
  37. src/agents/auth-profiles/external-cli-sync.ts +135 -0
  38. src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +173 -0
  39. src/agents/auth-profiles/oauth.ts +285 -0
  40. src/agents/auth-profiles/order.ts +210 -0
  41. src/agents/auth-profiles/paths.ts +33 -0
  42. src/agents/auth-profiles/profiles.ts +92 -0
  43. src/agents/auth-profiles/repair.ts +169 -0
  44. src/agents/auth-profiles/session-override.test.ts +63 -0
  45. src/agents/auth-profiles/session-override.ts +151 -0
  46. src/agents/auth-profiles/store.ts +378 -0
  47. src/agents/auth-profiles/types.ts +72 -0
  48. src/agents/auth-profiles/usage.ts +322 -0
  49. src/agents/bash-process-registry.test.ts +180 -0
  50. src/agents/bash-process-registry.ts +274 -0
src/acp/client.ts ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ClientSideConnection,
3
+ PROTOCOL_VERSION,
4
+ ndJsonStream,
5
+ type RequestPermissionRequest,
6
+ type SessionNotification,
7
+ } from "@agentclientprotocol/sdk";
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import * as readline from "node:readline";
10
+ import { Readable, Writable } from "node:stream";
11
+ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
12
+
13
+ export type AcpClientOptions = {
14
+ cwd?: string;
15
+ serverCommand?: string;
16
+ serverArgs?: string[];
17
+ serverVerbose?: boolean;
18
+ verbose?: boolean;
19
+ };
20
+
21
+ export type AcpClientHandle = {
22
+ client: ClientSideConnection;
23
+ agent: ChildProcess;
24
+ sessionId: string;
25
+ };
26
+
27
+ function toArgs(value: string[] | string | undefined): string[] {
28
+ if (!value) {
29
+ return [];
30
+ }
31
+ return Array.isArray(value) ? value : [value];
32
+ }
33
+
34
+ function buildServerArgs(opts: AcpClientOptions): string[] {
35
+ const args = ["acp", ...toArgs(opts.serverArgs)];
36
+ if (opts.serverVerbose && !args.includes("--verbose") && !args.includes("-v")) {
37
+ args.push("--verbose");
38
+ }
39
+ return args;
40
+ }
41
+
42
+ function printSessionUpdate(notification: SessionNotification): void {
43
+ const update = notification.update;
44
+ if (!("sessionUpdate" in update)) {
45
+ return;
46
+ }
47
+
48
+ switch (update.sessionUpdate) {
49
+ case "agent_message_chunk": {
50
+ if (update.content?.type === "text") {
51
+ process.stdout.write(update.content.text);
52
+ }
53
+ return;
54
+ }
55
+ case "tool_call": {
56
+ console.log(`\n[tool] ${update.title} (${update.status})`);
57
+ return;
58
+ }
59
+ case "tool_call_update": {
60
+ if (update.status) {
61
+ console.log(`[tool update] ${update.toolCallId}: ${update.status}`);
62
+ }
63
+ return;
64
+ }
65
+ case "available_commands_update": {
66
+ const names = update.availableCommands?.map((cmd) => `/${cmd.name}`).join(" ");
67
+ if (names) {
68
+ console.log(`\n[commands] ${names}`);
69
+ }
70
+ return;
71
+ }
72
+ default:
73
+ return;
74
+ }
75
+ }
76
+
77
+ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpClientHandle> {
78
+ const cwd = opts.cwd ?? process.cwd();
79
+ const verbose = Boolean(opts.verbose);
80
+ const log = verbose ? (msg: string) => console.error(`[acp-client] ${msg}`) : () => {};
81
+
82
+ ensureOpenClawCliOnPath({ cwd });
83
+ const serverCommand = opts.serverCommand ?? "openclaw";
84
+ const serverArgs = buildServerArgs(opts);
85
+
86
+ log(`spawning: ${serverCommand} ${serverArgs.join(" ")}`);
87
+
88
+ const agent = spawn(serverCommand, serverArgs, {
89
+ stdio: ["pipe", "pipe", "inherit"],
90
+ cwd,
91
+ });
92
+
93
+ if (!agent.stdin || !agent.stdout) {
94
+ throw new Error("Failed to create ACP stdio pipes");
95
+ }
96
+
97
+ const input = Writable.toWeb(agent.stdin);
98
+ const output = Readable.toWeb(agent.stdout) as unknown as ReadableStream<Uint8Array>;
99
+ const stream = ndJsonStream(input, output);
100
+
101
+ const client = new ClientSideConnection(
102
+ () => ({
103
+ sessionUpdate: async (params: SessionNotification) => {
104
+ printSessionUpdate(params);
105
+ },
106
+ requestPermission: async (params: RequestPermissionRequest) => {
107
+ console.log("\n[permission requested]", params.toolCall?.title ?? "tool");
108
+ const options = params.options ?? [];
109
+ const allowOnce = options.find((option) => option.kind === "allow_once");
110
+ const fallback = options[0];
111
+ return {
112
+ outcome: {
113
+ outcome: "selected",
114
+ optionId: allowOnce?.optionId ?? fallback?.optionId ?? "allow",
115
+ },
116
+ };
117
+ },
118
+ }),
119
+ stream,
120
+ );
121
+
122
+ log("initializing");
123
+ await client.initialize({
124
+ protocolVersion: PROTOCOL_VERSION,
125
+ clientCapabilities: {
126
+ fs: { readTextFile: true, writeTextFile: true },
127
+ terminal: true,
128
+ },
129
+ clientInfo: { name: "openclaw-acp-client", version: "1.0.0" },
130
+ });
131
+
132
+ log("creating session");
133
+ const session = await client.newSession({
134
+ cwd,
135
+ mcpServers: [],
136
+ });
137
+
138
+ return {
139
+ client,
140
+ agent,
141
+ sessionId: session.sessionId,
142
+ };
143
+ }
144
+
145
+ export async function runAcpClientInteractive(opts: AcpClientOptions = {}): Promise<void> {
146
+ const { client, agent, sessionId } = await createAcpClient(opts);
147
+
148
+ const rl = readline.createInterface({
149
+ input: process.stdin,
150
+ output: process.stdout,
151
+ });
152
+
153
+ console.log("OpenClaw ACP client");
154
+ console.log(`Session: ${sessionId}`);
155
+ console.log('Type a prompt, or "exit" to quit.\n');
156
+
157
+ const prompt = () => {
158
+ rl.question("> ", async (input) => {
159
+ const text = input.trim();
160
+ if (!text) {
161
+ prompt();
162
+ return;
163
+ }
164
+ if (text === "exit" || text === "quit") {
165
+ agent.kill();
166
+ rl.close();
167
+ process.exit(0);
168
+ }
169
+
170
+ try {
171
+ const response = await client.prompt({
172
+ sessionId,
173
+ prompt: [{ type: "text", text }],
174
+ });
175
+ console.log(`\n[${response.stopReason}]\n`);
176
+ } catch (err) {
177
+ console.error(`\n[error] ${String(err)}\n`);
178
+ }
179
+
180
+ prompt();
181
+ });
182
+ };
183
+
184
+ prompt();
185
+
186
+ agent.on("exit", (code) => {
187
+ console.log(`\nAgent exited with code ${code ?? 0}`);
188
+ rl.close();
189
+ process.exit(code ?? 0);
190
+ });
191
+ }
src/acp/commands.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AvailableCommand } from "@agentclientprotocol/sdk";
2
+
3
+ export function getAvailableCommands(): AvailableCommand[] {
4
+ return [
5
+ { name: "help", description: "Show help and common commands." },
6
+ { name: "commands", description: "List available commands." },
7
+ { name: "status", description: "Show current status." },
8
+ {
9
+ name: "context",
10
+ description: "Explain context usage (list|detail|json).",
11
+ input: { hint: "list | detail | json" },
12
+ },
13
+ { name: "whoami", description: "Show sender id (alias: /id)." },
14
+ { name: "id", description: "Alias for /whoami." },
15
+ { name: "subagents", description: "List or manage sub-agents." },
16
+ { name: "config", description: "Read or write config (owner-only)." },
17
+ { name: "debug", description: "Set runtime-only overrides (owner-only)." },
18
+ { name: "usage", description: "Toggle usage footer (off|tokens|full)." },
19
+ { name: "stop", description: "Stop the current run." },
20
+ { name: "restart", description: "Restart the gateway (if enabled)." },
21
+ { name: "dock-telegram", description: "Route replies to Telegram." },
22
+ { name: "dock-discord", description: "Route replies to Discord." },
23
+ { name: "dock-slack", description: "Route replies to Slack." },
24
+ { name: "activation", description: "Set group activation (mention|always)." },
25
+ { name: "send", description: "Set send mode (on|off|inherit)." },
26
+ { name: "reset", description: "Reset the session (/new)." },
27
+ { name: "new", description: "Reset the session (/reset)." },
28
+ {
29
+ name: "think",
30
+ description: "Set thinking level (off|minimal|low|medium|high|xhigh).",
31
+ },
32
+ { name: "verbose", description: "Set verbose mode (on|full|off)." },
33
+ { name: "reasoning", description: "Toggle reasoning output (on|off|stream)." },
34
+ { name: "elevated", description: "Toggle elevated mode (on|off)." },
35
+ { name: "model", description: "Select a model (list|status|<name>)." },
36
+ { name: "queue", description: "Adjust queue mode and options." },
37
+ { name: "bash", description: "Run a host command (if enabled)." },
38
+ { name: "compact", description: "Compact the session history." },
39
+ ];
40
+ }
src/acp/event-mapper.test.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
3
+
4
+ describe("acp event mapper", () => {
5
+ it("extracts text and resource blocks into prompt text", () => {
6
+ const text = extractTextFromPrompt([
7
+ { type: "text", text: "Hello" },
8
+ { type: "resource", resource: { text: "File contents" } },
9
+ { type: "resource_link", uri: "https://example.com", title: "Spec" },
10
+ { type: "image", data: "abc", mimeType: "image/png" },
11
+ ]);
12
+
13
+ expect(text).toBe("Hello\nFile contents\n[Resource link (Spec)] https://example.com");
14
+ });
15
+
16
+ it("extracts image blocks into gateway attachments", () => {
17
+ const attachments = extractAttachmentsFromPrompt([
18
+ { type: "image", data: "abc", mimeType: "image/png" },
19
+ { type: "image", data: "", mimeType: "image/png" },
20
+ { type: "text", text: "ignored" },
21
+ ]);
22
+
23
+ expect(attachments).toEqual([
24
+ {
25
+ type: "image",
26
+ mimeType: "image/png",
27
+ content: "abc",
28
+ },
29
+ ]);
30
+ });
31
+ });
src/acp/event-mapper.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
2
+
3
+ export type GatewayAttachment = {
4
+ type: string;
5
+ mimeType: string;
6
+ content: string;
7
+ };
8
+
9
+ export function extractTextFromPrompt(prompt: ContentBlock[]): string {
10
+ const parts: string[] = [];
11
+ for (const block of prompt) {
12
+ if (block.type === "text") {
13
+ parts.push(block.text);
14
+ continue;
15
+ }
16
+ if (block.type === "resource") {
17
+ const resource = block.resource as { text?: string } | undefined;
18
+ if (resource?.text) {
19
+ parts.push(resource.text);
20
+ }
21
+ continue;
22
+ }
23
+ if (block.type === "resource_link") {
24
+ const title = block.title ? ` (${block.title})` : "";
25
+ const uri = block.uri ?? "";
26
+ const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
27
+ parts.push(line);
28
+ }
29
+ }
30
+ return parts.join("\n");
31
+ }
32
+
33
+ export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
34
+ const attachments: GatewayAttachment[] = [];
35
+ for (const block of prompt) {
36
+ if (block.type !== "image") {
37
+ continue;
38
+ }
39
+ const image = block as ImageContent;
40
+ if (!image.data || !image.mimeType) {
41
+ continue;
42
+ }
43
+ attachments.push({
44
+ type: "image",
45
+ mimeType: image.mimeType,
46
+ content: image.data,
47
+ });
48
+ }
49
+ return attachments;
50
+ }
51
+
52
+ export function formatToolTitle(
53
+ name: string | undefined,
54
+ args: Record<string, unknown> | undefined,
55
+ ): string {
56
+ const base = name ?? "tool";
57
+ if (!args || Object.keys(args).length === 0) {
58
+ return base;
59
+ }
60
+ const parts = Object.entries(args).map(([key, value]) => {
61
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
62
+ const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
63
+ return `${key}: ${safe}`;
64
+ });
65
+ return `${base}: ${parts.join(", ")}`;
66
+ }
67
+
68
+ export function inferToolKind(name?: string): ToolKind {
69
+ if (!name) {
70
+ return "other";
71
+ }
72
+ const normalized = name.toLowerCase();
73
+ if (normalized.includes("read")) {
74
+ return "read";
75
+ }
76
+ if (normalized.includes("write") || normalized.includes("edit")) {
77
+ return "edit";
78
+ }
79
+ if (normalized.includes("delete") || normalized.includes("remove")) {
80
+ return "delete";
81
+ }
82
+ if (normalized.includes("move") || normalized.includes("rename")) {
83
+ return "move";
84
+ }
85
+ if (normalized.includes("search") || normalized.includes("find")) {
86
+ return "search";
87
+ }
88
+ if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
89
+ return "execute";
90
+ }
91
+ if (normalized.includes("fetch") || normalized.includes("http")) {
92
+ return "fetch";
93
+ }
94
+ return "other";
95
+ }
src/acp/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export { serveAcpGateway } from "./server.js";
2
+ export { createInMemorySessionStore } from "./session.js";
3
+ export type { AcpSessionStore } from "./session.js";
4
+ export type { AcpServerOptions } from "./types.js";
src/acp/meta.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function readString(
2
+ meta: Record<string, unknown> | null | undefined,
3
+ keys: string[],
4
+ ): string | undefined {
5
+ if (!meta) {
6
+ return undefined;
7
+ }
8
+ for (const key of keys) {
9
+ const value = meta[key];
10
+ if (typeof value === "string" && value.trim()) {
11
+ return value.trim();
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ export function readBool(
18
+ meta: Record<string, unknown> | null | undefined,
19
+ keys: string[],
20
+ ): boolean | undefined {
21
+ if (!meta) {
22
+ return undefined;
23
+ }
24
+ for (const key of keys) {
25
+ const value = meta[key];
26
+ if (typeof value === "boolean") {
27
+ return value;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ export function readNumber(
34
+ meta: Record<string, unknown> | null | undefined,
35
+ keys: string[],
36
+ ): number | undefined {
37
+ if (!meta) {
38
+ return undefined;
39
+ }
40
+ for (const key of keys) {
41
+ const value = meta[key];
42
+ if (typeof value === "number" && Number.isFinite(value)) {
43
+ return value;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
src/acp/server.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
3
+ import { Readable, Writable } from "node:stream";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { AcpServerOptions } from "./types.js";
6
+ import { loadConfig } from "../config/config.js";
7
+ import { resolveGatewayAuth } from "../gateway/auth.js";
8
+ import { buildGatewayConnectionDetails } from "../gateway/call.js";
9
+ import { GatewayClient } from "../gateway/client.js";
10
+ import { isMainModule } from "../infra/is-main.js";
11
+ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
12
+ import { AcpGatewayAgent } from "./translator.js";
13
+
14
+ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
15
+ const cfg = loadConfig();
16
+ const connection = buildGatewayConnectionDetails({
17
+ config: cfg,
18
+ url: opts.gatewayUrl,
19
+ });
20
+
21
+ const isRemoteMode = cfg.gateway?.mode === "remote";
22
+ const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
23
+ const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
24
+
25
+ const token =
26
+ opts.gatewayToken ??
27
+ (isRemoteMode ? remote?.token?.trim() : undefined) ??
28
+ process.env.OPENCLAW_GATEWAY_TOKEN ??
29
+ auth.token;
30
+ const password =
31
+ opts.gatewayPassword ??
32
+ (isRemoteMode ? remote?.password?.trim() : undefined) ??
33
+ process.env.OPENCLAW_GATEWAY_PASSWORD ??
34
+ auth.password;
35
+
36
+ let agent: AcpGatewayAgent | null = null;
37
+ const gateway = new GatewayClient({
38
+ url: connection.url,
39
+ token: token || undefined,
40
+ password: password || undefined,
41
+ clientName: GATEWAY_CLIENT_NAMES.CLI,
42
+ clientDisplayName: "ACP",
43
+ clientVersion: "acp",
44
+ mode: GATEWAY_CLIENT_MODES.CLI,
45
+ onEvent: (evt) => {
46
+ void agent?.handleGatewayEvent(evt);
47
+ },
48
+ onHelloOk: () => {
49
+ agent?.handleGatewayReconnect();
50
+ },
51
+ onClose: (code, reason) => {
52
+ agent?.handleGatewayDisconnect(`${code}: ${reason}`);
53
+ },
54
+ });
55
+
56
+ const input = Writable.toWeb(process.stdout);
57
+ const output = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
58
+ const stream = ndJsonStream(input, output);
59
+
60
+ new AgentSideConnection((conn: AgentSideConnection) => {
61
+ agent = new AcpGatewayAgent(conn, gateway, opts);
62
+ agent.start();
63
+ return agent;
64
+ }, stream);
65
+
66
+ gateway.start();
67
+ }
68
+
69
+ function parseArgs(args: string[]): AcpServerOptions {
70
+ const opts: AcpServerOptions = {};
71
+ for (let i = 0; i < args.length; i += 1) {
72
+ const arg = args[i];
73
+ if (arg === "--url" || arg === "--gateway-url") {
74
+ opts.gatewayUrl = args[i + 1];
75
+ i += 1;
76
+ continue;
77
+ }
78
+ if (arg === "--token" || arg === "--gateway-token") {
79
+ opts.gatewayToken = args[i + 1];
80
+ i += 1;
81
+ continue;
82
+ }
83
+ if (arg === "--password" || arg === "--gateway-password") {
84
+ opts.gatewayPassword = args[i + 1];
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (arg === "--session") {
89
+ opts.defaultSessionKey = args[i + 1];
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (arg === "--session-label") {
94
+ opts.defaultSessionLabel = args[i + 1];
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (arg === "--require-existing") {
99
+ opts.requireExistingSession = true;
100
+ continue;
101
+ }
102
+ if (arg === "--reset-session") {
103
+ opts.resetSession = true;
104
+ continue;
105
+ }
106
+ if (arg === "--no-prefix-cwd") {
107
+ opts.prefixCwd = false;
108
+ continue;
109
+ }
110
+ if (arg === "--verbose" || arg === "-v") {
111
+ opts.verbose = true;
112
+ continue;
113
+ }
114
+ if (arg === "--help" || arg === "-h") {
115
+ printHelp();
116
+ process.exit(0);
117
+ }
118
+ }
119
+ return opts;
120
+ }
121
+
122
+ function printHelp(): void {
123
+ console.log(`Usage: openclaw acp [options]
124
+
125
+ Gateway-backed ACP server for IDE integration.
126
+
127
+ Options:
128
+ --url <url> Gateway WebSocket URL
129
+ --token <token> Gateway auth token
130
+ --password <password> Gateway auth password
131
+ --session <key> Default session key (e.g. "agent:main:main")
132
+ --session-label <label> Default session label to resolve
133
+ --require-existing Fail if the session key/label does not exist
134
+ --reset-session Reset the session key before first use
135
+ --no-prefix-cwd Do not prefix prompts with the working directory
136
+ --verbose, -v Verbose logging to stderr
137
+ --help, -h Show this help message
138
+ `);
139
+ }
140
+
141
+ if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
142
+ const opts = parseArgs(process.argv.slice(2));
143
+ serveAcpGateway(opts);
144
+ }
src/acp/session-mapper.test.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { GatewayClient } from "../gateway/client.js";
3
+ import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
4
+
5
+ function createGateway(resolveLabelKey = "agent:main:label"): {
6
+ gateway: GatewayClient;
7
+ request: ReturnType<typeof vi.fn>;
8
+ } {
9
+ const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
10
+ if (method === "sessions.resolve" && "label" in params) {
11
+ return { ok: true, key: resolveLabelKey };
12
+ }
13
+ if (method === "sessions.resolve" && "key" in params) {
14
+ return { ok: true, key: params.key as string };
15
+ }
16
+ return { ok: true };
17
+ });
18
+
19
+ return {
20
+ gateway: { request } as unknown as GatewayClient,
21
+ request,
22
+ };
23
+ }
24
+
25
+ describe("acp session mapper", () => {
26
+ it("prefers explicit sessionLabel over sessionKey", async () => {
27
+ const { gateway, request } = createGateway();
28
+ const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
29
+
30
+ const key = await resolveSessionKey({
31
+ meta,
32
+ fallbackKey: "acp:fallback",
33
+ gateway,
34
+ opts: {},
35
+ });
36
+
37
+ expect(key).toBe("agent:main:label");
38
+ expect(request).toHaveBeenCalledTimes(1);
39
+ expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
40
+ });
41
+
42
+ it("lets meta sessionKey override default label", async () => {
43
+ const { gateway, request } = createGateway();
44
+ const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
45
+
46
+ const key = await resolveSessionKey({
47
+ meta,
48
+ fallbackKey: "acp:fallback",
49
+ gateway,
50
+ opts: { defaultSessionLabel: "default-label" },
51
+ });
52
+
53
+ expect(key).toBe("agent:main:override");
54
+ expect(request).not.toHaveBeenCalled();
55
+ });
56
+ });
src/acp/session-mapper.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GatewayClient } from "../gateway/client.js";
2
+ import type { AcpServerOptions } from "./types.js";
3
+ import { readBool, readString } from "./meta.js";
4
+
5
+ export type AcpSessionMeta = {
6
+ sessionKey?: string;
7
+ sessionLabel?: string;
8
+ resetSession?: boolean;
9
+ requireExisting?: boolean;
10
+ prefixCwd?: boolean;
11
+ };
12
+
13
+ export function parseSessionMeta(meta: unknown): AcpSessionMeta {
14
+ if (!meta || typeof meta !== "object") {
15
+ return {};
16
+ }
17
+ const record = meta as Record<string, unknown>;
18
+ return {
19
+ sessionKey: readString(record, ["sessionKey", "session", "key"]),
20
+ sessionLabel: readString(record, ["sessionLabel", "label"]),
21
+ resetSession: readBool(record, ["resetSession", "reset"]),
22
+ requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
23
+ prefixCwd: readBool(record, ["prefixCwd"]),
24
+ };
25
+ }
26
+
27
+ export async function resolveSessionKey(params: {
28
+ meta: AcpSessionMeta;
29
+ fallbackKey: string;
30
+ gateway: GatewayClient;
31
+ opts: AcpServerOptions;
32
+ }): Promise<string> {
33
+ const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
34
+ const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
35
+ const requireExisting =
36
+ params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
37
+
38
+ if (params.meta.sessionLabel) {
39
+ const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
40
+ label: params.meta.sessionLabel,
41
+ });
42
+ if (!resolved?.key) {
43
+ throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
44
+ }
45
+ return resolved.key;
46
+ }
47
+
48
+ if (params.meta.sessionKey) {
49
+ if (!requireExisting) {
50
+ return params.meta.sessionKey;
51
+ }
52
+ const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
53
+ key: params.meta.sessionKey,
54
+ });
55
+ if (!resolved?.key) {
56
+ throw new Error(`Session key not found: ${params.meta.sessionKey}`);
57
+ }
58
+ return resolved.key;
59
+ }
60
+
61
+ if (requestedLabel) {
62
+ const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
63
+ label: requestedLabel,
64
+ });
65
+ if (!resolved?.key) {
66
+ throw new Error(`Unable to resolve session label: ${requestedLabel}`);
67
+ }
68
+ return resolved.key;
69
+ }
70
+
71
+ if (requestedKey) {
72
+ if (!requireExisting) {
73
+ return requestedKey;
74
+ }
75
+ const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
76
+ key: requestedKey,
77
+ });
78
+ if (!resolved?.key) {
79
+ throw new Error(`Session key not found: ${requestedKey}`);
80
+ }
81
+ return resolved.key;
82
+ }
83
+
84
+ return params.fallbackKey;
85
+ }
86
+
87
+ export async function resetSessionIfNeeded(params: {
88
+ meta: AcpSessionMeta;
89
+ sessionKey: string;
90
+ gateway: GatewayClient;
91
+ opts: AcpServerOptions;
92
+ }): Promise<void> {
93
+ const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
94
+ if (!resetSession) {
95
+ return;
96
+ }
97
+ await params.gateway.request("sessions.reset", { key: params.sessionKey });
98
+ }
src/acp/session.test.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, afterEach } from "vitest";
2
+ import { createInMemorySessionStore } from "./session.js";
3
+
4
+ describe("acp session manager", () => {
5
+ const store = createInMemorySessionStore();
6
+
7
+ afterEach(() => {
8
+ store.clearAllSessionsForTest();
9
+ });
10
+
11
+ it("tracks active runs and clears on cancel", () => {
12
+ const session = store.createSession({
13
+ sessionKey: "acp:test",
14
+ cwd: "/tmp",
15
+ });
16
+ const controller = new AbortController();
17
+ store.setActiveRun(session.sessionId, "run-1", controller);
18
+
19
+ expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
20
+
21
+ const cancelled = store.cancelActiveRun(session.sessionId);
22
+ expect(cancelled).toBe(true);
23
+ expect(store.getSessionByRunId("run-1")).toBeUndefined();
24
+ });
25
+ });
src/acp/session.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AcpSession } from "./types.js";
3
+
4
+ export type AcpSessionStore = {
5
+ createSession: (params: { sessionKey: string; cwd: string; sessionId?: string }) => AcpSession;
6
+ getSession: (sessionId: string) => AcpSession | undefined;
7
+ getSessionByRunId: (runId: string) => AcpSession | undefined;
8
+ setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
9
+ clearActiveRun: (sessionId: string) => void;
10
+ cancelActiveRun: (sessionId: string) => boolean;
11
+ clearAllSessionsForTest: () => void;
12
+ };
13
+
14
+ export function createInMemorySessionStore(): AcpSessionStore {
15
+ const sessions = new Map<string, AcpSession>();
16
+ const runIdToSessionId = new Map<string, string>();
17
+
18
+ const createSession: AcpSessionStore["createSession"] = (params) => {
19
+ const sessionId = params.sessionId ?? randomUUID();
20
+ const session: AcpSession = {
21
+ sessionId,
22
+ sessionKey: params.sessionKey,
23
+ cwd: params.cwd,
24
+ createdAt: Date.now(),
25
+ abortController: null,
26
+ activeRunId: null,
27
+ };
28
+ sessions.set(sessionId, session);
29
+ return session;
30
+ };
31
+
32
+ const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
33
+
34
+ const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
35
+ const sessionId = runIdToSessionId.get(runId);
36
+ return sessionId ? sessions.get(sessionId) : undefined;
37
+ };
38
+
39
+ const setActiveRun: AcpSessionStore["setActiveRun"] = (sessionId, runId, abortController) => {
40
+ const session = sessions.get(sessionId);
41
+ if (!session) {
42
+ return;
43
+ }
44
+ session.activeRunId = runId;
45
+ session.abortController = abortController;
46
+ runIdToSessionId.set(runId, sessionId);
47
+ };
48
+
49
+ const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
50
+ const session = sessions.get(sessionId);
51
+ if (!session) {
52
+ return;
53
+ }
54
+ if (session.activeRunId) {
55
+ runIdToSessionId.delete(session.activeRunId);
56
+ }
57
+ session.activeRunId = null;
58
+ session.abortController = null;
59
+ };
60
+
61
+ const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
62
+ const session = sessions.get(sessionId);
63
+ if (!session?.abortController) {
64
+ return false;
65
+ }
66
+ session.abortController.abort();
67
+ if (session.activeRunId) {
68
+ runIdToSessionId.delete(session.activeRunId);
69
+ }
70
+ session.abortController = null;
71
+ session.activeRunId = null;
72
+ return true;
73
+ };
74
+
75
+ const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
76
+ for (const session of sessions.values()) {
77
+ session.abortController?.abort();
78
+ }
79
+ sessions.clear();
80
+ runIdToSessionId.clear();
81
+ };
82
+
83
+ return {
84
+ createSession,
85
+ getSession,
86
+ getSessionByRunId,
87
+ setActiveRun,
88
+ clearActiveRun,
89
+ cancelActiveRun,
90
+ clearAllSessionsForTest,
91
+ };
92
+ }
93
+
94
+ export const defaultAcpSessionStore = createInMemorySessionStore();
src/acp/translator.ts ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ Agent,
3
+ AgentSideConnection,
4
+ AuthenticateRequest,
5
+ AuthenticateResponse,
6
+ CancelNotification,
7
+ InitializeRequest,
8
+ InitializeResponse,
9
+ ListSessionsRequest,
10
+ ListSessionsResponse,
11
+ LoadSessionRequest,
12
+ LoadSessionResponse,
13
+ NewSessionRequest,
14
+ NewSessionResponse,
15
+ PromptRequest,
16
+ PromptResponse,
17
+ SetSessionModeRequest,
18
+ SetSessionModeResponse,
19
+ StopReason,
20
+ } from "@agentclientprotocol/sdk";
21
+ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
22
+ import { randomUUID } from "node:crypto";
23
+ import type { GatewayClient } from "../gateway/client.js";
24
+ import type { EventFrame } from "../gateway/protocol/index.js";
25
+ import type { SessionsListResult } from "../gateway/session-utils.js";
26
+ import { getAvailableCommands } from "./commands.js";
27
+ import {
28
+ extractAttachmentsFromPrompt,
29
+ extractTextFromPrompt,
30
+ formatToolTitle,
31
+ inferToolKind,
32
+ } from "./event-mapper.js";
33
+ import { readBool, readNumber, readString } from "./meta.js";
34
+ import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
35
+ import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js";
36
+ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
37
+
38
+ type PendingPrompt = {
39
+ sessionId: string;
40
+ sessionKey: string;
41
+ idempotencyKey: string;
42
+ resolve: (response: PromptResponse) => void;
43
+ reject: (err: Error) => void;
44
+ sentTextLength?: number;
45
+ sentText?: string;
46
+ toolCalls?: Set<string>;
47
+ };
48
+
49
+ type AcpGatewayAgentOptions = AcpServerOptions & {
50
+ sessionStore?: AcpSessionStore;
51
+ };
52
+
53
+ export class AcpGatewayAgent implements Agent {
54
+ private connection: AgentSideConnection;
55
+ private gateway: GatewayClient;
56
+ private opts: AcpGatewayAgentOptions;
57
+ private log: (msg: string) => void;
58
+ private sessionStore: AcpSessionStore;
59
+ private pendingPrompts = new Map<string, PendingPrompt>();
60
+
61
+ constructor(
62
+ connection: AgentSideConnection,
63
+ gateway: GatewayClient,
64
+ opts: AcpGatewayAgentOptions = {},
65
+ ) {
66
+ this.connection = connection;
67
+ this.gateway = gateway;
68
+ this.opts = opts;
69
+ this.log = opts.verbose ? (msg: string) => process.stderr.write(`[acp] ${msg}\n`) : () => {};
70
+ this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
71
+ }
72
+
73
+ start(): void {
74
+ this.log("ready");
75
+ }
76
+
77
+ handleGatewayReconnect(): void {
78
+ this.log("gateway reconnected");
79
+ }
80
+
81
+ handleGatewayDisconnect(reason: string): void {
82
+ this.log(`gateway disconnected: ${reason}`);
83
+ for (const pending of this.pendingPrompts.values()) {
84
+ pending.reject(new Error(`Gateway disconnected: ${reason}`));
85
+ this.sessionStore.clearActiveRun(pending.sessionId);
86
+ }
87
+ this.pendingPrompts.clear();
88
+ }
89
+
90
+ async handleGatewayEvent(evt: EventFrame): Promise<void> {
91
+ if (evt.event === "chat") {
92
+ await this.handleChatEvent(evt);
93
+ return;
94
+ }
95
+ if (evt.event === "agent") {
96
+ await this.handleAgentEvent(evt);
97
+ }
98
+ }
99
+
100
+ async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
101
+ return {
102
+ protocolVersion: PROTOCOL_VERSION,
103
+ agentCapabilities: {
104
+ loadSession: true,
105
+ promptCapabilities: {
106
+ image: true,
107
+ audio: false,
108
+ embeddedContext: true,
109
+ },
110
+ mcpCapabilities: {
111
+ http: false,
112
+ sse: false,
113
+ },
114
+ sessionCapabilities: {
115
+ list: {},
116
+ },
117
+ },
118
+ agentInfo: ACP_AGENT_INFO,
119
+ authMethods: [],
120
+ };
121
+ }
122
+
123
+ async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
124
+ if (params.mcpServers.length > 0) {
125
+ this.log(`ignoring ${params.mcpServers.length} MCP servers`);
126
+ }
127
+
128
+ const sessionId = randomUUID();
129
+ const meta = parseSessionMeta(params._meta);
130
+ const sessionKey = await resolveSessionKey({
131
+ meta,
132
+ fallbackKey: `acp:${sessionId}`,
133
+ gateway: this.gateway,
134
+ opts: this.opts,
135
+ });
136
+ await resetSessionIfNeeded({
137
+ meta,
138
+ sessionKey,
139
+ gateway: this.gateway,
140
+ opts: this.opts,
141
+ });
142
+
143
+ const session = this.sessionStore.createSession({
144
+ sessionId,
145
+ sessionKey,
146
+ cwd: params.cwd,
147
+ });
148
+ this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
149
+ await this.sendAvailableCommands(session.sessionId);
150
+ return { sessionId: session.sessionId };
151
+ }
152
+
153
+ async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
154
+ if (params.mcpServers.length > 0) {
155
+ this.log(`ignoring ${params.mcpServers.length} MCP servers`);
156
+ }
157
+
158
+ const meta = parseSessionMeta(params._meta);
159
+ const sessionKey = await resolveSessionKey({
160
+ meta,
161
+ fallbackKey: params.sessionId,
162
+ gateway: this.gateway,
163
+ opts: this.opts,
164
+ });
165
+ await resetSessionIfNeeded({
166
+ meta,
167
+ sessionKey,
168
+ gateway: this.gateway,
169
+ opts: this.opts,
170
+ });
171
+
172
+ const session = this.sessionStore.createSession({
173
+ sessionId: params.sessionId,
174
+ sessionKey,
175
+ cwd: params.cwd,
176
+ });
177
+ this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
178
+ await this.sendAvailableCommands(session.sessionId);
179
+ return {};
180
+ }
181
+
182
+ async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
183
+ const limit = readNumber(params._meta, ["limit"]) ?? 100;
184
+ const result = await this.gateway.request<SessionsListResult>("sessions.list", { limit });
185
+ const cwd = params.cwd ?? process.cwd();
186
+ return {
187
+ sessions: result.sessions.map((session) => ({
188
+ sessionId: session.key,
189
+ cwd,
190
+ title: session.displayName ?? session.label ?? session.key,
191
+ updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
192
+ _meta: {
193
+ sessionKey: session.key,
194
+ kind: session.kind,
195
+ channel: session.channel,
196
+ },
197
+ })),
198
+ nextCursor: null,
199
+ };
200
+ }
201
+
202
+ async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
203
+ return {};
204
+ }
205
+
206
+ async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
207
+ const session = this.sessionStore.getSession(params.sessionId);
208
+ if (!session) {
209
+ throw new Error(`Session ${params.sessionId} not found`);
210
+ }
211
+ if (!params.modeId) {
212
+ return {};
213
+ }
214
+ try {
215
+ await this.gateway.request("sessions.patch", {
216
+ key: session.sessionKey,
217
+ thinkingLevel: params.modeId,
218
+ });
219
+ this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
220
+ } catch (err) {
221
+ this.log(`setSessionMode error: ${String(err)}`);
222
+ }
223
+ return {};
224
+ }
225
+
226
+ async prompt(params: PromptRequest): Promise<PromptResponse> {
227
+ const session = this.sessionStore.getSession(params.sessionId);
228
+ if (!session) {
229
+ throw new Error(`Session ${params.sessionId} not found`);
230
+ }
231
+
232
+ if (session.abortController) {
233
+ this.sessionStore.cancelActiveRun(params.sessionId);
234
+ }
235
+
236
+ const abortController = new AbortController();
237
+ const runId = randomUUID();
238
+ this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
239
+
240
+ const meta = parseSessionMeta(params._meta);
241
+ const userText = extractTextFromPrompt(params.prompt);
242
+ const attachments = extractAttachmentsFromPrompt(params.prompt);
243
+ const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
244
+ const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
245
+
246
+ return new Promise<PromptResponse>((resolve, reject) => {
247
+ this.pendingPrompts.set(params.sessionId, {
248
+ sessionId: params.sessionId,
249
+ sessionKey: session.sessionKey,
250
+ idempotencyKey: runId,
251
+ resolve,
252
+ reject,
253
+ });
254
+
255
+ this.gateway
256
+ .request(
257
+ "chat.send",
258
+ {
259
+ sessionKey: session.sessionKey,
260
+ message,
261
+ attachments: attachments.length > 0 ? attachments : undefined,
262
+ idempotencyKey: runId,
263
+ thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
264
+ deliver: readBool(params._meta, ["deliver"]),
265
+ timeoutMs: readNumber(params._meta, ["timeoutMs"]),
266
+ },
267
+ { expectFinal: true },
268
+ )
269
+ .catch((err) => {
270
+ this.pendingPrompts.delete(params.sessionId);
271
+ this.sessionStore.clearActiveRun(params.sessionId);
272
+ reject(err instanceof Error ? err : new Error(String(err)));
273
+ });
274
+ });
275
+ }
276
+
277
+ async cancel(params: CancelNotification): Promise<void> {
278
+ const session = this.sessionStore.getSession(params.sessionId);
279
+ if (!session) {
280
+ return;
281
+ }
282
+
283
+ this.sessionStore.cancelActiveRun(params.sessionId);
284
+ try {
285
+ await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
286
+ } catch (err) {
287
+ this.log(`cancel error: ${String(err)}`);
288
+ }
289
+
290
+ const pending = this.pendingPrompts.get(params.sessionId);
291
+ if (pending) {
292
+ this.pendingPrompts.delete(params.sessionId);
293
+ pending.resolve({ stopReason: "cancelled" });
294
+ }
295
+ }
296
+
297
+ private async handleAgentEvent(evt: EventFrame): Promise<void> {
298
+ const payload = evt.payload as Record<string, unknown> | undefined;
299
+ if (!payload) {
300
+ return;
301
+ }
302
+ const stream = payload.stream as string | undefined;
303
+ const data = payload.data as Record<string, unknown> | undefined;
304
+ const sessionKey = payload.sessionKey as string | undefined;
305
+ if (!stream || !data || !sessionKey) {
306
+ return;
307
+ }
308
+
309
+ if (stream !== "tool") {
310
+ return;
311
+ }
312
+ const phase = data.phase as string | undefined;
313
+ const name = data.name as string | undefined;
314
+ const toolCallId = data.toolCallId as string | undefined;
315
+ if (!toolCallId) {
316
+ return;
317
+ }
318
+
319
+ const pending = this.findPendingBySessionKey(sessionKey);
320
+ if (!pending) {
321
+ return;
322
+ }
323
+
324
+ if (phase === "start") {
325
+ if (!pending.toolCalls) {
326
+ pending.toolCalls = new Set();
327
+ }
328
+ if (pending.toolCalls.has(toolCallId)) {
329
+ return;
330
+ }
331
+ pending.toolCalls.add(toolCallId);
332
+ const args = data.args as Record<string, unknown> | undefined;
333
+ await this.connection.sessionUpdate({
334
+ sessionId: pending.sessionId,
335
+ update: {
336
+ sessionUpdate: "tool_call",
337
+ toolCallId,
338
+ title: formatToolTitle(name, args),
339
+ status: "in_progress",
340
+ rawInput: args,
341
+ kind: inferToolKind(name),
342
+ },
343
+ });
344
+ return;
345
+ }
346
+
347
+ if (phase === "result") {
348
+ const isError = Boolean(data.isError);
349
+ await this.connection.sessionUpdate({
350
+ sessionId: pending.sessionId,
351
+ update: {
352
+ sessionUpdate: "tool_call_update",
353
+ toolCallId,
354
+ status: isError ? "failed" : "completed",
355
+ rawOutput: data.result,
356
+ },
357
+ });
358
+ }
359
+ }
360
+
361
+ private async handleChatEvent(evt: EventFrame): Promise<void> {
362
+ const payload = evt.payload as Record<string, unknown> | undefined;
363
+ if (!payload) {
364
+ return;
365
+ }
366
+
367
+ const sessionKey = payload.sessionKey as string | undefined;
368
+ const state = payload.state as string | undefined;
369
+ const runId = payload.runId as string | undefined;
370
+ const messageData = payload.message as Record<string, unknown> | undefined;
371
+ if (!sessionKey || !state) {
372
+ return;
373
+ }
374
+
375
+ const pending = this.findPendingBySessionKey(sessionKey);
376
+ if (!pending) {
377
+ return;
378
+ }
379
+ if (runId && pending.idempotencyKey !== runId) {
380
+ return;
381
+ }
382
+
383
+ if (state === "delta" && messageData) {
384
+ await this.handleDeltaEvent(pending.sessionId, messageData);
385
+ return;
386
+ }
387
+
388
+ if (state === "final") {
389
+ this.finishPrompt(pending.sessionId, pending, "end_turn");
390
+ return;
391
+ }
392
+ if (state === "aborted") {
393
+ this.finishPrompt(pending.sessionId, pending, "cancelled");
394
+ return;
395
+ }
396
+ if (state === "error") {
397
+ this.finishPrompt(pending.sessionId, pending, "refusal");
398
+ }
399
+ }
400
+
401
+ private async handleDeltaEvent(
402
+ sessionId: string,
403
+ messageData: Record<string, unknown>,
404
+ ): Promise<void> {
405
+ const content = messageData.content as Array<{ type: string; text?: string }> | undefined;
406
+ const fullText = content?.find((c) => c.type === "text")?.text ?? "";
407
+ const pending = this.pendingPrompts.get(sessionId);
408
+ if (!pending) {
409
+ return;
410
+ }
411
+
412
+ const sentSoFar = pending.sentTextLength ?? 0;
413
+ if (fullText.length <= sentSoFar) {
414
+ return;
415
+ }
416
+
417
+ const newText = fullText.slice(sentSoFar);
418
+ pending.sentTextLength = fullText.length;
419
+ pending.sentText = fullText;
420
+
421
+ await this.connection.sessionUpdate({
422
+ sessionId,
423
+ update: {
424
+ sessionUpdate: "agent_message_chunk",
425
+ content: { type: "text", text: newText },
426
+ },
427
+ });
428
+ }
429
+
430
+ private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void {
431
+ this.pendingPrompts.delete(sessionId);
432
+ this.sessionStore.clearActiveRun(sessionId);
433
+ pending.resolve({ stopReason });
434
+ }
435
+
436
+ private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
437
+ for (const pending of this.pendingPrompts.values()) {
438
+ if (pending.sessionKey === sessionKey) {
439
+ return pending;
440
+ }
441
+ }
442
+ return undefined;
443
+ }
444
+
445
+ private async sendAvailableCommands(sessionId: string): Promise<void> {
446
+ await this.connection.sessionUpdate({
447
+ sessionId,
448
+ update: {
449
+ sessionUpdate: "available_commands_update",
450
+ availableCommands: getAvailableCommands(),
451
+ },
452
+ });
453
+ }
454
+ }
src/acp/types.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SessionId } from "@agentclientprotocol/sdk";
2
+ import { VERSION } from "../version.js";
3
+
4
+ export type AcpSession = {
5
+ sessionId: SessionId;
6
+ sessionKey: string;
7
+ cwd: string;
8
+ createdAt: number;
9
+ abortController: AbortController | null;
10
+ activeRunId: string | null;
11
+ };
12
+
13
+ export type AcpServerOptions = {
14
+ gatewayUrl?: string;
15
+ gatewayToken?: string;
16
+ gatewayPassword?: string;
17
+ defaultSessionKey?: string;
18
+ defaultSessionLabel?: string;
19
+ requireExistingSession?: boolean;
20
+ resetSession?: boolean;
21
+ prefixCwd?: boolean;
22
+ verbose?: boolean;
23
+ };
24
+
25
+ export const ACP_AGENT_INFO = {
26
+ name: "openclaw-acp",
27
+ title: "OpenClaw ACP Gateway",
28
+ version: VERSION,
29
+ };
src/agents/agent-paths.test.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { resolveOpenClawAgentDir } from "./agent-paths.js";
6
+
7
+ describe("resolveOpenClawAgentDir", () => {
8
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
9
+ const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
10
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
11
+ let tempStateDir: string | null = null;
12
+
13
+ afterEach(async () => {
14
+ if (tempStateDir) {
15
+ await fs.rm(tempStateDir, { recursive: true, force: true });
16
+ tempStateDir = null;
17
+ }
18
+ if (previousStateDir === undefined) {
19
+ delete process.env.OPENCLAW_STATE_DIR;
20
+ } else {
21
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
22
+ }
23
+ if (previousAgentDir === undefined) {
24
+ delete process.env.OPENCLAW_AGENT_DIR;
25
+ } else {
26
+ process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
27
+ }
28
+ if (previousPiAgentDir === undefined) {
29
+ delete process.env.PI_CODING_AGENT_DIR;
30
+ } else {
31
+ process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
32
+ }
33
+ });
34
+
35
+ it("defaults to the multi-agent path when no overrides are set", async () => {
36
+ tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
37
+ process.env.OPENCLAW_STATE_DIR = tempStateDir;
38
+ delete process.env.OPENCLAW_AGENT_DIR;
39
+ delete process.env.PI_CODING_AGENT_DIR;
40
+
41
+ const resolved = resolveOpenClawAgentDir();
42
+
43
+ expect(resolved).toBe(path.join(tempStateDir, "agents", "main", "agent"));
44
+ });
45
+
46
+ it("honors OPENCLAW_AGENT_DIR overrides", async () => {
47
+ tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
48
+ const override = path.join(tempStateDir, "agent");
49
+ process.env.OPENCLAW_AGENT_DIR = override;
50
+ delete process.env.PI_CODING_AGENT_DIR;
51
+
52
+ const resolved = resolveOpenClawAgentDir();
53
+
54
+ expect(resolved).toBe(path.resolve(override));
55
+ });
56
+ });
src/agents/agent-paths.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "node:path";
2
+ import { resolveStateDir } from "../config/paths.js";
3
+ import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
4
+ import { resolveUserPath } from "../utils.js";
5
+
6
+ export function resolveOpenClawAgentDir(): string {
7
+ const override =
8
+ process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
9
+ if (override) {
10
+ return resolveUserPath(override);
11
+ }
12
+ const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
13
+ return resolveUserPath(defaultAgentDir);
14
+ }
15
+
16
+ export function ensureOpenClawAgentEnv(): string {
17
+ const dir = resolveOpenClawAgentDir();
18
+ if (!process.env.OPENCLAW_AGENT_DIR) {
19
+ process.env.OPENCLAW_AGENT_DIR = dir;
20
+ }
21
+ if (!process.env.PI_CODING_AGENT_DIR) {
22
+ process.env.PI_CODING_AGENT_DIR = dir;
23
+ }
24
+ return dir;
25
+ }
src/agents/agent-scope.test.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import type { OpenClawConfig } from "../config/config.js";
3
+ import {
4
+ resolveAgentConfig,
5
+ resolveAgentModelFallbacksOverride,
6
+ resolveAgentModelPrimary,
7
+ } from "./agent-scope.js";
8
+
9
+ describe("resolveAgentConfig", () => {
10
+ it("should return undefined when no agents config exists", () => {
11
+ const cfg: OpenClawConfig = {};
12
+ const result = resolveAgentConfig(cfg, "main");
13
+ expect(result).toBeUndefined();
14
+ });
15
+
16
+ it("should return undefined when agent id does not exist", () => {
17
+ const cfg: OpenClawConfig = {
18
+ agents: {
19
+ list: [{ id: "main", workspace: "~/openclaw" }],
20
+ },
21
+ };
22
+ const result = resolveAgentConfig(cfg, "nonexistent");
23
+ expect(result).toBeUndefined();
24
+ });
25
+
26
+ it("should return basic agent config", () => {
27
+ const cfg: OpenClawConfig = {
28
+ agents: {
29
+ list: [
30
+ {
31
+ id: "main",
32
+ name: "Main Agent",
33
+ workspace: "~/openclaw",
34
+ agentDir: "~/.openclaw/agents/main",
35
+ model: "anthropic/claude-opus-4",
36
+ },
37
+ ],
38
+ },
39
+ };
40
+ const result = resolveAgentConfig(cfg, "main");
41
+ expect(result).toEqual({
42
+ name: "Main Agent",
43
+ workspace: "~/openclaw",
44
+ agentDir: "~/.openclaw/agents/main",
45
+ model: "anthropic/claude-opus-4",
46
+ identity: undefined,
47
+ groupChat: undefined,
48
+ subagents: undefined,
49
+ sandbox: undefined,
50
+ tools: undefined,
51
+ });
52
+ });
53
+
54
+ it("supports per-agent model primary+fallbacks", () => {
55
+ const cfg: OpenClawConfig = {
56
+ agents: {
57
+ defaults: {
58
+ model: {
59
+ primary: "anthropic/claude-sonnet-4",
60
+ fallbacks: ["openai/gpt-4.1"],
61
+ },
62
+ },
63
+ list: [
64
+ {
65
+ id: "linus",
66
+ model: {
67
+ primary: "anthropic/claude-opus-4",
68
+ fallbacks: ["openai/gpt-5.2"],
69
+ },
70
+ },
71
+ ],
72
+ },
73
+ };
74
+
75
+ expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
76
+ expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
77
+
78
+ // If fallbacks isn't present, we don't override the global fallbacks.
79
+ const cfgNoOverride: OpenClawConfig = {
80
+ agents: {
81
+ list: [
82
+ {
83
+ id: "linus",
84
+ model: {
85
+ primary: "anthropic/claude-opus-4",
86
+ },
87
+ },
88
+ ],
89
+ },
90
+ };
91
+ expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined);
92
+
93
+ // Explicit empty list disables global fallbacks for that agent.
94
+ const cfgDisable: OpenClawConfig = {
95
+ agents: {
96
+ list: [
97
+ {
98
+ id: "linus",
99
+ model: {
100
+ primary: "anthropic/claude-opus-4",
101
+ fallbacks: [],
102
+ },
103
+ },
104
+ ],
105
+ },
106
+ };
107
+ expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
108
+ });
109
+
110
+ it("should return agent-specific sandbox config", () => {
111
+ const cfg: OpenClawConfig = {
112
+ agents: {
113
+ list: [
114
+ {
115
+ id: "work",
116
+ workspace: "~/openclaw-work",
117
+ sandbox: {
118
+ mode: "all",
119
+ scope: "agent",
120
+ perSession: false,
121
+ workspaceAccess: "ro",
122
+ workspaceRoot: "~/sandboxes",
123
+ },
124
+ },
125
+ ],
126
+ },
127
+ };
128
+ const result = resolveAgentConfig(cfg, "work");
129
+ expect(result?.sandbox).toEqual({
130
+ mode: "all",
131
+ scope: "agent",
132
+ perSession: false,
133
+ workspaceAccess: "ro",
134
+ workspaceRoot: "~/sandboxes",
135
+ });
136
+ });
137
+
138
+ it("should return agent-specific tools config", () => {
139
+ const cfg: OpenClawConfig = {
140
+ agents: {
141
+ list: [
142
+ {
143
+ id: "restricted",
144
+ workspace: "~/openclaw-restricted",
145
+ tools: {
146
+ allow: ["read"],
147
+ deny: ["exec", "write", "edit"],
148
+ elevated: {
149
+ enabled: false,
150
+ allowFrom: { whatsapp: ["+15555550123"] },
151
+ },
152
+ },
153
+ },
154
+ ],
155
+ },
156
+ };
157
+ const result = resolveAgentConfig(cfg, "restricted");
158
+ expect(result?.tools).toEqual({
159
+ allow: ["read"],
160
+ deny: ["exec", "write", "edit"],
161
+ elevated: {
162
+ enabled: false,
163
+ allowFrom: { whatsapp: ["+15555550123"] },
164
+ },
165
+ });
166
+ });
167
+
168
+ it("should return both sandbox and tools config", () => {
169
+ const cfg: OpenClawConfig = {
170
+ agents: {
171
+ list: [
172
+ {
173
+ id: "family",
174
+ workspace: "~/openclaw-family",
175
+ sandbox: {
176
+ mode: "all",
177
+ scope: "agent",
178
+ },
179
+ tools: {
180
+ allow: ["read"],
181
+ deny: ["exec"],
182
+ },
183
+ },
184
+ ],
185
+ },
186
+ };
187
+ const result = resolveAgentConfig(cfg, "family");
188
+ expect(result?.sandbox?.mode).toBe("all");
189
+ expect(result?.tools?.allow).toEqual(["read"]);
190
+ });
191
+
192
+ it("should normalize agent id", () => {
193
+ const cfg: OpenClawConfig = {
194
+ agents: {
195
+ list: [{ id: "main", workspace: "~/openclaw" }],
196
+ },
197
+ };
198
+ // Should normalize to "main" (default)
199
+ const result = resolveAgentConfig(cfg, "");
200
+ expect(result).toBeDefined();
201
+ expect(result?.workspace).toBe("~/openclaw");
202
+ });
203
+ });
src/agents/agent-scope.ts ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import type { OpenClawConfig } from "../config/config.js";
4
+ import { resolveStateDir } from "../config/paths.js";
5
+ import {
6
+ DEFAULT_AGENT_ID,
7
+ normalizeAgentId,
8
+ parseAgentSessionKey,
9
+ } from "../routing/session-key.js";
10
+ import { resolveUserPath } from "../utils.js";
11
+ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
12
+
13
+ export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
14
+
15
+ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
16
+
17
+ type ResolvedAgentConfig = {
18
+ name?: string;
19
+ workspace?: string;
20
+ agentDir?: string;
21
+ model?: AgentEntry["model"];
22
+ memorySearch?: AgentEntry["memorySearch"];
23
+ humanDelay?: AgentEntry["humanDelay"];
24
+ heartbeat?: AgentEntry["heartbeat"];
25
+ identity?: AgentEntry["identity"];
26
+ groupChat?: AgentEntry["groupChat"];
27
+ subagents?: AgentEntry["subagents"];
28
+ sandbox?: AgentEntry["sandbox"];
29
+ tools?: AgentEntry["tools"];
30
+ };
31
+
32
+ let defaultAgentWarned = false;
33
+
34
+ function listAgents(cfg: OpenClawConfig): AgentEntry[] {
35
+ const list = cfg.agents?.list;
36
+ if (!Array.isArray(list)) {
37
+ return [];
38
+ }
39
+ return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
40
+ }
41
+
42
+ export function listAgentIds(cfg: OpenClawConfig): string[] {
43
+ const agents = listAgents(cfg);
44
+ if (agents.length === 0) {
45
+ return [DEFAULT_AGENT_ID];
46
+ }
47
+ const seen = new Set<string>();
48
+ const ids: string[] = [];
49
+ for (const entry of agents) {
50
+ const id = normalizeAgentId(entry?.id);
51
+ if (seen.has(id)) {
52
+ continue;
53
+ }
54
+ seen.add(id);
55
+ ids.push(id);
56
+ }
57
+ return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
58
+ }
59
+
60
+ export function resolveDefaultAgentId(cfg: OpenClawConfig): string {
61
+ const agents = listAgents(cfg);
62
+ if (agents.length === 0) {
63
+ return DEFAULT_AGENT_ID;
64
+ }
65
+ const defaults = agents.filter((agent) => agent?.default);
66
+ if (defaults.length > 1 && !defaultAgentWarned) {
67
+ defaultAgentWarned = true;
68
+ console.warn("Multiple agents marked default=true; using the first entry as default.");
69
+ }
70
+ const chosen = (defaults[0] ?? agents[0])?.id?.trim();
71
+ return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
72
+ }
73
+
74
+ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: OpenClawConfig }): {
75
+ defaultAgentId: string;
76
+ sessionAgentId: string;
77
+ } {
78
+ const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
79
+ const sessionKey = params.sessionKey?.trim();
80
+ const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
81
+ const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
82
+ const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
83
+ return { defaultAgentId, sessionAgentId };
84
+ }
85
+
86
+ export function resolveSessionAgentId(params: {
87
+ sessionKey?: string;
88
+ config?: OpenClawConfig;
89
+ }): string {
90
+ return resolveSessionAgentIds(params).sessionAgentId;
91
+ }
92
+
93
+ function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
94
+ const id = normalizeAgentId(agentId);
95
+ return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
96
+ }
97
+
98
+ export function resolveAgentConfig(
99
+ cfg: OpenClawConfig,
100
+ agentId: string,
101
+ ): ResolvedAgentConfig | undefined {
102
+ const id = normalizeAgentId(agentId);
103
+ const entry = resolveAgentEntry(cfg, id);
104
+ if (!entry) {
105
+ return undefined;
106
+ }
107
+ return {
108
+ name: typeof entry.name === "string" ? entry.name : undefined,
109
+ workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
110
+ agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
111
+ model:
112
+ typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
113
+ ? entry.model
114
+ : undefined,
115
+ memorySearch: entry.memorySearch,
116
+ humanDelay: entry.humanDelay,
117
+ heartbeat: entry.heartbeat,
118
+ identity: entry.identity,
119
+ groupChat: entry.groupChat,
120
+ subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
121
+ sandbox: entry.sandbox,
122
+ tools: entry.tools,
123
+ };
124
+ }
125
+
126
+ export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
127
+ const raw = resolveAgentConfig(cfg, agentId)?.model;
128
+ if (!raw) {
129
+ return undefined;
130
+ }
131
+ if (typeof raw === "string") {
132
+ return raw.trim() || undefined;
133
+ }
134
+ const primary = raw.primary?.trim();
135
+ return primary || undefined;
136
+ }
137
+
138
+ export function resolveAgentModelFallbacksOverride(
139
+ cfg: OpenClawConfig,
140
+ agentId: string,
141
+ ): string[] | undefined {
142
+ const raw = resolveAgentConfig(cfg, agentId)?.model;
143
+ if (!raw || typeof raw === "string") {
144
+ return undefined;
145
+ }
146
+ // Important: treat an explicitly provided empty array as an override to disable global fallbacks.
147
+ if (!Object.hasOwn(raw, "fallbacks")) {
148
+ return undefined;
149
+ }
150
+ return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
151
+ }
152
+
153
+ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
154
+ const id = normalizeAgentId(agentId);
155
+ const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
156
+ if (configured) {
157
+ return resolveUserPath(configured);
158
+ }
159
+ const defaultAgentId = resolveDefaultAgentId(cfg);
160
+ if (id === defaultAgentId) {
161
+ const fallback = cfg.agents?.defaults?.workspace?.trim();
162
+ if (fallback) {
163
+ return resolveUserPath(fallback);
164
+ }
165
+ return DEFAULT_AGENT_WORKSPACE_DIR;
166
+ }
167
+ return path.join(os.homedir(), ".openclaw", `workspace-${id}`);
168
+ }
169
+
170
+ export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) {
171
+ const id = normalizeAgentId(agentId);
172
+ const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
173
+ if (configured) {
174
+ return resolveUserPath(configured);
175
+ }
176
+ const root = resolveStateDir(process.env, os.homedir);
177
+ return path.join(root, "agents", id, "agent");
178
+ }
src/agents/anthropic-payload-log.ts ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
2
+ import type { Api, Model } from "@mariozechner/pi-ai";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { resolveStateDir } from "../config/paths.js";
7
+ import { createSubsystemLogger } from "../logging/subsystem.js";
8
+ import { resolveUserPath } from "../utils.js";
9
+ import { parseBooleanValue } from "../utils/boolean.js";
10
+
11
+ type PayloadLogStage = "request" | "usage";
12
+
13
+ type PayloadLogEvent = {
14
+ ts: string;
15
+ stage: PayloadLogStage;
16
+ runId?: string;
17
+ sessionId?: string;
18
+ sessionKey?: string;
19
+ provider?: string;
20
+ modelId?: string;
21
+ modelApi?: string | null;
22
+ workspaceDir?: string;
23
+ payload?: unknown;
24
+ usage?: Record<string, unknown>;
25
+ error?: string;
26
+ payloadDigest?: string;
27
+ };
28
+
29
+ type PayloadLogConfig = {
30
+ enabled: boolean;
31
+ filePath: string;
32
+ };
33
+
34
+ type PayloadLogWriter = {
35
+ filePath: string;
36
+ write: (line: string) => void;
37
+ };
38
+
39
+ const writers = new Map<string, PayloadLogWriter>();
40
+ const log = createSubsystemLogger("agent/anthropic-payload");
41
+
42
+ function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
43
+ const enabled = parseBooleanValue(env.OPENCLAW_ANTHROPIC_PAYLOAD_LOG) ?? false;
44
+ const fileOverride = env.OPENCLAW_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
45
+ const filePath = fileOverride
46
+ ? resolveUserPath(fileOverride)
47
+ : path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
48
+ return { enabled, filePath };
49
+ }
50
+
51
+ function getWriter(filePath: string): PayloadLogWriter {
52
+ const existing = writers.get(filePath);
53
+ if (existing) {
54
+ return existing;
55
+ }
56
+
57
+ const dir = path.dirname(filePath);
58
+ const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
59
+ let queue = Promise.resolve();
60
+
61
+ const writer: PayloadLogWriter = {
62
+ filePath,
63
+ write: (line: string) => {
64
+ queue = queue
65
+ .then(() => ready)
66
+ .then(() => fs.appendFile(filePath, line, "utf8"))
67
+ .catch(() => undefined);
68
+ },
69
+ };
70
+
71
+ writers.set(filePath, writer);
72
+ return writer;
73
+ }
74
+
75
+ function safeJsonStringify(value: unknown): string | null {
76
+ try {
77
+ return JSON.stringify(value, (_key, val) => {
78
+ if (typeof val === "bigint") {
79
+ return val.toString();
80
+ }
81
+ if (typeof val === "function") {
82
+ return "[Function]";
83
+ }
84
+ if (val instanceof Error) {
85
+ return { name: val.name, message: val.message, stack: val.stack };
86
+ }
87
+ if (val instanceof Uint8Array) {
88
+ return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
89
+ }
90
+ return val;
91
+ });
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function formatError(error: unknown): string | undefined {
98
+ if (error instanceof Error) {
99
+ return error.message;
100
+ }
101
+ if (typeof error === "string") {
102
+ return error;
103
+ }
104
+ if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
105
+ return String(error);
106
+ }
107
+ if (error && typeof error === "object") {
108
+ return safeJsonStringify(error) ?? "unknown error";
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function digest(value: unknown): string | undefined {
114
+ const serialized = safeJsonStringify(value);
115
+ if (!serialized) {
116
+ return undefined;
117
+ }
118
+ return crypto.createHash("sha256").update(serialized).digest("hex");
119
+ }
120
+
121
+ function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
122
+ return (model as { api?: unknown })?.api === "anthropic-messages";
123
+ }
124
+
125
+ function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
126
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
127
+ const msg = messages[i] as { role?: unknown; usage?: unknown };
128
+ if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
129
+ return msg.usage as Record<string, unknown>;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export type AnthropicPayloadLogger = {
136
+ enabled: true;
137
+ wrapStreamFn: (streamFn: StreamFn) => StreamFn;
138
+ recordUsage: (messages: AgentMessage[], error?: unknown) => void;
139
+ };
140
+
141
+ export function createAnthropicPayloadLogger(params: {
142
+ env?: NodeJS.ProcessEnv;
143
+ runId?: string;
144
+ sessionId?: string;
145
+ sessionKey?: string;
146
+ provider?: string;
147
+ modelId?: string;
148
+ modelApi?: string | null;
149
+ workspaceDir?: string;
150
+ }): AnthropicPayloadLogger | null {
151
+ const env = params.env ?? process.env;
152
+ const cfg = resolvePayloadLogConfig(env);
153
+ if (!cfg.enabled) {
154
+ return null;
155
+ }
156
+
157
+ const writer = getWriter(cfg.filePath);
158
+ const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
159
+ runId: params.runId,
160
+ sessionId: params.sessionId,
161
+ sessionKey: params.sessionKey,
162
+ provider: params.provider,
163
+ modelId: params.modelId,
164
+ modelApi: params.modelApi,
165
+ workspaceDir: params.workspaceDir,
166
+ };
167
+
168
+ const record = (event: PayloadLogEvent) => {
169
+ const line = safeJsonStringify(event);
170
+ if (!line) {
171
+ return;
172
+ }
173
+ writer.write(`${line}\n`);
174
+ };
175
+
176
+ const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
177
+ const wrapped: StreamFn = (model, context, options) => {
178
+ if (!isAnthropicModel(model)) {
179
+ return streamFn(model, context, options);
180
+ }
181
+ const nextOnPayload = (payload: unknown) => {
182
+ record({
183
+ ...base,
184
+ ts: new Date().toISOString(),
185
+ stage: "request",
186
+ payload,
187
+ payloadDigest: digest(payload),
188
+ });
189
+ options?.onPayload?.(payload);
190
+ };
191
+ return streamFn(model, context, {
192
+ ...options,
193
+ onPayload: nextOnPayload,
194
+ });
195
+ };
196
+ return wrapped;
197
+ };
198
+
199
+ const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
200
+ const usage = findLastAssistantUsage(messages);
201
+ const errorMessage = formatError(error);
202
+ if (!usage) {
203
+ if (errorMessage) {
204
+ record({
205
+ ...base,
206
+ ts: new Date().toISOString(),
207
+ stage: "usage",
208
+ error: errorMessage,
209
+ });
210
+ }
211
+ return;
212
+ }
213
+ record({
214
+ ...base,
215
+ ts: new Date().toISOString(),
216
+ stage: "usage",
217
+ usage,
218
+ error: errorMessage,
219
+ });
220
+ log.info("anthropic usage", {
221
+ runId: params.runId,
222
+ sessionId: params.sessionId,
223
+ usage,
224
+ });
225
+ };
226
+
227
+ log.info("anthropic payload logger enabled", { filePath: writer.filePath });
228
+ return { enabled: true, wrapStreamFn, recordUsage };
229
+ }
src/agents/anthropic.setup-token.live.test.ts ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ ANTHROPIC_SETUP_TOKEN_PREFIX,
9
+ validateAnthropicSetupToken,
10
+ } from "../commands/auth-token.js";
11
+ import { loadConfig } from "../config/config.js";
12
+ import { isTruthyEnvValue } from "../infra/env.js";
13
+ import { resolveOpenClawAgentDir } from "./agent-paths.js";
14
+ import {
15
+ type AuthProfileCredential,
16
+ ensureAuthProfileStore,
17
+ saveAuthProfileStore,
18
+ } from "./auth-profiles.js";
19
+ import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
20
+ import { normalizeProviderId, parseModelRef } from "./model-selection.js";
21
+ import { ensureOpenClawModelsJson } from "./models-config.js";
22
+ import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
23
+
24
+ const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
25
+ const SETUP_TOKEN_RAW = process.env.OPENCLAW_LIVE_SETUP_TOKEN?.trim() ?? "";
26
+ const SETUP_TOKEN_VALUE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
27
+ const SETUP_TOKEN_PROFILE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
28
+ const SETUP_TOKEN_MODEL = process.env.OPENCLAW_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
29
+
30
+ const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
31
+ const describeLive = ENABLED ? describe : describe.skip;
32
+
33
+ type TokenSource = {
34
+ agentDir: string;
35
+ profileId: string;
36
+ cleanup?: () => Promise<void>;
37
+ };
38
+
39
+ function isSetupToken(value: string): boolean {
40
+ return value.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX);
41
+ }
42
+
43
+ function listSetupTokenProfiles(store: {
44
+ profiles: Record<string, AuthProfileCredential>;
45
+ }): string[] {
46
+ return Object.entries(store.profiles)
47
+ .filter(([, cred]) => {
48
+ if (cred.type !== "token") {
49
+ return false;
50
+ }
51
+ if (normalizeProviderId(cred.provider) !== "anthropic") {
52
+ return false;
53
+ }
54
+ return isSetupToken(cred.token);
55
+ })
56
+ .map(([id]) => id);
57
+ }
58
+
59
+ function pickSetupTokenProfile(candidates: string[]): string {
60
+ const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
61
+ for (const id of preferred) {
62
+ if (candidates.includes(id)) {
63
+ return id;
64
+ }
65
+ }
66
+ return candidates[0] ?? "";
67
+ }
68
+
69
+ async function resolveTokenSource(): Promise<TokenSource> {
70
+ const explicitToken =
71
+ (SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
72
+
73
+ if (explicitToken) {
74
+ const error = validateAnthropicSetupToken(explicitToken);
75
+ if (error) {
76
+ throw new Error(`Invalid setup-token: ${error}`);
77
+ }
78
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-token-"));
79
+ const profileId = `anthropic:setup-token-live-${randomUUID()}`;
80
+ const store = ensureAuthProfileStore(tempDir, {
81
+ allowKeychainPrompt: false,
82
+ });
83
+ store.profiles[profileId] = {
84
+ type: "token",
85
+ provider: "anthropic",
86
+ token: explicitToken,
87
+ };
88
+ saveAuthProfileStore(store, tempDir);
89
+ return {
90
+ agentDir: tempDir,
91
+ profileId,
92
+ cleanup: async () => {
93
+ await fs.rm(tempDir, { recursive: true, force: true });
94
+ },
95
+ };
96
+ }
97
+
98
+ const agentDir = resolveOpenClawAgentDir();
99
+ const store = ensureAuthProfileStore(agentDir, {
100
+ allowKeychainPrompt: false,
101
+ });
102
+
103
+ const candidates = listSetupTokenProfiles(store);
104
+ if (SETUP_TOKEN_PROFILE) {
105
+ if (!candidates.includes(SETUP_TOKEN_PROFILE)) {
106
+ const available = candidates.length > 0 ? candidates.join(", ") : "(none)";
107
+ throw new Error(
108
+ `Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`,
109
+ );
110
+ }
111
+ return { agentDir, profileId: SETUP_TOKEN_PROFILE };
112
+ }
113
+
114
+ if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
115
+ throw new Error(
116
+ "OPENCLAW_LIVE_SETUP_TOKEN did not look like a setup-token. Use OPENCLAW_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
117
+ );
118
+ }
119
+
120
+ if (candidates.length === 0) {
121
+ throw new Error(
122
+ "No Anthropics setup-token profiles found. Set OPENCLAW_LIVE_SETUP_TOKEN_VALUE or OPENCLAW_LIVE_SETUP_TOKEN_PROFILE.",
123
+ );
124
+ }
125
+ return { agentDir, profileId: pickSetupTokenProfile(candidates) };
126
+ }
127
+
128
+ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
129
+ const normalized = raw?.trim() ?? "";
130
+ if (normalized) {
131
+ const parsed = parseModelRef(normalized, "anthropic");
132
+ if (!parsed) {
133
+ return null;
134
+ }
135
+ return (
136
+ models.find(
137
+ (model) =>
138
+ normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model,
139
+ ) ?? null
140
+ );
141
+ }
142
+
143
+ const preferred = [
144
+ "claude-opus-4-5",
145
+ "claude-sonnet-4-5",
146
+ "claude-sonnet-4-0",
147
+ "claude-haiku-3-5",
148
+ ];
149
+ for (const id of preferred) {
150
+ const match = models.find((model) => model.id === id);
151
+ if (match) {
152
+ return match;
153
+ }
154
+ }
155
+ return models[0] ?? null;
156
+ }
157
+
158
+ describeLive("live anthropic setup-token", () => {
159
+ it(
160
+ "completes using a setup-token profile",
161
+ async () => {
162
+ const tokenSource = await resolveTokenSource();
163
+ try {
164
+ const cfg = loadConfig();
165
+ await ensureOpenClawModelsJson(cfg, tokenSource.agentDir);
166
+
167
+ const authStorage = discoverAuthStorage(tokenSource.agentDir);
168
+ const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
169
+ const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll();
170
+ const candidates = all.filter(
171
+ (model) => normalizeProviderId(model.provider) === "anthropic",
172
+ ) as Array<Model<Api>>;
173
+ expect(candidates.length).toBeGreaterThan(0);
174
+
175
+ const model = pickModel(candidates, SETUP_TOKEN_MODEL);
176
+ if (!model) {
177
+ throw new Error(
178
+ SETUP_TOKEN_MODEL
179
+ ? `Model not found: ${SETUP_TOKEN_MODEL}`
180
+ : "No Anthropic models available.",
181
+ );
182
+ }
183
+
184
+ const apiKeyInfo = await getApiKeyForModel({
185
+ model,
186
+ cfg,
187
+ profileId: tokenSource.profileId,
188
+ agentDir: tokenSource.agentDir,
189
+ });
190
+ const apiKey = requireApiKey(apiKeyInfo, model.provider);
191
+ const tokenError = validateAnthropicSetupToken(apiKey);
192
+ if (tokenError) {
193
+ throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
194
+ }
195
+
196
+ const res = await completeSimple(
197
+ model,
198
+ {
199
+ messages: [
200
+ {
201
+ role: "user",
202
+ content: "Reply with the word ok.",
203
+ timestamp: Date.now(),
204
+ },
205
+ ],
206
+ },
207
+ {
208
+ apiKey,
209
+ maxTokens: 64,
210
+ temperature: 0,
211
+ },
212
+ );
213
+ const text = res.content
214
+ .filter((block) => block.type === "text")
215
+ .map((block) => block.text.trim())
216
+ .join(" ");
217
+ expect(text.toLowerCase()).toContain("ok");
218
+ } finally {
219
+ if (tokenSource.cleanup) {
220
+ await tokenSource.cleanup();
221
+ }
222
+ }
223
+ },
224
+ 5 * 60 * 1000,
225
+ );
226
+ });
src/agents/apply-patch-update.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+
3
+ type UpdateFileChunk = {
4
+ changeContext?: string;
5
+ oldLines: string[];
6
+ newLines: string[];
7
+ isEndOfFile: boolean;
8
+ };
9
+
10
+ export async function applyUpdateHunk(
11
+ filePath: string,
12
+ chunks: UpdateFileChunk[],
13
+ ): Promise<string> {
14
+ const originalContents = await fs.readFile(filePath, "utf8").catch((err) => {
15
+ throw new Error(`Failed to read file to update ${filePath}: ${err}`);
16
+ });
17
+
18
+ const originalLines = originalContents.split("\n");
19
+ if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
20
+ originalLines.pop();
21
+ }
22
+
23
+ const replacements = computeReplacements(originalLines, filePath, chunks);
24
+ let newLines = applyReplacements(originalLines, replacements);
25
+ if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
26
+ newLines = [...newLines, ""];
27
+ }
28
+ return newLines.join("\n");
29
+ }
30
+
31
+ function computeReplacements(
32
+ originalLines: string[],
33
+ filePath: string,
34
+ chunks: UpdateFileChunk[],
35
+ ): Array<[number, number, string[]]> {
36
+ const replacements: Array<[number, number, string[]]> = [];
37
+ let lineIndex = 0;
38
+
39
+ for (const chunk of chunks) {
40
+ if (chunk.changeContext) {
41
+ const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
42
+ if (ctxIndex === null) {
43
+ throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
44
+ }
45
+ lineIndex = ctxIndex + 1;
46
+ }
47
+
48
+ if (chunk.oldLines.length === 0) {
49
+ const insertionIndex =
50
+ originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
51
+ ? originalLines.length - 1
52
+ : originalLines.length;
53
+ replacements.push([insertionIndex, 0, chunk.newLines]);
54
+ continue;
55
+ }
56
+
57
+ let pattern = chunk.oldLines;
58
+ let newSlice = chunk.newLines;
59
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
60
+
61
+ if (found === null && pattern[pattern.length - 1] === "") {
62
+ pattern = pattern.slice(0, -1);
63
+ if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
64
+ newSlice = newSlice.slice(0, -1);
65
+ }
66
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
67
+ }
68
+
69
+ if (found === null) {
70
+ throw new Error(
71
+ `Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`,
72
+ );
73
+ }
74
+
75
+ replacements.push([found, pattern.length, newSlice]);
76
+ lineIndex = found + pattern.length;
77
+ }
78
+
79
+ replacements.sort((a, b) => a[0] - b[0]);
80
+ return replacements;
81
+ }
82
+
83
+ function applyReplacements(
84
+ lines: string[],
85
+ replacements: Array<[number, number, string[]]>,
86
+ ): string[] {
87
+ const result = [...lines];
88
+ for (const [startIndex, oldLen, newLines] of [...replacements].toReversed()) {
89
+ for (let i = 0; i < oldLen; i += 1) {
90
+ if (startIndex < result.length) {
91
+ result.splice(startIndex, 1);
92
+ }
93
+ }
94
+ for (let i = 0; i < newLines.length; i += 1) {
95
+ result.splice(startIndex + i, 0, newLines[i]);
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+
101
+ function seekSequence(
102
+ lines: string[],
103
+ pattern: string[],
104
+ start: number,
105
+ eof: boolean,
106
+ ): number | null {
107
+ if (pattern.length === 0) {
108
+ return start;
109
+ }
110
+ if (pattern.length > lines.length) {
111
+ return null;
112
+ }
113
+
114
+ const maxStart = lines.length - pattern.length;
115
+ const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
116
+ if (searchStart > maxStart) {
117
+ return null;
118
+ }
119
+
120
+ for (let i = searchStart; i <= maxStart; i += 1) {
121
+ if (linesMatch(lines, pattern, i, (value) => value)) {
122
+ return i;
123
+ }
124
+ }
125
+ for (let i = searchStart; i <= maxStart; i += 1) {
126
+ if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) {
127
+ return i;
128
+ }
129
+ }
130
+ for (let i = searchStart; i <= maxStart; i += 1) {
131
+ if (linesMatch(lines, pattern, i, (value) => value.trim())) {
132
+ return i;
133
+ }
134
+ }
135
+ for (let i = searchStart; i <= maxStart; i += 1) {
136
+ if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
137
+ return i;
138
+ }
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ function linesMatch(
145
+ lines: string[],
146
+ pattern: string[],
147
+ start: number,
148
+ normalize: (value: string) => string,
149
+ ): boolean {
150
+ for (let idx = 0; idx < pattern.length; idx += 1) {
151
+ if (normalize(lines[start + idx]) !== normalize(pattern[idx])) {
152
+ return false;
153
+ }
154
+ }
155
+ return true;
156
+ }
157
+
158
+ function normalizePunctuation(value: string): string {
159
+ return Array.from(value)
160
+ .map((char) => {
161
+ switch (char) {
162
+ case "\u2010":
163
+ case "\u2011":
164
+ case "\u2012":
165
+ case "\u2013":
166
+ case "\u2014":
167
+ case "\u2015":
168
+ case "\u2212":
169
+ return "-";
170
+ case "\u2018":
171
+ case "\u2019":
172
+ case "\u201A":
173
+ case "\u201B":
174
+ return "'";
175
+ case "\u201C":
176
+ case "\u201D":
177
+ case "\u201E":
178
+ case "\u201F":
179
+ return '"';
180
+ case "\u00A0":
181
+ case "\u2002":
182
+ case "\u2003":
183
+ case "\u2004":
184
+ case "\u2005":
185
+ case "\u2006":
186
+ case "\u2007":
187
+ case "\u2008":
188
+ case "\u2009":
189
+ case "\u200A":
190
+ case "\u202F":
191
+ case "\u205F":
192
+ case "\u3000":
193
+ return " ";
194
+ default:
195
+ return char;
196
+ }
197
+ })
198
+ .join("");
199
+ }
src/agents/apply-patch.test.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { applyPatch } from "./apply-patch.js";
6
+
7
+ async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-"));
9
+ try {
10
+ return await fn(dir);
11
+ } finally {
12
+ await fs.rm(dir, { recursive: true, force: true });
13
+ }
14
+ }
15
+
16
+ describe("applyPatch", () => {
17
+ it("adds a file", async () => {
18
+ await withTempDir(async (dir) => {
19
+ const patch = `*** Begin Patch
20
+ *** Add File: hello.txt
21
+ +hello
22
+ *** End Patch`;
23
+
24
+ const result = await applyPatch(patch, { cwd: dir });
25
+ const contents = await fs.readFile(path.join(dir, "hello.txt"), "utf8");
26
+
27
+ expect(contents).toBe("hello\n");
28
+ expect(result.summary.added).toEqual(["hello.txt"]);
29
+ });
30
+ });
31
+
32
+ it("updates and moves a file", async () => {
33
+ await withTempDir(async (dir) => {
34
+ const source = path.join(dir, "source.txt");
35
+ await fs.writeFile(source, "foo\nbar\n", "utf8");
36
+
37
+ const patch = `*** Begin Patch
38
+ *** Update File: source.txt
39
+ *** Move to: dest.txt
40
+ @@
41
+ foo
42
+ -bar
43
+ +baz
44
+ *** End Patch`;
45
+
46
+ const result = await applyPatch(patch, { cwd: dir });
47
+ const dest = path.join(dir, "dest.txt");
48
+ const contents = await fs.readFile(dest, "utf8");
49
+
50
+ expect(contents).toBe("foo\nbaz\n");
51
+ await expect(fs.stat(source)).rejects.toBeDefined();
52
+ expect(result.summary.modified).toEqual(["dest.txt"]);
53
+ });
54
+ });
55
+
56
+ it("supports end-of-file inserts", async () => {
57
+ await withTempDir(async (dir) => {
58
+ const target = path.join(dir, "end.txt");
59
+ await fs.writeFile(target, "line1\n", "utf8");
60
+
61
+ const patch = `*** Begin Patch
62
+ *** Update File: end.txt
63
+ @@
64
+ +line2
65
+ *** End of File
66
+ *** End Patch`;
67
+
68
+ await applyPatch(patch, { cwd: dir });
69
+ const contents = await fs.readFile(target, "utf8");
70
+ expect(contents).toBe("line1\nline2\n");
71
+ });
72
+ });
73
+ });
src/agents/apply-patch.ts ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import { Type } from "@sinclair/typebox";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { applyUpdateHunk } from "./apply-patch-update.js";
7
+ import { assertSandboxPath } from "./sandbox-paths.js";
8
+
9
+ const BEGIN_PATCH_MARKER = "*** Begin Patch";
10
+ const END_PATCH_MARKER = "*** End Patch";
11
+ const ADD_FILE_MARKER = "*** Add File: ";
12
+ const DELETE_FILE_MARKER = "*** Delete File: ";
13
+ const UPDATE_FILE_MARKER = "*** Update File: ";
14
+ const MOVE_TO_MARKER = "*** Move to: ";
15
+ const EOF_MARKER = "*** End of File";
16
+ const CHANGE_CONTEXT_MARKER = "@@ ";
17
+ const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
18
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
19
+
20
+ type AddFileHunk = {
21
+ kind: "add";
22
+ path: string;
23
+ contents: string;
24
+ };
25
+
26
+ type DeleteFileHunk = {
27
+ kind: "delete";
28
+ path: string;
29
+ };
30
+
31
+ type UpdateFileChunk = {
32
+ changeContext?: string;
33
+ oldLines: string[];
34
+ newLines: string[];
35
+ isEndOfFile: boolean;
36
+ };
37
+
38
+ type UpdateFileHunk = {
39
+ kind: "update";
40
+ path: string;
41
+ movePath?: string;
42
+ chunks: UpdateFileChunk[];
43
+ };
44
+
45
+ type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk;
46
+
47
+ export type ApplyPatchSummary = {
48
+ added: string[];
49
+ modified: string[];
50
+ deleted: string[];
51
+ };
52
+
53
+ export type ApplyPatchResult = {
54
+ summary: ApplyPatchSummary;
55
+ text: string;
56
+ };
57
+
58
+ export type ApplyPatchToolDetails = {
59
+ summary: ApplyPatchSummary;
60
+ };
61
+
62
+ type ApplyPatchOptions = {
63
+ cwd: string;
64
+ sandboxRoot?: string;
65
+ signal?: AbortSignal;
66
+ };
67
+
68
+ const applyPatchSchema = Type.Object({
69
+ input: Type.String({
70
+ description: "Patch content using the *** Begin Patch/End Patch format.",
71
+ }),
72
+ });
73
+
74
+ export function createApplyPatchTool(
75
+ options: { cwd?: string; sandboxRoot?: string } = {},
76
+ // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
77
+ ): AgentTool<any, ApplyPatchToolDetails> {
78
+ const cwd = options.cwd ?? process.cwd();
79
+ const sandboxRoot = options.sandboxRoot;
80
+
81
+ return {
82
+ name: "apply_patch",
83
+ label: "apply_patch",
84
+ description:
85
+ "Apply a patch to one or more files using the apply_patch format. The input should include *** Begin Patch and *** End Patch markers.",
86
+ parameters: applyPatchSchema,
87
+ execute: async (_toolCallId, args, signal) => {
88
+ const params = args as { input?: string };
89
+ const input = typeof params.input === "string" ? params.input : "";
90
+ if (!input.trim()) {
91
+ throw new Error("Provide a patch input.");
92
+ }
93
+ if (signal?.aborted) {
94
+ const err = new Error("Aborted");
95
+ err.name = "AbortError";
96
+ throw err;
97
+ }
98
+
99
+ const result = await applyPatch(input, {
100
+ cwd,
101
+ sandboxRoot,
102
+ signal,
103
+ });
104
+
105
+ return {
106
+ content: [{ type: "text", text: result.text }],
107
+ details: { summary: result.summary },
108
+ };
109
+ },
110
+ };
111
+ }
112
+
113
+ export async function applyPatch(
114
+ input: string,
115
+ options: ApplyPatchOptions,
116
+ ): Promise<ApplyPatchResult> {
117
+ const parsed = parsePatchText(input);
118
+ if (parsed.hunks.length === 0) {
119
+ throw new Error("No files were modified.");
120
+ }
121
+
122
+ const summary: ApplyPatchSummary = {
123
+ added: [],
124
+ modified: [],
125
+ deleted: [],
126
+ };
127
+ const seen = {
128
+ added: new Set<string>(),
129
+ modified: new Set<string>(),
130
+ deleted: new Set<string>(),
131
+ };
132
+
133
+ for (const hunk of parsed.hunks) {
134
+ if (options.signal?.aborted) {
135
+ const err = new Error("Aborted");
136
+ err.name = "AbortError";
137
+ throw err;
138
+ }
139
+
140
+ if (hunk.kind === "add") {
141
+ const target = await resolvePatchPath(hunk.path, options);
142
+ await ensureDir(target.resolved);
143
+ await fs.writeFile(target.resolved, hunk.contents, "utf8");
144
+ recordSummary(summary, seen, "added", target.display);
145
+ continue;
146
+ }
147
+
148
+ if (hunk.kind === "delete") {
149
+ const target = await resolvePatchPath(hunk.path, options);
150
+ await fs.rm(target.resolved);
151
+ recordSummary(summary, seen, "deleted", target.display);
152
+ continue;
153
+ }
154
+
155
+ const target = await resolvePatchPath(hunk.path, options);
156
+ const applied = await applyUpdateHunk(target.resolved, hunk.chunks);
157
+
158
+ if (hunk.movePath) {
159
+ const moveTarget = await resolvePatchPath(hunk.movePath, options);
160
+ await ensureDir(moveTarget.resolved);
161
+ await fs.writeFile(moveTarget.resolved, applied, "utf8");
162
+ await fs.rm(target.resolved);
163
+ recordSummary(summary, seen, "modified", moveTarget.display);
164
+ } else {
165
+ await fs.writeFile(target.resolved, applied, "utf8");
166
+ recordSummary(summary, seen, "modified", target.display);
167
+ }
168
+ }
169
+
170
+ return {
171
+ summary,
172
+ text: formatSummary(summary),
173
+ };
174
+ }
175
+
176
+ function recordSummary(
177
+ summary: ApplyPatchSummary,
178
+ seen: {
179
+ added: Set<string>;
180
+ modified: Set<string>;
181
+ deleted: Set<string>;
182
+ },
183
+ bucket: keyof ApplyPatchSummary,
184
+ value: string,
185
+ ) {
186
+ if (seen[bucket].has(value)) {
187
+ return;
188
+ }
189
+ seen[bucket].add(value);
190
+ summary[bucket].push(value);
191
+ }
192
+
193
+ function formatSummary(summary: ApplyPatchSummary): string {
194
+ const lines = ["Success. Updated the following files:"];
195
+ for (const file of summary.added) {
196
+ lines.push(`A ${file}`);
197
+ }
198
+ for (const file of summary.modified) {
199
+ lines.push(`M ${file}`);
200
+ }
201
+ for (const file of summary.deleted) {
202
+ lines.push(`D ${file}`);
203
+ }
204
+ return lines.join("\n");
205
+ }
206
+
207
+ async function ensureDir(filePath: string) {
208
+ const parent = path.dirname(filePath);
209
+ if (!parent || parent === ".") {
210
+ return;
211
+ }
212
+ await fs.mkdir(parent, { recursive: true });
213
+ }
214
+
215
+ async function resolvePatchPath(
216
+ filePath: string,
217
+ options: ApplyPatchOptions,
218
+ ): Promise<{ resolved: string; display: string }> {
219
+ if (options.sandboxRoot) {
220
+ const resolved = await assertSandboxPath({
221
+ filePath,
222
+ cwd: options.cwd,
223
+ root: options.sandboxRoot,
224
+ });
225
+ return {
226
+ resolved: resolved.resolved,
227
+ display: resolved.relative || resolved.resolved,
228
+ };
229
+ }
230
+
231
+ const resolved = resolvePathFromCwd(filePath, options.cwd);
232
+ return {
233
+ resolved,
234
+ display: toDisplayPath(resolved, options.cwd),
235
+ };
236
+ }
237
+
238
+ function normalizeUnicodeSpaces(value: string): string {
239
+ return value.replace(UNICODE_SPACES, " ");
240
+ }
241
+
242
+ function expandPath(filePath: string): string {
243
+ const normalized = normalizeUnicodeSpaces(filePath);
244
+ if (normalized === "~") {
245
+ return os.homedir();
246
+ }
247
+ if (normalized.startsWith("~/")) {
248
+ return os.homedir() + normalized.slice(1);
249
+ }
250
+ return normalized;
251
+ }
252
+
253
+ function resolvePathFromCwd(filePath: string, cwd: string): string {
254
+ const expanded = expandPath(filePath);
255
+ if (path.isAbsolute(expanded)) {
256
+ return path.normalize(expanded);
257
+ }
258
+ return path.resolve(cwd, expanded);
259
+ }
260
+
261
+ function toDisplayPath(resolved: string, cwd: string): string {
262
+ const relative = path.relative(cwd, resolved);
263
+ if (!relative || relative === "") {
264
+ return path.basename(resolved);
265
+ }
266
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
267
+ return resolved;
268
+ }
269
+ return relative;
270
+ }
271
+
272
+ function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
273
+ const trimmed = input.trim();
274
+ if (!trimmed) {
275
+ throw new Error("Invalid patch: input is empty.");
276
+ }
277
+
278
+ const lines = trimmed.split(/\r?\n/);
279
+ const validated = checkPatchBoundariesLenient(lines);
280
+ const hunks: Hunk[] = [];
281
+
282
+ const lastLineIndex = validated.length - 1;
283
+ let remaining = validated.slice(1, lastLineIndex);
284
+ let lineNumber = 2;
285
+
286
+ while (remaining.length > 0) {
287
+ const { hunk, consumed } = parseOneHunk(remaining, lineNumber);
288
+ hunks.push(hunk);
289
+ lineNumber += consumed;
290
+ remaining = remaining.slice(consumed);
291
+ }
292
+
293
+ return { hunks, patch: validated.join("\n") };
294
+ }
295
+
296
+ function checkPatchBoundariesLenient(lines: string[]): string[] {
297
+ const strictError = checkPatchBoundariesStrict(lines);
298
+ if (!strictError) {
299
+ return lines;
300
+ }
301
+
302
+ if (lines.length < 4) {
303
+ throw new Error(strictError);
304
+ }
305
+ const first = lines[0];
306
+ const last = lines[lines.length - 1];
307
+ if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
308
+ const inner = lines.slice(1, lines.length - 1);
309
+ const innerError = checkPatchBoundariesStrict(inner);
310
+ if (!innerError) {
311
+ return inner;
312
+ }
313
+ throw new Error(innerError);
314
+ }
315
+
316
+ throw new Error(strictError);
317
+ }
318
+
319
+ function checkPatchBoundariesStrict(lines: string[]): string | null {
320
+ const firstLine = lines[0]?.trim();
321
+ const lastLine = lines[lines.length - 1]?.trim();
322
+
323
+ if (firstLine === BEGIN_PATCH_MARKER && lastLine === END_PATCH_MARKER) {
324
+ return null;
325
+ }
326
+ if (firstLine !== BEGIN_PATCH_MARKER) {
327
+ return "The first line of the patch must be '*** Begin Patch'";
328
+ }
329
+ return "The last line of the patch must be '*** End Patch'";
330
+ }
331
+
332
+ function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
333
+ if (lines.length === 0) {
334
+ throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
335
+ }
336
+ const firstLine = lines[0].trim();
337
+ if (firstLine.startsWith(ADD_FILE_MARKER)) {
338
+ const targetPath = firstLine.slice(ADD_FILE_MARKER.length);
339
+ let contents = "";
340
+ let consumed = 1;
341
+ for (const addLine of lines.slice(1)) {
342
+ if (addLine.startsWith("+")) {
343
+ contents += `${addLine.slice(1)}\n`;
344
+ consumed += 1;
345
+ } else {
346
+ break;
347
+ }
348
+ }
349
+ return {
350
+ hunk: { kind: "add", path: targetPath, contents },
351
+ consumed,
352
+ };
353
+ }
354
+
355
+ if (firstLine.startsWith(DELETE_FILE_MARKER)) {
356
+ const targetPath = firstLine.slice(DELETE_FILE_MARKER.length);
357
+ return {
358
+ hunk: { kind: "delete", path: targetPath },
359
+ consumed: 1,
360
+ };
361
+ }
362
+
363
+ if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
364
+ const targetPath = firstLine.slice(UPDATE_FILE_MARKER.length);
365
+ let remaining = lines.slice(1);
366
+ let consumed = 1;
367
+ let movePath: string | undefined;
368
+
369
+ const moveCandidate = remaining[0]?.trim();
370
+ if (moveCandidate?.startsWith(MOVE_TO_MARKER)) {
371
+ movePath = moveCandidate.slice(MOVE_TO_MARKER.length);
372
+ remaining = remaining.slice(1);
373
+ consumed += 1;
374
+ }
375
+
376
+ const chunks: UpdateFileChunk[] = [];
377
+ while (remaining.length > 0) {
378
+ if (remaining[0].trim() === "") {
379
+ remaining = remaining.slice(1);
380
+ consumed += 1;
381
+ continue;
382
+ }
383
+ if (remaining[0].startsWith("***")) {
384
+ break;
385
+ }
386
+ const { chunk, consumed: chunkLines } = parseUpdateFileChunk(
387
+ remaining,
388
+ lineNumber + consumed,
389
+ chunks.length === 0,
390
+ );
391
+ chunks.push(chunk);
392
+ remaining = remaining.slice(chunkLines);
393
+ consumed += chunkLines;
394
+ }
395
+
396
+ if (chunks.length === 0) {
397
+ throw new Error(
398
+ `Invalid patch hunk at line ${lineNumber}: Update file hunk for path '${targetPath}' is empty`,
399
+ );
400
+ }
401
+
402
+ return {
403
+ hunk: {
404
+ kind: "update",
405
+ path: targetPath,
406
+ movePath,
407
+ chunks,
408
+ },
409
+ consumed,
410
+ };
411
+ }
412
+
413
+ throw new Error(
414
+ `Invalid patch hunk at line ${lineNumber}: '${lines[0]}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
415
+ );
416
+ }
417
+
418
+ function parseUpdateFileChunk(
419
+ lines: string[],
420
+ lineNumber: number,
421
+ allowMissingContext: boolean,
422
+ ): { chunk: UpdateFileChunk; consumed: number } {
423
+ if (lines.length === 0) {
424
+ throw new Error(
425
+ `Invalid patch hunk at line ${lineNumber}: Update hunk does not contain any lines`,
426
+ );
427
+ }
428
+
429
+ let changeContext: string | undefined;
430
+ let startIndex = 0;
431
+ if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) {
432
+ startIndex = 1;
433
+ } else if (lines[0].startsWith(CHANGE_CONTEXT_MARKER)) {
434
+ changeContext = lines[0].slice(CHANGE_CONTEXT_MARKER.length);
435
+ startIndex = 1;
436
+ } else if (!allowMissingContext) {
437
+ throw new Error(
438
+ `Invalid patch hunk at line ${lineNumber}: Expected update hunk to start with a @@ context marker, got: '${lines[0]}'`,
439
+ );
440
+ }
441
+
442
+ if (startIndex >= lines.length) {
443
+ throw new Error(
444
+ `Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
445
+ );
446
+ }
447
+
448
+ const chunk: UpdateFileChunk = {
449
+ changeContext,
450
+ oldLines: [],
451
+ newLines: [],
452
+ isEndOfFile: false,
453
+ };
454
+
455
+ let parsedLines = 0;
456
+ for (const line of lines.slice(startIndex)) {
457
+ if (line === EOF_MARKER) {
458
+ if (parsedLines === 0) {
459
+ throw new Error(
460
+ `Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
461
+ );
462
+ }
463
+ chunk.isEndOfFile = true;
464
+ parsedLines += 1;
465
+ break;
466
+ }
467
+
468
+ const marker = line[0];
469
+ if (!marker) {
470
+ chunk.oldLines.push("");
471
+ chunk.newLines.push("");
472
+ parsedLines += 1;
473
+ continue;
474
+ }
475
+
476
+ if (marker === " ") {
477
+ const content = line.slice(1);
478
+ chunk.oldLines.push(content);
479
+ chunk.newLines.push(content);
480
+ parsedLines += 1;
481
+ continue;
482
+ }
483
+ if (marker === "+") {
484
+ chunk.newLines.push(line.slice(1));
485
+ parsedLines += 1;
486
+ continue;
487
+ }
488
+ if (marker === "-") {
489
+ chunk.oldLines.push(line.slice(1));
490
+ parsedLines += 1;
491
+ continue;
492
+ }
493
+
494
+ if (parsedLines === 0) {
495
+ throw new Error(
496
+ `Invalid patch hunk at line ${lineNumber + 1}: Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
497
+ );
498
+ }
499
+ break;
500
+ }
501
+
502
+ return { chunk, consumed: parsedLines + startIndex };
503
+ }
src/agents/auth-health.test.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
3
+
4
+ describe("buildAuthHealthSummary", () => {
5
+ const now = 1_700_000_000_000;
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+
10
+ it("classifies OAuth and API key profiles", () => {
11
+ vi.spyOn(Date, "now").mockReturnValue(now);
12
+ const store = {
13
+ version: 1,
14
+ profiles: {
15
+ "anthropic:ok": {
16
+ type: "oauth" as const,
17
+ provider: "anthropic",
18
+ access: "access",
19
+ refresh: "refresh",
20
+ expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
21
+ },
22
+ "anthropic:expiring": {
23
+ type: "oauth" as const,
24
+ provider: "anthropic",
25
+ access: "access",
26
+ refresh: "refresh",
27
+ expires: now + 10_000,
28
+ },
29
+ "anthropic:expired": {
30
+ type: "oauth" as const,
31
+ provider: "anthropic",
32
+ access: "access",
33
+ refresh: "refresh",
34
+ expires: now - 10_000,
35
+ },
36
+ "anthropic:api": {
37
+ type: "api_key" as const,
38
+ provider: "anthropic",
39
+ key: "sk-ant-api",
40
+ },
41
+ },
42
+ };
43
+
44
+ const summary = buildAuthHealthSummary({
45
+ store,
46
+ warnAfterMs: DEFAULT_OAUTH_WARN_MS,
47
+ });
48
+
49
+ const statuses = Object.fromEntries(
50
+ summary.profiles.map((profile) => [profile.profileId, profile.status]),
51
+ );
52
+
53
+ expect(statuses["anthropic:ok"]).toBe("ok");
54
+ // OAuth credentials with refresh tokens are auto-renewable, so they report "ok"
55
+ expect(statuses["anthropic:expiring"]).toBe("ok");
56
+ expect(statuses["anthropic:expired"]).toBe("ok");
57
+ expect(statuses["anthropic:api"]).toBe("static");
58
+
59
+ const provider = summary.providers.find((entry) => entry.provider === "anthropic");
60
+ expect(provider?.status).toBe("ok");
61
+ });
62
+
63
+ it("reports expired for OAuth without a refresh token", () => {
64
+ vi.spyOn(Date, "now").mockReturnValue(now);
65
+ const store = {
66
+ version: 1,
67
+ profiles: {
68
+ "google:no-refresh": {
69
+ type: "oauth" as const,
70
+ provider: "google-antigravity",
71
+ access: "access",
72
+ refresh: "",
73
+ expires: now - 10_000,
74
+ },
75
+ },
76
+ };
77
+
78
+ const summary = buildAuthHealthSummary({
79
+ store,
80
+ warnAfterMs: DEFAULT_OAUTH_WARN_MS,
81
+ });
82
+
83
+ const statuses = Object.fromEntries(
84
+ summary.profiles.map((profile) => [profile.profileId, profile.status]),
85
+ );
86
+
87
+ expect(statuses["google:no-refresh"]).toBe("expired");
88
+ });
89
+ });
src/agents/auth-health.ts ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../config/config.js";
2
+ import {
3
+ type AuthProfileCredential,
4
+ type AuthProfileStore,
5
+ resolveAuthProfileDisplayLabel,
6
+ } from "./auth-profiles.js";
7
+
8
+ export type AuthProfileSource = "store";
9
+
10
+ export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
11
+
12
+ export type AuthProfileHealth = {
13
+ profileId: string;
14
+ provider: string;
15
+ type: "oauth" | "token" | "api_key";
16
+ status: AuthProfileHealthStatus;
17
+ expiresAt?: number;
18
+ remainingMs?: number;
19
+ source: AuthProfileSource;
20
+ label: string;
21
+ };
22
+
23
+ export type AuthProviderHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
24
+
25
+ export type AuthProviderHealth = {
26
+ provider: string;
27
+ status: AuthProviderHealthStatus;
28
+ expiresAt?: number;
29
+ remainingMs?: number;
30
+ profiles: AuthProfileHealth[];
31
+ };
32
+
33
+ export type AuthHealthSummary = {
34
+ now: number;
35
+ warnAfterMs: number;
36
+ profiles: AuthProfileHealth[];
37
+ providers: AuthProviderHealth[];
38
+ };
39
+
40
+ export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
41
+
42
+ export function resolveAuthProfileSource(_profileId: string): AuthProfileSource {
43
+ return "store";
44
+ }
45
+
46
+ export function formatRemainingShort(remainingMs?: number): string {
47
+ if (remainingMs === undefined || Number.isNaN(remainingMs)) {
48
+ return "unknown";
49
+ }
50
+ if (remainingMs <= 0) {
51
+ return "0m";
52
+ }
53
+ const minutes = Math.max(1, Math.round(remainingMs / 60_000));
54
+ if (minutes < 60) {
55
+ return `${minutes}m`;
56
+ }
57
+ const hours = Math.round(minutes / 60);
58
+ if (hours < 48) {
59
+ return `${hours}h`;
60
+ }
61
+ const days = Math.round(hours / 24);
62
+ return `${days}d`;
63
+ }
64
+
65
+ function resolveOAuthStatus(
66
+ expiresAt: number | undefined,
67
+ now: number,
68
+ warnAfterMs: number,
69
+ ): { status: AuthProfileHealthStatus; remainingMs?: number } {
70
+ if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) {
71
+ return { status: "missing" };
72
+ }
73
+ const remainingMs = expiresAt - now;
74
+ if (remainingMs <= 0) {
75
+ return { status: "expired", remainingMs };
76
+ }
77
+ if (remainingMs <= warnAfterMs) {
78
+ return { status: "expiring", remainingMs };
79
+ }
80
+ return { status: "ok", remainingMs };
81
+ }
82
+
83
+ function buildProfileHealth(params: {
84
+ profileId: string;
85
+ credential: AuthProfileCredential;
86
+ store: AuthProfileStore;
87
+ cfg?: OpenClawConfig;
88
+ now: number;
89
+ warnAfterMs: number;
90
+ }): AuthProfileHealth {
91
+ const { profileId, credential, store, cfg, now, warnAfterMs } = params;
92
+ const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
93
+ const source = resolveAuthProfileSource(profileId);
94
+
95
+ if (credential.type === "api_key") {
96
+ return {
97
+ profileId,
98
+ provider: credential.provider,
99
+ type: "api_key",
100
+ status: "static",
101
+ source,
102
+ label,
103
+ };
104
+ }
105
+
106
+ if (credential.type === "token") {
107
+ const expiresAt =
108
+ typeof credential.expires === "number" && Number.isFinite(credential.expires)
109
+ ? credential.expires
110
+ : undefined;
111
+ if (!expiresAt || expiresAt <= 0) {
112
+ return {
113
+ profileId,
114
+ provider: credential.provider,
115
+ type: "token",
116
+ status: "static",
117
+ source,
118
+ label,
119
+ };
120
+ }
121
+ const { status, remainingMs } = resolveOAuthStatus(expiresAt, now, warnAfterMs);
122
+ return {
123
+ profileId,
124
+ provider: credential.provider,
125
+ type: "token",
126
+ status,
127
+ expiresAt,
128
+ remainingMs,
129
+ source,
130
+ label,
131
+ };
132
+ }
133
+
134
+ const hasRefreshToken = typeof credential.refresh === "string" && credential.refresh.length > 0;
135
+ const { status: rawStatus, remainingMs } = resolveOAuthStatus(
136
+ credential.expires,
137
+ now,
138
+ warnAfterMs,
139
+ );
140
+ // OAuth credentials with a valid refresh token auto-renew on first API call,
141
+ // so don't warn about access token expiration.
142
+ const status =
143
+ hasRefreshToken && (rawStatus === "expired" || rawStatus === "expiring") ? "ok" : rawStatus;
144
+ return {
145
+ profileId,
146
+ provider: credential.provider,
147
+ type: "oauth",
148
+ status,
149
+ expiresAt: credential.expires,
150
+ remainingMs,
151
+ source,
152
+ label,
153
+ };
154
+ }
155
+
156
+ export function buildAuthHealthSummary(params: {
157
+ store: AuthProfileStore;
158
+ cfg?: OpenClawConfig;
159
+ warnAfterMs?: number;
160
+ providers?: string[];
161
+ }): AuthHealthSummary {
162
+ const now = Date.now();
163
+ const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
164
+ const providerFilter = params.providers
165
+ ? new Set(params.providers.map((p) => p.trim()).filter(Boolean))
166
+ : null;
167
+
168
+ const profiles = Object.entries(params.store.profiles)
169
+ .filter(([_, cred]) => (providerFilter ? providerFilter.has(cred.provider) : true))
170
+ .map(([profileId, credential]) =>
171
+ buildProfileHealth({
172
+ profileId,
173
+ credential,
174
+ store: params.store,
175
+ cfg: params.cfg,
176
+ now,
177
+ warnAfterMs,
178
+ }),
179
+ )
180
+ .toSorted((a, b) => {
181
+ if (a.provider !== b.provider) {
182
+ return a.provider.localeCompare(b.provider);
183
+ }
184
+ return a.profileId.localeCompare(b.profileId);
185
+ });
186
+
187
+ const providersMap = new Map<string, AuthProviderHealth>();
188
+ for (const profile of profiles) {
189
+ const existing = providersMap.get(profile.provider);
190
+ if (!existing) {
191
+ providersMap.set(profile.provider, {
192
+ provider: profile.provider,
193
+ status: "missing",
194
+ profiles: [profile],
195
+ });
196
+ } else {
197
+ existing.profiles.push(profile);
198
+ }
199
+ }
200
+
201
+ if (providerFilter) {
202
+ for (const provider of providerFilter) {
203
+ if (!providersMap.has(provider)) {
204
+ providersMap.set(provider, {
205
+ provider,
206
+ status: "missing",
207
+ profiles: [],
208
+ });
209
+ }
210
+ }
211
+ }
212
+
213
+ for (const provider of providersMap.values()) {
214
+ if (provider.profiles.length === 0) {
215
+ provider.status = "missing";
216
+ continue;
217
+ }
218
+
219
+ const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
220
+ const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
221
+ const apiKeyProfiles = provider.profiles.filter((p) => p.type === "api_key");
222
+
223
+ const expirable = [...oauthProfiles, ...tokenProfiles];
224
+ if (expirable.length === 0) {
225
+ provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
226
+ continue;
227
+ }
228
+
229
+ const expiryCandidates = expirable
230
+ .map((p) => p.expiresAt)
231
+ .filter((v): v is number => typeof v === "number" && Number.isFinite(v));
232
+ if (expiryCandidates.length > 0) {
233
+ provider.expiresAt = Math.min(...expiryCandidates);
234
+ provider.remainingMs = provider.expiresAt - now;
235
+ }
236
+
237
+ const statuses = new Set(expirable.map((p) => p.status));
238
+ if (statuses.has("expired") || statuses.has("missing")) {
239
+ provider.status = "expired";
240
+ } else if (statuses.has("expiring")) {
241
+ provider.status = "expiring";
242
+ } else {
243
+ provider.status = "ok";
244
+ }
245
+ }
246
+
247
+ const providers = Array.from(providersMap.values()).toSorted((a, b) =>
248
+ a.provider.localeCompare(b.provider),
249
+ );
250
+
251
+ return { now, warnAfterMs, profiles, providers };
252
+ }
src/agents/auth-profiles.auth-profile-cooldowns.test.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { calculateAuthProfileCooldownMs } from "./auth-profiles.js";
3
+
4
+ describe("auth profile cooldowns", () => {
5
+ it("applies exponential backoff with a 1h cap", () => {
6
+ expect(calculateAuthProfileCooldownMs(1)).toBe(60_000);
7
+ expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000);
8
+ expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000);
9
+ expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000);
10
+ expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000);
11
+ });
12
+ });
src/agents/auth-profiles.chutes.test.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ type AuthProfileStore,
7
+ ensureAuthProfileStore,
8
+ resolveApiKeyForProfile,
9
+ } from "./auth-profiles.js";
10
+ import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
11
+
12
+ describe("auth-profiles (chutes)", () => {
13
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
14
+ const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
15
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
16
+ const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
17
+ let tempDir: string | null = null;
18
+
19
+ afterEach(async () => {
20
+ vi.unstubAllGlobals();
21
+ if (tempDir) {
22
+ await fs.rm(tempDir, { recursive: true, force: true });
23
+ tempDir = null;
24
+ }
25
+ if (previousStateDir === undefined) {
26
+ delete process.env.OPENCLAW_STATE_DIR;
27
+ } else {
28
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
29
+ }
30
+ if (previousAgentDir === undefined) {
31
+ delete process.env.OPENCLAW_AGENT_DIR;
32
+ } else {
33
+ process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
34
+ }
35
+ if (previousPiAgentDir === undefined) {
36
+ delete process.env.PI_CODING_AGENT_DIR;
37
+ } else {
38
+ process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
39
+ }
40
+ if (previousChutesClientId === undefined) {
41
+ delete process.env.CHUTES_CLIENT_ID;
42
+ } else {
43
+ process.env.CHUTES_CLIENT_ID = previousChutesClientId;
44
+ }
45
+ });
46
+
47
+ it("refreshes expired Chutes OAuth credentials", async () => {
48
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-"));
49
+ process.env.OPENCLAW_STATE_DIR = tempDir;
50
+ process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
51
+ process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
52
+
53
+ const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
54
+ await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
55
+
56
+ const store: AuthProfileStore = {
57
+ version: 1,
58
+ profiles: {
59
+ "chutes:default": {
60
+ type: "oauth",
61
+ provider: "chutes",
62
+ access: "at_old",
63
+ refresh: "rt_old",
64
+ expires: Date.now() - 60_000,
65
+ clientId: "cid_test",
66
+ } as unknown as ChutesStoredOAuth,
67
+ },
68
+ };
69
+ await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
70
+
71
+ const fetchSpy = vi.fn(async (input: string | URL) => {
72
+ const url = typeof input === "string" ? input : input.toString();
73
+ if (url !== CHUTES_TOKEN_ENDPOINT) {
74
+ return new Response("not found", { status: 404 });
75
+ }
76
+ return new Response(
77
+ JSON.stringify({
78
+ access_token: "at_new",
79
+ expires_in: 3600,
80
+ }),
81
+ { status: 200, headers: { "Content-Type": "application/json" } },
82
+ );
83
+ });
84
+ vi.stubGlobal("fetch", fetchSpy);
85
+
86
+ const loaded = ensureAuthProfileStore();
87
+ const resolved = await resolveApiKeyForProfile({
88
+ store: loaded,
89
+ profileId: "chutes:default",
90
+ });
91
+
92
+ expect(resolved?.apiKey).toBe("at_new");
93
+ expect(fetchSpy).toHaveBeenCalled();
94
+
95
+ const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
96
+ profiles?: Record<string, { access?: string }>;
97
+ };
98
+ expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
99
+ });
100
+ });
src/agents/auth-profiles.ensureauthprofilestore.test.ts ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { ensureAuthProfileStore } from "./auth-profiles.js";
6
+ import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
7
+
8
+ describe("ensureAuthProfileStore", () => {
9
+ it("migrates legacy auth.json and deletes it (PR #368)", () => {
10
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-"));
11
+ try {
12
+ const legacyPath = path.join(agentDir, "auth.json");
13
+ fs.writeFileSync(
14
+ legacyPath,
15
+ `${JSON.stringify(
16
+ {
17
+ anthropic: {
18
+ type: "oauth",
19
+ provider: "anthropic",
20
+ access: "access-token",
21
+ refresh: "refresh-token",
22
+ expires: Date.now() + 60_000,
23
+ },
24
+ },
25
+ null,
26
+ 2,
27
+ )}\n`,
28
+ "utf8",
29
+ );
30
+
31
+ const store = ensureAuthProfileStore(agentDir);
32
+ expect(store.profiles["anthropic:default"]).toMatchObject({
33
+ type: "oauth",
34
+ provider: "anthropic",
35
+ });
36
+
37
+ const migratedPath = path.join(agentDir, "auth-profiles.json");
38
+ expect(fs.existsSync(migratedPath)).toBe(true);
39
+ expect(fs.existsSync(legacyPath)).toBe(false);
40
+
41
+ // idempotent
42
+ const store2 = ensureAuthProfileStore(agentDir);
43
+ expect(store2.profiles["anthropic:default"]).toBeDefined();
44
+ expect(fs.existsSync(legacyPath)).toBe(false);
45
+ } finally {
46
+ fs.rmSync(agentDir, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ it("merges main auth profiles into agent store and keeps agent overrides", () => {
51
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-merge-"));
52
+ const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
53
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
54
+ try {
55
+ const mainDir = path.join(root, "main-agent");
56
+ const agentDir = path.join(root, "agent-x");
57
+ fs.mkdirSync(mainDir, { recursive: true });
58
+ fs.mkdirSync(agentDir, { recursive: true });
59
+
60
+ process.env.OPENCLAW_AGENT_DIR = mainDir;
61
+ process.env.PI_CODING_AGENT_DIR = mainDir;
62
+
63
+ const mainStore = {
64
+ version: AUTH_STORE_VERSION,
65
+ profiles: {
66
+ "openai:default": {
67
+ type: "api_key",
68
+ provider: "openai",
69
+ key: "main-key",
70
+ },
71
+ "anthropic:default": {
72
+ type: "api_key",
73
+ provider: "anthropic",
74
+ key: "main-anthropic-key",
75
+ },
76
+ },
77
+ };
78
+ fs.writeFileSync(
79
+ path.join(mainDir, "auth-profiles.json"),
80
+ `${JSON.stringify(mainStore, null, 2)}\n`,
81
+ "utf8",
82
+ );
83
+
84
+ const agentStore = {
85
+ version: AUTH_STORE_VERSION,
86
+ profiles: {
87
+ "openai:default": {
88
+ type: "api_key",
89
+ provider: "openai",
90
+ key: "agent-key",
91
+ },
92
+ },
93
+ };
94
+ fs.writeFileSync(
95
+ path.join(agentDir, "auth-profiles.json"),
96
+ `${JSON.stringify(agentStore, null, 2)}\n`,
97
+ "utf8",
98
+ );
99
+
100
+ const store = ensureAuthProfileStore(agentDir);
101
+ expect(store.profiles["anthropic:default"]).toMatchObject({
102
+ type: "api_key",
103
+ provider: "anthropic",
104
+ key: "main-anthropic-key",
105
+ });
106
+ expect(store.profiles["openai:default"]).toMatchObject({
107
+ type: "api_key",
108
+ provider: "openai",
109
+ key: "agent-key",
110
+ });
111
+ } finally {
112
+ if (previousAgentDir === undefined) {
113
+ delete process.env.OPENCLAW_AGENT_DIR;
114
+ } else {
115
+ process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
116
+ }
117
+ if (previousPiAgentDir === undefined) {
118
+ delete process.env.PI_CODING_AGENT_DIR;
119
+ } else {
120
+ process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
121
+ }
122
+ fs.rmSync(root, { recursive: true, force: true });
123
+ }
124
+ });
125
+ });
src/agents/auth-profiles.markauthprofilefailure.test.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.js";
6
+
7
+ describe("markAuthProfileFailure", () => {
8
+ it("disables billing failures for ~5 hours by default", async () => {
9
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
10
+ try {
11
+ const authPath = path.join(agentDir, "auth-profiles.json");
12
+ fs.writeFileSync(
13
+ authPath,
14
+ JSON.stringify({
15
+ version: 1,
16
+ profiles: {
17
+ "anthropic:default": {
18
+ type: "api_key",
19
+ provider: "anthropic",
20
+ key: "sk-default",
21
+ },
22
+ },
23
+ }),
24
+ );
25
+
26
+ const store = ensureAuthProfileStore(agentDir);
27
+ const startedAt = Date.now();
28
+ await markAuthProfileFailure({
29
+ store,
30
+ profileId: "anthropic:default",
31
+ reason: "billing",
32
+ agentDir,
33
+ });
34
+
35
+ const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
36
+ expect(typeof disabledUntil).toBe("number");
37
+ const remainingMs = (disabledUntil as number) - startedAt;
38
+ expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
39
+ expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000);
40
+ } finally {
41
+ fs.rmSync(agentDir, { recursive: true, force: true });
42
+ }
43
+ });
44
+ it("honors per-provider billing backoff overrides", async () => {
45
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
46
+ try {
47
+ const authPath = path.join(agentDir, "auth-profiles.json");
48
+ fs.writeFileSync(
49
+ authPath,
50
+ JSON.stringify({
51
+ version: 1,
52
+ profiles: {
53
+ "anthropic:default": {
54
+ type: "api_key",
55
+ provider: "anthropic",
56
+ key: "sk-default",
57
+ },
58
+ },
59
+ }),
60
+ );
61
+
62
+ const store = ensureAuthProfileStore(agentDir);
63
+ const startedAt = Date.now();
64
+ await markAuthProfileFailure({
65
+ store,
66
+ profileId: "anthropic:default",
67
+ reason: "billing",
68
+ agentDir,
69
+ cfg: {
70
+ auth: {
71
+ cooldowns: {
72
+ billingBackoffHoursByProvider: { Anthropic: 1 },
73
+ billingMaxHours: 2,
74
+ },
75
+ },
76
+ } as never,
77
+ });
78
+
79
+ const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
80
+ expect(typeof disabledUntil).toBe("number");
81
+ const remainingMs = (disabledUntil as number) - startedAt;
82
+ expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
83
+ expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000);
84
+ } finally {
85
+ fs.rmSync(agentDir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ it("resets backoff counters outside the failure window", async () => {
89
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
90
+ try {
91
+ const authPath = path.join(agentDir, "auth-profiles.json");
92
+ const now = Date.now();
93
+ fs.writeFileSync(
94
+ authPath,
95
+ JSON.stringify({
96
+ version: 1,
97
+ profiles: {
98
+ "anthropic:default": {
99
+ type: "api_key",
100
+ provider: "anthropic",
101
+ key: "sk-default",
102
+ },
103
+ },
104
+ usageStats: {
105
+ "anthropic:default": {
106
+ errorCount: 9,
107
+ failureCounts: { billing: 3 },
108
+ lastFailureAt: now - 48 * 60 * 60 * 1000,
109
+ },
110
+ },
111
+ }),
112
+ );
113
+
114
+ const store = ensureAuthProfileStore(agentDir);
115
+ await markAuthProfileFailure({
116
+ store,
117
+ profileId: "anthropic:default",
118
+ reason: "billing",
119
+ agentDir,
120
+ cfg: {
121
+ auth: { cooldowns: { failureWindowHours: 24 } },
122
+ } as never,
123
+ });
124
+
125
+ expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(1);
126
+ expect(store.usageStats?.["anthropic:default"]?.failureCounts?.billing).toBe(1);
127
+ } finally {
128
+ fs.rmSync(agentDir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ });
src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveAuthProfileOrder } from "./auth-profiles.js";
3
+
4
+ describe("resolveAuthProfileOrder", () => {
5
+ const store: AuthProfileStore = {
6
+ version: 1,
7
+ profiles: {
8
+ "anthropic:default": {
9
+ type: "api_key",
10
+ provider: "anthropic",
11
+ key: "sk-default",
12
+ },
13
+ "anthropic:work": {
14
+ type: "api_key",
15
+ provider: "anthropic",
16
+ key: "sk-work",
17
+ },
18
+ },
19
+ };
20
+ const cfg = {
21
+ auth: {
22
+ profiles: {
23
+ "anthropic:default": { provider: "anthropic", mode: "api_key" },
24
+ "anthropic:work": { provider: "anthropic", mode: "api_key" },
25
+ },
26
+ },
27
+ };
28
+
29
+ it("does not prioritize lastGood over round-robin ordering", () => {
30
+ const order = resolveAuthProfileOrder({
31
+ cfg,
32
+ store: {
33
+ ...store,
34
+ lastGood: { anthropic: "anthropic:work" },
35
+ usageStats: {
36
+ "anthropic:default": { lastUsed: 100 },
37
+ "anthropic:work": { lastUsed: 200 },
38
+ },
39
+ },
40
+ provider: "anthropic",
41
+ });
42
+ expect(order[0]).toBe("anthropic:default");
43
+ });
44
+ it("uses explicit profiles when order is missing", () => {
45
+ const order = resolveAuthProfileOrder({
46
+ cfg,
47
+ store,
48
+ provider: "anthropic",
49
+ });
50
+ expect(order).toEqual(["anthropic:default", "anthropic:work"]);
51
+ });
52
+ it("uses configured order when provided", () => {
53
+ const order = resolveAuthProfileOrder({
54
+ cfg: {
55
+ auth: {
56
+ order: { anthropic: ["anthropic:work", "anthropic:default"] },
57
+ profiles: cfg.auth.profiles,
58
+ },
59
+ },
60
+ store,
61
+ provider: "anthropic",
62
+ });
63
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
64
+ });
65
+ it("prefers store order over config order", () => {
66
+ const order = resolveAuthProfileOrder({
67
+ cfg: {
68
+ auth: {
69
+ order: { anthropic: ["anthropic:default", "anthropic:work"] },
70
+ profiles: cfg.auth.profiles,
71
+ },
72
+ },
73
+ store: {
74
+ ...store,
75
+ order: { anthropic: ["anthropic:work", "anthropic:default"] },
76
+ },
77
+ provider: "anthropic",
78
+ });
79
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
80
+ });
81
+ it("pushes cooldown profiles to the end even with store order", () => {
82
+ const now = Date.now();
83
+ const order = resolveAuthProfileOrder({
84
+ store: {
85
+ ...store,
86
+ order: { anthropic: ["anthropic:default", "anthropic:work"] },
87
+ usageStats: {
88
+ "anthropic:default": { cooldownUntil: now + 60_000 },
89
+ "anthropic:work": { lastUsed: 1 },
90
+ },
91
+ },
92
+ provider: "anthropic",
93
+ });
94
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
95
+ });
96
+ it("pushes cooldown profiles to the end even with configured order", () => {
97
+ const now = Date.now();
98
+ const order = resolveAuthProfileOrder({
99
+ cfg: {
100
+ auth: {
101
+ order: { anthropic: ["anthropic:default", "anthropic:work"] },
102
+ profiles: cfg.auth.profiles,
103
+ },
104
+ },
105
+ store: {
106
+ ...store,
107
+ usageStats: {
108
+ "anthropic:default": { cooldownUntil: now + 60_000 },
109
+ "anthropic:work": { lastUsed: 1 },
110
+ },
111
+ },
112
+ provider: "anthropic",
113
+ });
114
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
115
+ });
116
+ it("pushes disabled profiles to the end even with store order", () => {
117
+ const now = Date.now();
118
+ const order = resolveAuthProfileOrder({
119
+ store: {
120
+ ...store,
121
+ order: { anthropic: ["anthropic:default", "anthropic:work"] },
122
+ usageStats: {
123
+ "anthropic:default": {
124
+ disabledUntil: now + 60_000,
125
+ disabledReason: "billing",
126
+ },
127
+ "anthropic:work": { lastUsed: 1 },
128
+ },
129
+ },
130
+ provider: "anthropic",
131
+ });
132
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
133
+ });
134
+ it("pushes disabled profiles to the end even with configured order", () => {
135
+ const now = Date.now();
136
+ const order = resolveAuthProfileOrder({
137
+ cfg: {
138
+ auth: {
139
+ order: { anthropic: ["anthropic:default", "anthropic:work"] },
140
+ profiles: cfg.auth.profiles,
141
+ },
142
+ },
143
+ store: {
144
+ ...store,
145
+ usageStats: {
146
+ "anthropic:default": {
147
+ disabledUntil: now + 60_000,
148
+ disabledReason: "billing",
149
+ },
150
+ "anthropic:work": { lastUsed: 1 },
151
+ },
152
+ },
153
+ provider: "anthropic",
154
+ });
155
+ expect(order).toEqual(["anthropic:work", "anthropic:default"]);
156
+ });
157
+
158
+ it("mode: oauth config accepts both oauth and token credentials (issue #559)", () => {
159
+ const now = Date.now();
160
+ const storeWithBothTypes: AuthProfileStore = {
161
+ version: 1,
162
+ profiles: {
163
+ "anthropic:oauth-cred": {
164
+ type: "oauth",
165
+ provider: "anthropic",
166
+ access: "access-token",
167
+ refresh: "refresh-token",
168
+ expires: now + 60_000,
169
+ },
170
+ "anthropic:token-cred": {
171
+ type: "token",
172
+ provider: "anthropic",
173
+ token: "just-a-token",
174
+ expires: now + 60_000,
175
+ },
176
+ },
177
+ };
178
+
179
+ const orderOauthCred = resolveAuthProfileOrder({
180
+ store: storeWithBothTypes,
181
+ provider: "anthropic",
182
+ cfg: {
183
+ auth: {
184
+ profiles: {
185
+ "anthropic:oauth-cred": { provider: "anthropic", mode: "oauth" },
186
+ },
187
+ },
188
+ },
189
+ });
190
+ expect(orderOauthCred).toContain("anthropic:oauth-cred");
191
+
192
+ const orderTokenCred = resolveAuthProfileOrder({
193
+ store: storeWithBothTypes,
194
+ provider: "anthropic",
195
+ cfg: {
196
+ auth: {
197
+ profiles: {
198
+ "anthropic:token-cred": { provider: "anthropic", mode: "oauth" },
199
+ },
200
+ },
201
+ },
202
+ });
203
+ expect(orderTokenCred).toContain("anthropic:token-cred");
204
+ });
205
+
206
+ it("mode: token config rejects oauth credentials (issue #559 root cause)", () => {
207
+ const now = Date.now();
208
+ const storeWithOauth: AuthProfileStore = {
209
+ version: 1,
210
+ profiles: {
211
+ "anthropic:oauth-cred": {
212
+ type: "oauth",
213
+ provider: "anthropic",
214
+ access: "access-token",
215
+ refresh: "refresh-token",
216
+ expires: now + 60_000,
217
+ },
218
+ },
219
+ };
220
+
221
+ const order = resolveAuthProfileOrder({
222
+ store: storeWithOauth,
223
+ provider: "anthropic",
224
+ cfg: {
225
+ auth: {
226
+ profiles: {
227
+ "anthropic:oauth-cred": { provider: "anthropic", mode: "token" },
228
+ },
229
+ },
230
+ },
231
+ });
232
+ expect(order).not.toContain("anthropic:oauth-cred");
233
+ });
234
+ });
src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveAuthProfileOrder } from "./auth-profiles.js";
3
+
4
+ describe("resolveAuthProfileOrder", () => {
5
+ const _store: AuthProfileStore = {
6
+ version: 1,
7
+ profiles: {
8
+ "anthropic:default": {
9
+ type: "api_key",
10
+ provider: "anthropic",
11
+ key: "sk-default",
12
+ },
13
+ "anthropic:work": {
14
+ type: "api_key",
15
+ provider: "anthropic",
16
+ key: "sk-work",
17
+ },
18
+ },
19
+ };
20
+ const _cfg = {
21
+ auth: {
22
+ profiles: {
23
+ "anthropic:default": { provider: "anthropic", mode: "api_key" },
24
+ "anthropic:work": { provider: "anthropic", mode: "api_key" },
25
+ },
26
+ },
27
+ };
28
+
29
+ it("normalizes z.ai aliases in auth.order", () => {
30
+ const order = resolveAuthProfileOrder({
31
+ cfg: {
32
+ auth: {
33
+ order: { "z.ai": ["zai:work", "zai:default"] },
34
+ profiles: {
35
+ "zai:default": { provider: "zai", mode: "api_key" },
36
+ "zai:work": { provider: "zai", mode: "api_key" },
37
+ },
38
+ },
39
+ },
40
+ store: {
41
+ version: 1,
42
+ profiles: {
43
+ "zai:default": {
44
+ type: "api_key",
45
+ provider: "zai",
46
+ key: "sk-default",
47
+ },
48
+ "zai:work": {
49
+ type: "api_key",
50
+ provider: "zai",
51
+ key: "sk-work",
52
+ },
53
+ },
54
+ },
55
+ provider: "zai",
56
+ });
57
+ expect(order).toEqual(["zai:work", "zai:default"]);
58
+ });
59
+ it("normalizes provider casing in auth.order keys", () => {
60
+ const order = resolveAuthProfileOrder({
61
+ cfg: {
62
+ auth: {
63
+ order: { OpenAI: ["openai:work", "openai:default"] },
64
+ profiles: {
65
+ "openai:default": { provider: "openai", mode: "api_key" },
66
+ "openai:work": { provider: "openai", mode: "api_key" },
67
+ },
68
+ },
69
+ },
70
+ store: {
71
+ version: 1,
72
+ profiles: {
73
+ "openai:default": {
74
+ type: "api_key",
75
+ provider: "openai",
76
+ key: "sk-default",
77
+ },
78
+ "openai:work": {
79
+ type: "api_key",
80
+ provider: "openai",
81
+ key: "sk-work",
82
+ },
83
+ },
84
+ },
85
+ provider: "openai",
86
+ });
87
+ expect(order).toEqual(["openai:work", "openai:default"]);
88
+ });
89
+ it("normalizes z.ai aliases in auth.profiles", () => {
90
+ const order = resolveAuthProfileOrder({
91
+ cfg: {
92
+ auth: {
93
+ profiles: {
94
+ "zai:default": { provider: "z.ai", mode: "api_key" },
95
+ "zai:work": { provider: "Z.AI", mode: "api_key" },
96
+ },
97
+ },
98
+ },
99
+ store: {
100
+ version: 1,
101
+ profiles: {
102
+ "zai:default": {
103
+ type: "api_key",
104
+ provider: "zai",
105
+ key: "sk-default",
106
+ },
107
+ "zai:work": {
108
+ type: "api_key",
109
+ provider: "zai",
110
+ key: "sk-work",
111
+ },
112
+ },
113
+ },
114
+ provider: "zai",
115
+ });
116
+ expect(order).toEqual(["zai:default", "zai:work"]);
117
+ });
118
+ it("prioritizes oauth profiles when order missing", () => {
119
+ const mixedStore: AuthProfileStore = {
120
+ version: 1,
121
+ profiles: {
122
+ "anthropic:default": {
123
+ type: "api_key",
124
+ provider: "anthropic",
125
+ key: "sk-default",
126
+ },
127
+ "anthropic:oauth": {
128
+ type: "oauth",
129
+ provider: "anthropic",
130
+ access: "access-token",
131
+ refresh: "refresh-token",
132
+ expires: Date.now() + 60_000,
133
+ },
134
+ },
135
+ };
136
+ const order = resolveAuthProfileOrder({
137
+ store: mixedStore,
138
+ provider: "anthropic",
139
+ });
140
+ expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
141
+ });
142
+ });
src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveAuthProfileOrder } from "./auth-profiles.js";
3
+
4
+ describe("resolveAuthProfileOrder", () => {
5
+ const _store: AuthProfileStore = {
6
+ version: 1,
7
+ profiles: {
8
+ "anthropic:default": {
9
+ type: "api_key",
10
+ provider: "anthropic",
11
+ key: "sk-default",
12
+ },
13
+ "anthropic:work": {
14
+ type: "api_key",
15
+ provider: "anthropic",
16
+ key: "sk-work",
17
+ },
18
+ },
19
+ };
20
+ const _cfg = {
21
+ auth: {
22
+ profiles: {
23
+ "anthropic:default": { provider: "anthropic", mode: "api_key" },
24
+ "anthropic:work": { provider: "anthropic", mode: "api_key" },
25
+ },
26
+ },
27
+ };
28
+
29
+ it("orders by lastUsed when no explicit order exists", () => {
30
+ const order = resolveAuthProfileOrder({
31
+ store: {
32
+ version: 1,
33
+ profiles: {
34
+ "anthropic:a": {
35
+ type: "oauth",
36
+ provider: "anthropic",
37
+ access: "access-token",
38
+ refresh: "refresh-token",
39
+ expires: Date.now() + 60_000,
40
+ },
41
+ "anthropic:b": {
42
+ type: "api_key",
43
+ provider: "anthropic",
44
+ key: "sk-b",
45
+ },
46
+ "anthropic:c": {
47
+ type: "api_key",
48
+ provider: "anthropic",
49
+ key: "sk-c",
50
+ },
51
+ },
52
+ usageStats: {
53
+ "anthropic:a": { lastUsed: 200 },
54
+ "anthropic:b": { lastUsed: 100 },
55
+ "anthropic:c": { lastUsed: 300 },
56
+ },
57
+ },
58
+ provider: "anthropic",
59
+ });
60
+ expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]);
61
+ });
62
+ it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => {
63
+ const now = Date.now();
64
+ const order = resolveAuthProfileOrder({
65
+ store: {
66
+ version: 1,
67
+ profiles: {
68
+ "anthropic:ready": {
69
+ type: "api_key",
70
+ provider: "anthropic",
71
+ key: "sk-ready",
72
+ },
73
+ "anthropic:cool1": {
74
+ type: "oauth",
75
+ provider: "anthropic",
76
+ access: "access-token",
77
+ refresh: "refresh-token",
78
+ expires: now + 60_000,
79
+ },
80
+ "anthropic:cool2": {
81
+ type: "api_key",
82
+ provider: "anthropic",
83
+ key: "sk-cool",
84
+ },
85
+ },
86
+ usageStats: {
87
+ "anthropic:ready": { lastUsed: 50 },
88
+ "anthropic:cool1": { cooldownUntil: now + 5_000 },
89
+ "anthropic:cool2": { cooldownUntil: now + 1_000 },
90
+ },
91
+ },
92
+ provider: "anthropic",
93
+ });
94
+ expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
95
+ });
96
+ });
src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveAuthProfileOrder } from "./auth-profiles.js";
3
+
4
+ describe("resolveAuthProfileOrder", () => {
5
+ const store: AuthProfileStore = {
6
+ version: 1,
7
+ profiles: {
8
+ "anthropic:default": {
9
+ type: "api_key",
10
+ provider: "anthropic",
11
+ key: "sk-default",
12
+ },
13
+ "anthropic:work": {
14
+ type: "api_key",
15
+ provider: "anthropic",
16
+ key: "sk-work",
17
+ },
18
+ },
19
+ };
20
+ const cfg = {
21
+ auth: {
22
+ profiles: {
23
+ "anthropic:default": { provider: "anthropic", mode: "api_key" },
24
+ "anthropic:work": { provider: "anthropic", mode: "api_key" },
25
+ },
26
+ },
27
+ };
28
+
29
+ it("uses stored profiles when no config exists", () => {
30
+ const order = resolveAuthProfileOrder({
31
+ store,
32
+ provider: "anthropic",
33
+ });
34
+ expect(order).toEqual(["anthropic:default", "anthropic:work"]);
35
+ });
36
+ it("prioritizes preferred profiles", () => {
37
+ const order = resolveAuthProfileOrder({
38
+ cfg,
39
+ store,
40
+ provider: "anthropic",
41
+ preferredProfile: "anthropic:work",
42
+ });
43
+ expect(order[0]).toBe("anthropic:work");
44
+ expect(order).toContain("anthropic:default");
45
+ });
46
+ it("drops explicit order entries that are missing from the store", () => {
47
+ const order = resolveAuthProfileOrder({
48
+ cfg: {
49
+ auth: {
50
+ order: {
51
+ minimax: ["minimax:default", "minimax:prod"],
52
+ },
53
+ },
54
+ },
55
+ store: {
56
+ version: 1,
57
+ profiles: {
58
+ "minimax:prod": {
59
+ type: "api_key",
60
+ provider: "minimax",
61
+ key: "sk-prod",
62
+ },
63
+ },
64
+ },
65
+ provider: "minimax",
66
+ });
67
+ expect(order).toEqual(["minimax:prod"]);
68
+ });
69
+ it("drops explicit order entries that belong to another provider", () => {
70
+ const order = resolveAuthProfileOrder({
71
+ cfg: {
72
+ auth: {
73
+ order: {
74
+ minimax: ["openai:default", "minimax:prod"],
75
+ },
76
+ },
77
+ },
78
+ store: {
79
+ version: 1,
80
+ profiles: {
81
+ "openai:default": {
82
+ type: "api_key",
83
+ provider: "openai",
84
+ key: "sk-openai",
85
+ },
86
+ "minimax:prod": {
87
+ type: "api_key",
88
+ provider: "minimax",
89
+ key: "sk-mini",
90
+ },
91
+ },
92
+ },
93
+ provider: "minimax",
94
+ });
95
+ expect(order).toEqual(["minimax:prod"]);
96
+ });
97
+ it("drops token profiles with empty credentials", () => {
98
+ const order = resolveAuthProfileOrder({
99
+ cfg: {
100
+ auth: {
101
+ order: {
102
+ minimax: ["minimax:default"],
103
+ },
104
+ },
105
+ },
106
+ store: {
107
+ version: 1,
108
+ profiles: {
109
+ "minimax:default": {
110
+ type: "token",
111
+ provider: "minimax",
112
+ token: " ",
113
+ },
114
+ },
115
+ },
116
+ provider: "minimax",
117
+ });
118
+ expect(order).toEqual([]);
119
+ });
120
+ it("drops token profiles that are already expired", () => {
121
+ const order = resolveAuthProfileOrder({
122
+ cfg: {
123
+ auth: {
124
+ order: {
125
+ minimax: ["minimax:default"],
126
+ },
127
+ },
128
+ },
129
+ store: {
130
+ version: 1,
131
+ profiles: {
132
+ "minimax:default": {
133
+ type: "token",
134
+ provider: "minimax",
135
+ token: "sk-minimax",
136
+ expires: Date.now() - 1000,
137
+ },
138
+ },
139
+ },
140
+ provider: "minimax",
141
+ });
142
+ expect(order).toEqual([]);
143
+ });
144
+ it("keeps oauth profiles that can refresh", () => {
145
+ const order = resolveAuthProfileOrder({
146
+ cfg: {
147
+ auth: {
148
+ order: {
149
+ anthropic: ["anthropic:oauth"],
150
+ },
151
+ },
152
+ },
153
+ store: {
154
+ version: 1,
155
+ profiles: {
156
+ "anthropic:oauth": {
157
+ type: "oauth",
158
+ provider: "anthropic",
159
+ access: "",
160
+ refresh: "refresh-token",
161
+ expires: Date.now() - 1000,
162
+ },
163
+ },
164
+ },
165
+ provider: "anthropic",
166
+ });
167
+ expect(order).toEqual(["anthropic:oauth"]);
168
+ });
169
+ });
src/agents/auth-profiles.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
2
+ export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
3
+ export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
4
+ export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";
5
+ export { resolveAuthProfileOrder } from "./auth-profiles/order.js";
6
+ export { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js";
7
+ export {
8
+ listProfilesForProvider,
9
+ markAuthProfileGood,
10
+ setAuthProfileOrder,
11
+ upsertAuthProfile,
12
+ } from "./auth-profiles/profiles.js";
13
+ export {
14
+ repairOAuthProfileIdMismatch,
15
+ suggestOAuthProfileIdForLegacyDefault,
16
+ } from "./auth-profiles/repair.js";
17
+ export {
18
+ ensureAuthProfileStore,
19
+ loadAuthProfileStore,
20
+ saveAuthProfileStore,
21
+ } from "./auth-profiles/store.js";
22
+ export type {
23
+ ApiKeyCredential,
24
+ AuthProfileCredential,
25
+ AuthProfileFailureReason,
26
+ AuthProfileIdRepairResult,
27
+ AuthProfileStore,
28
+ OAuthCredential,
29
+ ProfileUsageStats,
30
+ TokenCredential,
31
+ } from "./auth-profiles/types.js";
32
+ export {
33
+ calculateAuthProfileCooldownMs,
34
+ clearAuthProfileCooldown,
35
+ isProfileInCooldown,
36
+ markAuthProfileCooldown,
37
+ markAuthProfileFailure,
38
+ markAuthProfileUsed,
39
+ resolveProfileUnusableUntilForDisplay,
40
+ } from "./auth-profiles/usage.js";
src/agents/auth-profiles/constants.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSubsystemLogger } from "../../logging/subsystem.js";
2
+
3
+ export const AUTH_STORE_VERSION = 1;
4
+ export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
5
+ export const LEGACY_AUTH_FILENAME = "auth.json";
6
+
7
+ export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
8
+ export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
9
+ export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
10
+ export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli";
11
+
12
+ export const AUTH_STORE_LOCK_OPTIONS = {
13
+ retries: {
14
+ retries: 10,
15
+ factor: 2,
16
+ minTimeout: 100,
17
+ maxTimeout: 10_000,
18
+ randomize: true,
19
+ },
20
+ stale: 30_000,
21
+ } as const;
22
+
23
+ export const EXTERNAL_CLI_SYNC_TTL_MS = 15 * 60 * 1000;
24
+ export const EXTERNAL_CLI_NEAR_EXPIRY_MS = 10 * 60 * 1000;
25
+
26
+ export const log = createSubsystemLogger("agents/auth-profiles");
src/agents/auth-profiles/display.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import type { AuthProfileStore } from "./types.js";
3
+
4
+ export function resolveAuthProfileDisplayLabel(params: {
5
+ cfg?: OpenClawConfig;
6
+ store: AuthProfileStore;
7
+ profileId: string;
8
+ }): string {
9
+ const { cfg, store, profileId } = params;
10
+ const profile = store.profiles[profileId];
11
+ const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
12
+ const email = configEmail || (profile && "email" in profile ? profile.email?.trim() : undefined);
13
+ if (email) {
14
+ return `${profileId} (${email})`;
15
+ }
16
+ return profileId;
17
+ }
src/agents/auth-profiles/doctor.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import type { AuthProfileStore } from "./types.js";
3
+ import { formatCliCommand } from "../../cli/command-format.js";
4
+ import { normalizeProviderId } from "../model-selection.js";
5
+ import { listProfilesForProvider } from "./profiles.js";
6
+ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
7
+
8
+ export function formatAuthDoctorHint(params: {
9
+ cfg?: OpenClawConfig;
10
+ store: AuthProfileStore;
11
+ provider: string;
12
+ profileId?: string;
13
+ }): string {
14
+ const providerKey = normalizeProviderId(params.provider);
15
+ if (providerKey !== "anthropic") {
16
+ return "";
17
+ }
18
+
19
+ const legacyProfileId = params.profileId ?? "anthropic:default";
20
+ const suggested = suggestOAuthProfileIdForLegacyDefault({
21
+ cfg: params.cfg,
22
+ store: params.store,
23
+ provider: providerKey,
24
+ legacyProfileId,
25
+ });
26
+ if (!suggested || suggested === legacyProfileId) {
27
+ return "";
28
+ }
29
+
30
+ const storeOauthProfiles = listProfilesForProvider(params.store, providerKey)
31
+ .filter((id) => params.store.profiles[id]?.type === "oauth")
32
+ .join(", ");
33
+
34
+ const cfgMode = params.cfg?.auth?.profiles?.[legacyProfileId]?.mode;
35
+ const cfgProvider = params.cfg?.auth?.profiles?.[legacyProfileId]?.provider;
36
+
37
+ return [
38
+ "Doctor hint (for GitHub issue):",
39
+ `- provider: ${providerKey}`,
40
+ `- config: ${legacyProfileId}${
41
+ cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
42
+ }`,
43
+ `- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
44
+ `- suggested profile: ${suggested}`,
45
+ `Fix: run "${formatCliCommand("openclaw doctor --yes")}"`,
46
+ ].join("\n");
47
+ }
src/agents/auth-profiles/external-cli-sync.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
2
+ import {
3
+ readQwenCliCredentialsCached,
4
+ readMiniMaxCliCredentialsCached,
5
+ } from "../cli-credentials.js";
6
+ import {
7
+ EXTERNAL_CLI_NEAR_EXPIRY_MS,
8
+ EXTERNAL_CLI_SYNC_TTL_MS,
9
+ QWEN_CLI_PROFILE_ID,
10
+ MINIMAX_CLI_PROFILE_ID,
11
+ log,
12
+ } from "./constants.js";
13
+
14
+ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
15
+ if (!a) {
16
+ return false;
17
+ }
18
+ if (a.type !== "oauth") {
19
+ return false;
20
+ }
21
+ return (
22
+ a.provider === b.provider &&
23
+ a.access === b.access &&
24
+ a.refresh === b.refresh &&
25
+ a.expires === b.expires &&
26
+ a.email === b.email &&
27
+ a.enterpriseUrl === b.enterpriseUrl &&
28
+ a.projectId === b.projectId &&
29
+ a.accountId === b.accountId
30
+ );
31
+ }
32
+
33
+ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
34
+ if (!cred) {
35
+ return false;
36
+ }
37
+ if (cred.type !== "oauth" && cred.type !== "token") {
38
+ return false;
39
+ }
40
+ if (cred.provider !== "qwen-portal" && cred.provider !== "minimax-portal") {
41
+ return false;
42
+ }
43
+ if (typeof cred.expires !== "number") {
44
+ return true;
45
+ }
46
+ return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
47
+ }
48
+
49
+ /** Sync external CLI credentials into the store for a given provider. */
50
+ function syncExternalCliCredentialsForProvider(
51
+ store: AuthProfileStore,
52
+ profileId: string,
53
+ provider: string,
54
+ readCredentials: () => OAuthCredential | null,
55
+ now: number,
56
+ ): boolean {
57
+ const existing = store.profiles[profileId];
58
+ const shouldSync =
59
+ !existing || existing.provider !== provider || !isExternalProfileFresh(existing, now);
60
+ const creds = shouldSync ? readCredentials() : null;
61
+ if (!creds) {
62
+ return false;
63
+ }
64
+
65
+ const existingOAuth = existing?.type === "oauth" ? existing : undefined;
66
+ const shouldUpdate =
67
+ !existingOAuth ||
68
+ existingOAuth.provider !== provider ||
69
+ existingOAuth.expires <= now ||
70
+ creds.expires > existingOAuth.expires;
71
+
72
+ if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
73
+ store.profiles[profileId] = creds;
74
+ log.info(`synced ${provider} credentials from external cli`, {
75
+ profileId,
76
+ expires: new Date(creds.expires).toISOString(),
77
+ });
78
+ return true;
79
+ }
80
+
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI) into the store.
86
+ *
87
+ * Returns true if any credentials were updated.
88
+ */
89
+ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
90
+ let mutated = false;
91
+ const now = Date.now();
92
+
93
+ // Sync from Qwen Code CLI
94
+ const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
95
+ const shouldSyncQwen =
96
+ !existingQwen ||
97
+ existingQwen.provider !== "qwen-portal" ||
98
+ !isExternalProfileFresh(existingQwen, now);
99
+ const qwenCreds = shouldSyncQwen
100
+ ? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
101
+ : null;
102
+ if (qwenCreds) {
103
+ const existing = store.profiles[QWEN_CLI_PROFILE_ID];
104
+ const existingOAuth = existing?.type === "oauth" ? existing : undefined;
105
+ const shouldUpdate =
106
+ !existingOAuth ||
107
+ existingOAuth.provider !== "qwen-portal" ||
108
+ existingOAuth.expires <= now ||
109
+ qwenCreds.expires > existingOAuth.expires;
110
+
111
+ if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
112
+ store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
113
+ mutated = true;
114
+ log.info("synced qwen credentials from qwen cli", {
115
+ profileId: QWEN_CLI_PROFILE_ID,
116
+ expires: new Date(qwenCreds.expires).toISOString(),
117
+ });
118
+ }
119
+ }
120
+
121
+ // Sync from MiniMax Portal CLI
122
+ if (
123
+ syncExternalCliCredentialsForProvider(
124
+ store,
125
+ MINIMAX_CLI_PROFILE_ID,
126
+ "minimax-portal",
127
+ () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
128
+ now,
129
+ )
130
+ ) {
131
+ mutated = true;
132
+ }
133
+
134
+ return mutated;
135
+ }
src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { AuthProfileStore } from "./types.js";
6
+ import { resolveApiKeyForProfile } from "./oauth.js";
7
+ import { ensureAuthProfileStore } from "./store.js";
8
+
9
+ describe("resolveApiKeyForProfile fallback to main agent", () => {
10
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
11
+ const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
12
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
13
+ let tmpDir: string;
14
+ let mainAgentDir: string;
15
+ let secondaryAgentDir: string;
16
+
17
+ beforeEach(async () => {
18
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
19
+ mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
20
+ secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
21
+ await fs.mkdir(mainAgentDir, { recursive: true });
22
+ await fs.mkdir(secondaryAgentDir, { recursive: true });
23
+
24
+ // Set environment variables so resolveOpenClawAgentDir() returns mainAgentDir
25
+ process.env.OPENCLAW_STATE_DIR = tmpDir;
26
+ process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
27
+ process.env.PI_CODING_AGENT_DIR = mainAgentDir;
28
+ });
29
+
30
+ afterEach(async () => {
31
+ vi.unstubAllGlobals();
32
+
33
+ // Restore original environment
34
+ if (previousStateDir === undefined) {
35
+ delete process.env.OPENCLAW_STATE_DIR;
36
+ } else {
37
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
38
+ }
39
+ if (previousAgentDir === undefined) {
40
+ delete process.env.OPENCLAW_AGENT_DIR;
41
+ } else {
42
+ process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
43
+ }
44
+ if (previousPiAgentDir === undefined) {
45
+ delete process.env.PI_CODING_AGENT_DIR;
46
+ } else {
47
+ process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
48
+ }
49
+
50
+ await fs.rm(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
54
+ const profileId = "anthropic:claude-cli";
55
+ const now = Date.now();
56
+ const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
57
+ const freshTime = now + 60 * 60 * 1000; // 1 hour from now
58
+
59
+ // Write expired credentials for secondary agent
60
+ const secondaryStore: AuthProfileStore = {
61
+ version: 1,
62
+ profiles: {
63
+ [profileId]: {
64
+ type: "oauth",
65
+ provider: "anthropic",
66
+ access: "expired-access-token",
67
+ refresh: "expired-refresh-token",
68
+ expires: expiredTime,
69
+ },
70
+ },
71
+ };
72
+ await fs.writeFile(
73
+ path.join(secondaryAgentDir, "auth-profiles.json"),
74
+ JSON.stringify(secondaryStore),
75
+ );
76
+
77
+ // Write fresh credentials for main agent
78
+ const mainStore: AuthProfileStore = {
79
+ version: 1,
80
+ profiles: {
81
+ [profileId]: {
82
+ type: "oauth",
83
+ provider: "anthropic",
84
+ access: "fresh-access-token",
85
+ refresh: "fresh-refresh-token",
86
+ expires: freshTime,
87
+ },
88
+ },
89
+ };
90
+ await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
91
+
92
+ // Mock fetch to simulate OAuth refresh failure
93
+ const fetchSpy = vi.fn(async () => {
94
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
95
+ status: 400,
96
+ headers: { "Content-Type": "application/json" },
97
+ });
98
+ });
99
+ vi.stubGlobal("fetch", fetchSpy);
100
+
101
+ // Load the secondary agent's store (will merge with main agent's store)
102
+ const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
103
+
104
+ // Call resolveApiKeyForProfile with the secondary agent's expired credentials
105
+ // This should:
106
+ // 1. Try to refresh the expired token (fails due to mocked fetch)
107
+ // 2. Fall back to main agent's fresh credentials
108
+ // 3. Copy those credentials to the secondary agent
109
+ const result = await resolveApiKeyForProfile({
110
+ store: loadedSecondaryStore,
111
+ profileId,
112
+ agentDir: secondaryAgentDir,
113
+ });
114
+
115
+ expect(result).not.toBeNull();
116
+ expect(result?.apiKey).toBe("fresh-access-token");
117
+ expect(result?.provider).toBe("anthropic");
118
+
119
+ // Verify the credentials were copied to the secondary agent
120
+ const updatedSecondaryStore = JSON.parse(
121
+ await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
122
+ ) as AuthProfileStore;
123
+ expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
124
+ access: "fresh-access-token",
125
+ expires: freshTime,
126
+ });
127
+ });
128
+
129
+ it("throws error when both secondary and main agent credentials are expired", async () => {
130
+ const profileId = "anthropic:claude-cli";
131
+ const now = Date.now();
132
+ const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
133
+
134
+ // Write expired credentials for both agents
135
+ const expiredStore: AuthProfileStore = {
136
+ version: 1,
137
+ profiles: {
138
+ [profileId]: {
139
+ type: "oauth",
140
+ provider: "anthropic",
141
+ access: "expired-access-token",
142
+ refresh: "expired-refresh-token",
143
+ expires: expiredTime,
144
+ },
145
+ },
146
+ };
147
+ await fs.writeFile(
148
+ path.join(secondaryAgentDir, "auth-profiles.json"),
149
+ JSON.stringify(expiredStore),
150
+ );
151
+ await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
152
+
153
+ // Mock fetch to simulate OAuth refresh failure
154
+ const fetchSpy = vi.fn(async () => {
155
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
156
+ status: 400,
157
+ headers: { "Content-Type": "application/json" },
158
+ });
159
+ });
160
+ vi.stubGlobal("fetch", fetchSpy);
161
+
162
+ const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
163
+
164
+ // Should throw because both agents have expired credentials
165
+ await expect(
166
+ resolveApiKeyForProfile({
167
+ store: loadedSecondaryStore,
168
+ profileId,
169
+ agentDir: secondaryAgentDir,
170
+ }),
171
+ ).rejects.toThrow(/OAuth token refresh failed/);
172
+ });
173
+ });
src/agents/auth-profiles/oauth.ts ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getOAuthApiKey,
3
+ getOAuthProviders,
4
+ type OAuthCredentials,
5
+ type OAuthProvider,
6
+ } from "@mariozechner/pi-ai";
7
+ import lockfile from "proper-lockfile";
8
+ import type { OpenClawConfig } from "../../config/config.js";
9
+ import type { AuthProfileStore } from "./types.js";
10
+ import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
11
+ import { refreshChutesTokens } from "../chutes-oauth.js";
12
+ import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
13
+ import { formatAuthDoctorHint } from "./doctor.js";
14
+ import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
15
+ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
16
+ import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
17
+
18
+ const OAUTH_PROVIDER_IDS = new Set<OAuthProvider>(
19
+ getOAuthProviders().map((provider) => provider.id),
20
+ );
21
+
22
+ function isOAuthProvider(provider: string): provider is OAuthProvider {
23
+ // biome-ignore lint/suspicious/noExplicitAny: type guard needs runtime check
24
+ return OAUTH_PROVIDER_IDS.has(provider as any);
25
+ }
26
+
27
+ const resolveOAuthProvider = (provider: string): OAuthProvider | null =>
28
+ isOAuthProvider(provider) ? provider : null;
29
+
30
+ function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
31
+ const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
32
+ return needsProjectId
33
+ ? JSON.stringify({
34
+ token: credentials.access,
35
+ projectId: credentials.projectId,
36
+ })
37
+ : credentials.access;
38
+ }
39
+
40
+ async function refreshOAuthTokenWithLock(params: {
41
+ profileId: string;
42
+ agentDir?: string;
43
+ }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
44
+ const authPath = resolveAuthStorePath(params.agentDir);
45
+ ensureAuthStoreFile(authPath);
46
+
47
+ let release: (() => Promise<void>) | undefined;
48
+ try {
49
+ release = await lockfile.lock(authPath, {
50
+ ...AUTH_STORE_LOCK_OPTIONS,
51
+ });
52
+
53
+ const store = ensureAuthProfileStore(params.agentDir);
54
+ const cred = store.profiles[params.profileId];
55
+ if (!cred || cred.type !== "oauth") {
56
+ return null;
57
+ }
58
+
59
+ if (Date.now() < cred.expires) {
60
+ return {
61
+ apiKey: buildOAuthApiKey(cred.provider, cred),
62
+ newCredentials: cred,
63
+ };
64
+ }
65
+
66
+ const oauthCreds: Record<string, OAuthCredentials> = {
67
+ [cred.provider]: cred,
68
+ };
69
+
70
+ const result =
71
+ String(cred.provider) === "chutes"
72
+ ? await (async () => {
73
+ const newCredentials = await refreshChutesTokens({
74
+ credential: cred,
75
+ });
76
+ return { apiKey: newCredentials.access, newCredentials };
77
+ })()
78
+ : String(cred.provider) === "qwen-portal"
79
+ ? await (async () => {
80
+ const newCredentials = await refreshQwenPortalCredentials(cred);
81
+ return { apiKey: newCredentials.access, newCredentials };
82
+ })()
83
+ : await (async () => {
84
+ const oauthProvider = resolveOAuthProvider(cred.provider);
85
+ if (!oauthProvider) {
86
+ return null;
87
+ }
88
+ return await getOAuthApiKey(oauthProvider, oauthCreds);
89
+ })();
90
+ if (!result) {
91
+ return null;
92
+ }
93
+ store.profiles[params.profileId] = {
94
+ ...cred,
95
+ ...result.newCredentials,
96
+ type: "oauth",
97
+ };
98
+ saveAuthProfileStore(store, params.agentDir);
99
+
100
+ return result;
101
+ } finally {
102
+ if (release) {
103
+ try {
104
+ await release();
105
+ } catch {
106
+ // ignore unlock errors
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ async function tryResolveOAuthProfile(params: {
113
+ cfg?: OpenClawConfig;
114
+ store: AuthProfileStore;
115
+ profileId: string;
116
+ agentDir?: string;
117
+ }): Promise<{ apiKey: string; provider: string; email?: string } | null> {
118
+ const { cfg, store, profileId } = params;
119
+ const cred = store.profiles[profileId];
120
+ if (!cred || cred.type !== "oauth") {
121
+ return null;
122
+ }
123
+ const profileConfig = cfg?.auth?.profiles?.[profileId];
124
+ if (profileConfig && profileConfig.provider !== cred.provider) {
125
+ return null;
126
+ }
127
+ if (profileConfig && profileConfig.mode !== cred.type) {
128
+ return null;
129
+ }
130
+
131
+ if (Date.now() < cred.expires) {
132
+ return {
133
+ apiKey: buildOAuthApiKey(cred.provider, cred),
134
+ provider: cred.provider,
135
+ email: cred.email,
136
+ };
137
+ }
138
+
139
+ const refreshed = await refreshOAuthTokenWithLock({
140
+ profileId,
141
+ agentDir: params.agentDir,
142
+ });
143
+ if (!refreshed) {
144
+ return null;
145
+ }
146
+ return {
147
+ apiKey: refreshed.apiKey,
148
+ provider: cred.provider,
149
+ email: cred.email,
150
+ };
151
+ }
152
+
153
+ export async function resolveApiKeyForProfile(params: {
154
+ cfg?: OpenClawConfig;
155
+ store: AuthProfileStore;
156
+ profileId: string;
157
+ agentDir?: string;
158
+ }): Promise<{ apiKey: string; provider: string; email?: string } | null> {
159
+ const { cfg, store, profileId } = params;
160
+ const cred = store.profiles[profileId];
161
+ if (!cred) {
162
+ return null;
163
+ }
164
+ const profileConfig = cfg?.auth?.profiles?.[profileId];
165
+ if (profileConfig && profileConfig.provider !== cred.provider) {
166
+ return null;
167
+ }
168
+ if (profileConfig && profileConfig.mode !== cred.type) {
169
+ // Compatibility: treat "oauth" config as compatible with stored token profiles.
170
+ if (!(profileConfig.mode === "oauth" && cred.type === "token")) {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ if (cred.type === "api_key") {
176
+ return { apiKey: cred.key, provider: cred.provider, email: cred.email };
177
+ }
178
+ if (cred.type === "token") {
179
+ const token = cred.token?.trim();
180
+ if (!token) {
181
+ return null;
182
+ }
183
+ if (
184
+ typeof cred.expires === "number" &&
185
+ Number.isFinite(cred.expires) &&
186
+ cred.expires > 0 &&
187
+ Date.now() >= cred.expires
188
+ ) {
189
+ return null;
190
+ }
191
+ return { apiKey: token, provider: cred.provider, email: cred.email };
192
+ }
193
+ if (Date.now() < cred.expires) {
194
+ return {
195
+ apiKey: buildOAuthApiKey(cred.provider, cred),
196
+ provider: cred.provider,
197
+ email: cred.email,
198
+ };
199
+ }
200
+
201
+ try {
202
+ const result = await refreshOAuthTokenWithLock({
203
+ profileId,
204
+ agentDir: params.agentDir,
205
+ });
206
+ if (!result) {
207
+ return null;
208
+ }
209
+ return {
210
+ apiKey: result.apiKey,
211
+ provider: cred.provider,
212
+ email: cred.email,
213
+ };
214
+ } catch (error) {
215
+ const refreshedStore = ensureAuthProfileStore(params.agentDir);
216
+ const refreshed = refreshedStore.profiles[profileId];
217
+ if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
218
+ return {
219
+ apiKey: buildOAuthApiKey(refreshed.provider, refreshed),
220
+ provider: refreshed.provider,
221
+ email: refreshed.email ?? cred.email,
222
+ };
223
+ }
224
+ const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({
225
+ cfg,
226
+ store: refreshedStore,
227
+ provider: cred.provider,
228
+ legacyProfileId: profileId,
229
+ });
230
+ if (fallbackProfileId && fallbackProfileId !== profileId) {
231
+ try {
232
+ const fallbackResolved = await tryResolveOAuthProfile({
233
+ cfg,
234
+ store: refreshedStore,
235
+ profileId: fallbackProfileId,
236
+ agentDir: params.agentDir,
237
+ });
238
+ if (fallbackResolved) {
239
+ return fallbackResolved;
240
+ }
241
+ } catch {
242
+ // keep original error
243
+ }
244
+ }
245
+
246
+ // Fallback: if this is a secondary agent, try using the main agent's credentials
247
+ if (params.agentDir) {
248
+ try {
249
+ const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
250
+ const mainCred = mainStore.profiles[profileId];
251
+ if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
252
+ // Main agent has fresh credentials - copy them to this agent and use them
253
+ refreshedStore.profiles[profileId] = { ...mainCred };
254
+ saveAuthProfileStore(refreshedStore, params.agentDir);
255
+ log.info("inherited fresh OAuth credentials from main agent", {
256
+ profileId,
257
+ agentDir: params.agentDir,
258
+ expires: new Date(mainCred.expires).toISOString(),
259
+ });
260
+ return {
261
+ apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
262
+ provider: mainCred.provider,
263
+ email: mainCred.email,
264
+ };
265
+ }
266
+ } catch {
267
+ // keep original error if main agent fallback also fails
268
+ }
269
+ }
270
+
271
+ const message = error instanceof Error ? error.message : String(error);
272
+ const hint = formatAuthDoctorHint({
273
+ cfg,
274
+ store: refreshedStore,
275
+ provider: cred.provider,
276
+ profileId,
277
+ });
278
+ throw new Error(
279
+ `OAuth token refresh failed for ${cred.provider}: ${message}. ` +
280
+ "Please try again or re-authenticate." +
281
+ (hint ? `\n\n${hint}` : ""),
282
+ { cause: error },
283
+ );
284
+ }
285
+ }
src/agents/auth-profiles/order.ts ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import type { AuthProfileStore } from "./types.js";
3
+ import { normalizeProviderId } from "../model-selection.js";
4
+ import { listProfilesForProvider } from "./profiles.js";
5
+ import { isProfileInCooldown } from "./usage.js";
6
+
7
+ function resolveProfileUnusableUntil(stats: {
8
+ cooldownUntil?: number;
9
+ disabledUntil?: number;
10
+ }): number | null {
11
+ const values = [stats.cooldownUntil, stats.disabledUntil]
12
+ .filter((value): value is number => typeof value === "number")
13
+ .filter((value) => Number.isFinite(value) && value > 0);
14
+ if (values.length === 0) {
15
+ return null;
16
+ }
17
+ return Math.max(...values);
18
+ }
19
+
20
+ export function resolveAuthProfileOrder(params: {
21
+ cfg?: OpenClawConfig;
22
+ store: AuthProfileStore;
23
+ provider: string;
24
+ preferredProfile?: string;
25
+ }): string[] {
26
+ const { cfg, store, provider, preferredProfile } = params;
27
+ const providerKey = normalizeProviderId(provider);
28
+ const now = Date.now();
29
+ const storedOrder = (() => {
30
+ const order = store.order;
31
+ if (!order) {
32
+ return undefined;
33
+ }
34
+ for (const [key, value] of Object.entries(order)) {
35
+ if (normalizeProviderId(key) === providerKey) {
36
+ return value;
37
+ }
38
+ }
39
+ return undefined;
40
+ })();
41
+ const configuredOrder = (() => {
42
+ const order = cfg?.auth?.order;
43
+ if (!order) {
44
+ return undefined;
45
+ }
46
+ for (const [key, value] of Object.entries(order)) {
47
+ if (normalizeProviderId(key) === providerKey) {
48
+ return value;
49
+ }
50
+ }
51
+ return undefined;
52
+ })();
53
+ const explicitOrder = storedOrder ?? configuredOrder;
54
+ const explicitProfiles = cfg?.auth?.profiles
55
+ ? Object.entries(cfg.auth.profiles)
56
+ .filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
57
+ .map(([profileId]) => profileId)
58
+ : [];
59
+ const baseOrder =
60
+ explicitOrder ??
61
+ (explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
62
+ if (baseOrder.length === 0) {
63
+ return [];
64
+ }
65
+
66
+ const filtered = baseOrder.filter((profileId) => {
67
+ const cred = store.profiles[profileId];
68
+ if (!cred) {
69
+ return false;
70
+ }
71
+ if (normalizeProviderId(cred.provider) !== providerKey) {
72
+ return false;
73
+ }
74
+ const profileConfig = cfg?.auth?.profiles?.[profileId];
75
+ if (profileConfig) {
76
+ if (normalizeProviderId(profileConfig.provider) !== providerKey) {
77
+ return false;
78
+ }
79
+ if (profileConfig.mode !== cred.type) {
80
+ const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
81
+ if (!oauthCompatible) {
82
+ return false;
83
+ }
84
+ }
85
+ }
86
+ if (cred.type === "api_key") {
87
+ return Boolean(cred.key?.trim());
88
+ }
89
+ if (cred.type === "token") {
90
+ if (!cred.token?.trim()) {
91
+ return false;
92
+ }
93
+ if (
94
+ typeof cred.expires === "number" &&
95
+ Number.isFinite(cred.expires) &&
96
+ cred.expires > 0 &&
97
+ now >= cred.expires
98
+ ) {
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ if (cred.type === "oauth") {
104
+ return Boolean(cred.access?.trim() || cred.refresh?.trim());
105
+ }
106
+ return false;
107
+ });
108
+ const deduped: string[] = [];
109
+ for (const entry of filtered) {
110
+ if (!deduped.includes(entry)) {
111
+ deduped.push(entry);
112
+ }
113
+ }
114
+
115
+ // If user specified explicit order (store override or config), respect it
116
+ // exactly, but still apply cooldown sorting to avoid repeatedly selecting
117
+ // known-bad/rate-limited keys as the first candidate.
118
+ if (explicitOrder && explicitOrder.length > 0) {
119
+ // ...but still respect cooldown tracking to avoid repeatedly selecting a
120
+ // known-bad/rate-limited key as the first candidate.
121
+ const available: string[] = [];
122
+ const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
123
+
124
+ for (const profileId of deduped) {
125
+ const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
126
+ if (
127
+ typeof cooldownUntil === "number" &&
128
+ Number.isFinite(cooldownUntil) &&
129
+ cooldownUntil > 0 &&
130
+ now < cooldownUntil
131
+ ) {
132
+ inCooldown.push({ profileId, cooldownUntil });
133
+ } else {
134
+ available.push(profileId);
135
+ }
136
+ }
137
+
138
+ const cooldownSorted = inCooldown
139
+ .toSorted((a, b) => a.cooldownUntil - b.cooldownUntil)
140
+ .map((entry) => entry.profileId);
141
+
142
+ const ordered = [...available, ...cooldownSorted];
143
+
144
+ // Still put preferredProfile first if specified
145
+ if (preferredProfile && ordered.includes(preferredProfile)) {
146
+ return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
147
+ }
148
+ return ordered;
149
+ }
150
+
151
+ // Otherwise, use round-robin: sort by lastUsed (oldest first)
152
+ // preferredProfile goes first if specified (for explicit user choice)
153
+ // lastGood is NOT prioritized - that would defeat round-robin
154
+ const sorted = orderProfilesByMode(deduped, store);
155
+
156
+ if (preferredProfile && sorted.includes(preferredProfile)) {
157
+ return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)];
158
+ }
159
+
160
+ return sorted;
161
+ }
162
+
163
+ function orderProfilesByMode(order: string[], store: AuthProfileStore): string[] {
164
+ const now = Date.now();
165
+
166
+ // Partition into available and in-cooldown
167
+ const available: string[] = [];
168
+ const inCooldown: string[] = [];
169
+
170
+ for (const profileId of order) {
171
+ if (isProfileInCooldown(store, profileId)) {
172
+ inCooldown.push(profileId);
173
+ } else {
174
+ available.push(profileId);
175
+ }
176
+ }
177
+
178
+ // Sort available profiles by lastUsed (oldest first = round-robin)
179
+ // Then by lastUsed (oldest first = round-robin within type)
180
+ const scored = available.map((profileId) => {
181
+ const type = store.profiles[profileId]?.type;
182
+ const typeScore = type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
183
+ const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
184
+ return { profileId, typeScore, lastUsed };
185
+ });
186
+
187
+ // Primary sort: type preference (oauth > token > api_key).
188
+ // Secondary sort: lastUsed (oldest first for round-robin within type).
189
+ const sorted = scored
190
+ .toSorted((a, b) => {
191
+ // First by type (oauth > token > api_key)
192
+ if (a.typeScore !== b.typeScore) {
193
+ return a.typeScore - b.typeScore;
194
+ }
195
+ // Then by lastUsed (oldest first)
196
+ return a.lastUsed - b.lastUsed;
197
+ })
198
+ .map((entry) => entry.profileId);
199
+
200
+ // Append cooldown profiles at the end (sorted by cooldown expiry, soonest first)
201
+ const cooldownSorted = inCooldown
202
+ .map((profileId) => ({
203
+ profileId,
204
+ cooldownUntil: resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
205
+ }))
206
+ .toSorted((a, b) => a.cooldownUntil - b.cooldownUntil)
207
+ .map((entry) => entry.profileId);
208
+
209
+ return [...sorted, ...cooldownSorted];
210
+ }
src/agents/auth-profiles/paths.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { AuthProfileStore } from "./types.js";
4
+ import { saveJsonFile } from "../../infra/json-file.js";
5
+ import { resolveUserPath } from "../../utils.js";
6
+ import { resolveOpenClawAgentDir } from "../agent-paths.js";
7
+ import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
8
+
9
+ export function resolveAuthStorePath(agentDir?: string): string {
10
+ const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir());
11
+ return path.join(resolved, AUTH_PROFILE_FILENAME);
12
+ }
13
+
14
+ export function resolveLegacyAuthStorePath(agentDir?: string): string {
15
+ const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir());
16
+ return path.join(resolved, LEGACY_AUTH_FILENAME);
17
+ }
18
+
19
+ export function resolveAuthStorePathForDisplay(agentDir?: string): string {
20
+ const pathname = resolveAuthStorePath(agentDir);
21
+ return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
22
+ }
23
+
24
+ export function ensureAuthStoreFile(pathname: string) {
25
+ if (fs.existsSync(pathname)) {
26
+ return;
27
+ }
28
+ const payload: AuthProfileStore = {
29
+ version: AUTH_STORE_VERSION,
30
+ profiles: {},
31
+ };
32
+ saveJsonFile(pathname, payload);
33
+ }
src/agents/auth-profiles/profiles.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
2
+ import { normalizeProviderId } from "../model-selection.js";
3
+ import {
4
+ ensureAuthProfileStore,
5
+ saveAuthProfileStore,
6
+ updateAuthProfileStoreWithLock,
7
+ } from "./store.js";
8
+
9
+ export async function setAuthProfileOrder(params: {
10
+ agentDir?: string;
11
+ provider: string;
12
+ order?: string[] | null;
13
+ }): Promise<AuthProfileStore | null> {
14
+ const providerKey = normalizeProviderId(params.provider);
15
+ const sanitized =
16
+ params.order && Array.isArray(params.order)
17
+ ? params.order.map((entry) => String(entry).trim()).filter(Boolean)
18
+ : [];
19
+
20
+ const deduped: string[] = [];
21
+ for (const entry of sanitized) {
22
+ if (!deduped.includes(entry)) {
23
+ deduped.push(entry);
24
+ }
25
+ }
26
+
27
+ return await updateAuthProfileStoreWithLock({
28
+ agentDir: params.agentDir,
29
+ updater: (store) => {
30
+ store.order = store.order ?? {};
31
+ if (deduped.length === 0) {
32
+ if (!store.order[providerKey]) {
33
+ return false;
34
+ }
35
+ delete store.order[providerKey];
36
+ if (Object.keys(store.order).length === 0) {
37
+ store.order = undefined;
38
+ }
39
+ return true;
40
+ }
41
+ store.order[providerKey] = deduped;
42
+ return true;
43
+ },
44
+ });
45
+ }
46
+
47
+ export function upsertAuthProfile(params: {
48
+ profileId: string;
49
+ credential: AuthProfileCredential;
50
+ agentDir?: string;
51
+ }): void {
52
+ const store = ensureAuthProfileStore(params.agentDir);
53
+ store.profiles[params.profileId] = params.credential;
54
+ saveAuthProfileStore(store, params.agentDir);
55
+ }
56
+
57
+ export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
58
+ const providerKey = normalizeProviderId(provider);
59
+ return Object.entries(store.profiles)
60
+ .filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)
61
+ .map(([id]) => id);
62
+ }
63
+
64
+ export async function markAuthProfileGood(params: {
65
+ store: AuthProfileStore;
66
+ provider: string;
67
+ profileId: string;
68
+ agentDir?: string;
69
+ }): Promise<void> {
70
+ const { store, provider, profileId, agentDir } = params;
71
+ const updated = await updateAuthProfileStoreWithLock({
72
+ agentDir,
73
+ updater: (freshStore) => {
74
+ const profile = freshStore.profiles[profileId];
75
+ if (!profile || profile.provider !== provider) {
76
+ return false;
77
+ }
78
+ freshStore.lastGood = { ...freshStore.lastGood, [provider]: profileId };
79
+ return true;
80
+ },
81
+ });
82
+ if (updated) {
83
+ store.lastGood = updated.lastGood;
84
+ return;
85
+ }
86
+ const profile = store.profiles[profileId];
87
+ if (!profile || profile.provider !== provider) {
88
+ return;
89
+ }
90
+ store.lastGood = { ...store.lastGood, [provider]: profileId };
91
+ saveAuthProfileStore(store, agentDir);
92
+ }
src/agents/auth-profiles/repair.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import type { AuthProfileConfig } from "../../config/types.js";
3
+ import type { AuthProfileIdRepairResult, AuthProfileStore } from "./types.js";
4
+ import { normalizeProviderId } from "../model-selection.js";
5
+ import { listProfilesForProvider } from "./profiles.js";
6
+
7
+ function getProfileSuffix(profileId: string): string {
8
+ const idx = profileId.indexOf(":");
9
+ if (idx < 0) {
10
+ return "";
11
+ }
12
+ return profileId.slice(idx + 1);
13
+ }
14
+
15
+ function isEmailLike(value: string): boolean {
16
+ const trimmed = value.trim();
17
+ if (!trimmed) {
18
+ return false;
19
+ }
20
+ return trimmed.includes("@") && trimmed.includes(".");
21
+ }
22
+
23
+ export function suggestOAuthProfileIdForLegacyDefault(params: {
24
+ cfg?: OpenClawConfig;
25
+ store: AuthProfileStore;
26
+ provider: string;
27
+ legacyProfileId: string;
28
+ }): string | null {
29
+ const providerKey = normalizeProviderId(params.provider);
30
+ const legacySuffix = getProfileSuffix(params.legacyProfileId);
31
+ if (legacySuffix !== "default") {
32
+ return null;
33
+ }
34
+
35
+ const legacyCfg = params.cfg?.auth?.profiles?.[params.legacyProfileId];
36
+ if (
37
+ legacyCfg &&
38
+ normalizeProviderId(legacyCfg.provider) === providerKey &&
39
+ legacyCfg.mode !== "oauth"
40
+ ) {
41
+ return null;
42
+ }
43
+
44
+ const oauthProfiles = listProfilesForProvider(params.store, providerKey).filter(
45
+ (id) => params.store.profiles[id]?.type === "oauth",
46
+ );
47
+ if (oauthProfiles.length === 0) {
48
+ return null;
49
+ }
50
+
51
+ const configuredEmail = legacyCfg?.email?.trim();
52
+ if (configuredEmail) {
53
+ const byEmail = oauthProfiles.find((id) => {
54
+ const cred = params.store.profiles[id];
55
+ if (!cred || cred.type !== "oauth") {
56
+ return false;
57
+ }
58
+ const email = cred.email?.trim();
59
+ return email === configuredEmail || id === `${providerKey}:${configuredEmail}`;
60
+ });
61
+ if (byEmail) {
62
+ return byEmail;
63
+ }
64
+ }
65
+
66
+ const lastGood = params.store.lastGood?.[providerKey] ?? params.store.lastGood?.[params.provider];
67
+ if (lastGood && oauthProfiles.includes(lastGood)) {
68
+ return lastGood;
69
+ }
70
+
71
+ const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId);
72
+ if (nonLegacy.length === 1) {
73
+ return nonLegacy[0] ?? null;
74
+ }
75
+
76
+ const emailLike = nonLegacy.filter((id) => isEmailLike(getProfileSuffix(id)));
77
+ if (emailLike.length === 1) {
78
+ return emailLike[0] ?? null;
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ export function repairOAuthProfileIdMismatch(params: {
85
+ cfg: OpenClawConfig;
86
+ store: AuthProfileStore;
87
+ provider: string;
88
+ legacyProfileId?: string;
89
+ }): AuthProfileIdRepairResult {
90
+ const legacyProfileId =
91
+ params.legacyProfileId ?? `${normalizeProviderId(params.provider)}:default`;
92
+ const legacyCfg = params.cfg.auth?.profiles?.[legacyProfileId];
93
+ if (!legacyCfg) {
94
+ return { config: params.cfg, changes: [], migrated: false };
95
+ }
96
+ if (legacyCfg.mode !== "oauth") {
97
+ return { config: params.cfg, changes: [], migrated: false };
98
+ }
99
+ if (normalizeProviderId(legacyCfg.provider) !== normalizeProviderId(params.provider)) {
100
+ return { config: params.cfg, changes: [], migrated: false };
101
+ }
102
+
103
+ const toProfileId = suggestOAuthProfileIdForLegacyDefault({
104
+ cfg: params.cfg,
105
+ store: params.store,
106
+ provider: params.provider,
107
+ legacyProfileId,
108
+ });
109
+ if (!toProfileId || toProfileId === legacyProfileId) {
110
+ return { config: params.cfg, changes: [], migrated: false };
111
+ }
112
+
113
+ const toCred = params.store.profiles[toProfileId];
114
+ const toEmail = toCred?.type === "oauth" ? toCred.email?.trim() : undefined;
115
+
116
+ const nextProfiles = {
117
+ ...params.cfg.auth?.profiles,
118
+ } as Record<string, AuthProfileConfig>;
119
+ delete nextProfiles[legacyProfileId];
120
+ nextProfiles[toProfileId] = {
121
+ ...legacyCfg,
122
+ ...(toEmail ? { email: toEmail } : {}),
123
+ };
124
+
125
+ const providerKey = normalizeProviderId(params.provider);
126
+ const nextOrder = (() => {
127
+ const order = params.cfg.auth?.order;
128
+ if (!order) {
129
+ return undefined;
130
+ }
131
+ const resolvedKey = Object.keys(order).find((key) => normalizeProviderId(key) === providerKey);
132
+ if (!resolvedKey) {
133
+ return order;
134
+ }
135
+ const existing = order[resolvedKey];
136
+ if (!Array.isArray(existing)) {
137
+ return order;
138
+ }
139
+ const replaced = existing
140
+ .map((id) => (id === legacyProfileId ? toProfileId : id))
141
+ .filter((id): id is string => typeof id === "string" && id.trim().length > 0);
142
+ const deduped: string[] = [];
143
+ for (const entry of replaced) {
144
+ if (!deduped.includes(entry)) {
145
+ deduped.push(entry);
146
+ }
147
+ }
148
+ return { ...order, [resolvedKey]: deduped };
149
+ })();
150
+
151
+ const nextCfg: OpenClawConfig = {
152
+ ...params.cfg,
153
+ auth: {
154
+ ...params.cfg.auth,
155
+ profiles: nextProfiles,
156
+ ...(nextOrder ? { order: nextOrder } : {}),
157
+ },
158
+ };
159
+
160
+ const changes = [`Auth: migrate ${legacyProfileId} → ${toProfileId} (OAuth profile id)`];
161
+
162
+ return {
163
+ config: nextCfg,
164
+ changes,
165
+ migrated: true,
166
+ fromProfileId: legacyProfileId,
167
+ toProfileId,
168
+ };
169
+ }
src/agents/auth-profiles/session-override.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { OpenClawConfig } from "../../config/config.js";
6
+ import type { SessionEntry } from "../../config/sessions.js";
7
+ import { resolveSessionAuthProfileOverride } from "./session-override.js";
8
+
9
+ async function writeAuthStore(agentDir: string) {
10
+ const authPath = path.join(agentDir, "auth-profiles.json");
11
+ const payload = {
12
+ version: 1,
13
+ profiles: {
14
+ "zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
15
+ },
16
+ order: {
17
+ zai: ["zai:work"],
18
+ },
19
+ };
20
+ await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
21
+ }
22
+
23
+ describe("resolveSessionAuthProfileOverride", () => {
24
+ it("keeps user override when provider alias differs", async () => {
25
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
26
+ const prevStateDir = process.env.OPENCLAW_STATE_DIR;
27
+ process.env.OPENCLAW_STATE_DIR = tmpDir;
28
+ try {
29
+ const agentDir = path.join(tmpDir, "agent");
30
+ await fs.mkdir(agentDir, { recursive: true });
31
+ await writeAuthStore(agentDir);
32
+
33
+ const sessionEntry: SessionEntry = {
34
+ sessionId: "s1",
35
+ updatedAt: Date.now(),
36
+ authProfileOverride: "zai:work",
37
+ authProfileOverrideSource: "user",
38
+ };
39
+ const sessionStore = { "agent:main:main": sessionEntry };
40
+
41
+ const resolved = await resolveSessionAuthProfileOverride({
42
+ cfg: {} as OpenClawConfig,
43
+ provider: "z.ai",
44
+ agentDir,
45
+ sessionEntry,
46
+ sessionStore,
47
+ sessionKey: "agent:main:main",
48
+ storePath: undefined,
49
+ isNewSession: false,
50
+ });
51
+
52
+ expect(resolved).toBe("zai:work");
53
+ expect(sessionEntry.authProfileOverride).toBe("zai:work");
54
+ } finally {
55
+ if (prevStateDir === undefined) {
56
+ delete process.env.OPENCLAW_STATE_DIR;
57
+ } else {
58
+ process.env.OPENCLAW_STATE_DIR = prevStateDir;
59
+ }
60
+ await fs.rm(tmpDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
src/agents/auth-profiles/session-override.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
3
+ import {
4
+ ensureAuthProfileStore,
5
+ isProfileInCooldown,
6
+ resolveAuthProfileOrder,
7
+ } from "../auth-profiles.js";
8
+ import { normalizeProviderId } from "../model-selection.js";
9
+
10
+ function isProfileForProvider(params: {
11
+ provider: string;
12
+ profileId: string;
13
+ store: ReturnType<typeof ensureAuthProfileStore>;
14
+ }): boolean {
15
+ const entry = params.store.profiles[params.profileId];
16
+ if (!entry?.provider) {
17
+ return false;
18
+ }
19
+ return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
20
+ }
21
+
22
+ export async function clearSessionAuthProfileOverride(params: {
23
+ sessionEntry: SessionEntry;
24
+ sessionStore: Record<string, SessionEntry>;
25
+ sessionKey: string;
26
+ storePath?: string;
27
+ }) {
28
+ const { sessionEntry, sessionStore, sessionKey, storePath } = params;
29
+ delete sessionEntry.authProfileOverride;
30
+ delete sessionEntry.authProfileOverrideSource;
31
+ delete sessionEntry.authProfileOverrideCompactionCount;
32
+ sessionEntry.updatedAt = Date.now();
33
+ sessionStore[sessionKey] = sessionEntry;
34
+ if (storePath) {
35
+ await updateSessionStore(storePath, (store) => {
36
+ store[sessionKey] = sessionEntry;
37
+ });
38
+ }
39
+ }
40
+
41
+ export async function resolveSessionAuthProfileOverride(params: {
42
+ cfg: OpenClawConfig;
43
+ provider: string;
44
+ agentDir: string;
45
+ sessionEntry?: SessionEntry;
46
+ sessionStore?: Record<string, SessionEntry>;
47
+ sessionKey?: string;
48
+ storePath?: string;
49
+ isNewSession: boolean;
50
+ }): Promise<string | undefined> {
51
+ const {
52
+ cfg,
53
+ provider,
54
+ agentDir,
55
+ sessionEntry,
56
+ sessionStore,
57
+ sessionKey,
58
+ storePath,
59
+ isNewSession,
60
+ } = params;
61
+ if (!sessionEntry || !sessionStore || !sessionKey) {
62
+ return sessionEntry?.authProfileOverride;
63
+ }
64
+
65
+ const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
66
+ const order = resolveAuthProfileOrder({ cfg, store, provider });
67
+ let current = sessionEntry.authProfileOverride?.trim();
68
+
69
+ if (current && !store.profiles[current]) {
70
+ await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
71
+ current = undefined;
72
+ }
73
+
74
+ if (current && !isProfileForProvider({ provider, profileId: current, store })) {
75
+ await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
76
+ current = undefined;
77
+ }
78
+
79
+ if (current && order.length > 0 && !order.includes(current)) {
80
+ await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
81
+ current = undefined;
82
+ }
83
+
84
+ if (order.length === 0) {
85
+ return undefined;
86
+ }
87
+
88
+ const pickFirstAvailable = () =>
89
+ order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
90
+ const pickNextAvailable = (active: string) => {
91
+ const startIndex = order.indexOf(active);
92
+ if (startIndex < 0) {
93
+ return pickFirstAvailable();
94
+ }
95
+ for (let offset = 1; offset <= order.length; offset += 1) {
96
+ const candidate = order[(startIndex + offset) % order.length];
97
+ if (!isProfileInCooldown(store, candidate)) {
98
+ return candidate;
99
+ }
100
+ }
101
+ return order[startIndex] ?? order[0];
102
+ };
103
+
104
+ const compactionCount = sessionEntry.compactionCount ?? 0;
105
+ const storedCompaction =
106
+ typeof sessionEntry.authProfileOverrideCompactionCount === "number"
107
+ ? sessionEntry.authProfileOverrideCompactionCount
108
+ : compactionCount;
109
+
110
+ const source =
111
+ sessionEntry.authProfileOverrideSource ??
112
+ (typeof sessionEntry.authProfileOverrideCompactionCount === "number"
113
+ ? "auto"
114
+ : current
115
+ ? "user"
116
+ : undefined);
117
+ if (source === "user" && current && !isNewSession) {
118
+ return current;
119
+ }
120
+
121
+ let next = current;
122
+ if (isNewSession) {
123
+ next = current ? pickNextAvailable(current) : pickFirstAvailable();
124
+ } else if (current && compactionCount > storedCompaction) {
125
+ next = pickNextAvailable(current);
126
+ } else if (!current || isProfileInCooldown(store, current)) {
127
+ next = pickFirstAvailable();
128
+ }
129
+
130
+ if (!next) {
131
+ return current;
132
+ }
133
+ const shouldPersist =
134
+ next !== sessionEntry.authProfileOverride ||
135
+ sessionEntry.authProfileOverrideSource !== "auto" ||
136
+ sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
137
+ if (shouldPersist) {
138
+ sessionEntry.authProfileOverride = next;
139
+ sessionEntry.authProfileOverrideSource = "auto";
140
+ sessionEntry.authProfileOverrideCompactionCount = compactionCount;
141
+ sessionEntry.updatedAt = Date.now();
142
+ sessionStore[sessionKey] = sessionEntry;
143
+ if (storePath) {
144
+ await updateSessionStore(storePath, (store) => {
145
+ store[sessionKey] = sessionEntry;
146
+ });
147
+ }
148
+ }
149
+
150
+ return next;
151
+ }
src/agents/auth-profiles/store.ts ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OAuthCredentials } from "@mariozechner/pi-ai";
2
+ import fs from "node:fs";
3
+ import lockfile from "proper-lockfile";
4
+ import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
5
+ import { resolveOAuthPath } from "../../config/paths.js";
6
+ import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
7
+ import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
8
+ import { syncExternalCliCredentials } from "./external-cli-sync.js";
9
+ import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
10
+
11
+ type LegacyAuthStore = Record<string, AuthProfileCredential>;
12
+
13
+ function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void {
14
+ target.version = source.version;
15
+ target.profiles = source.profiles;
16
+ target.order = source.order;
17
+ target.lastGood = source.lastGood;
18
+ target.usageStats = source.usageStats;
19
+ }
20
+
21
+ export async function updateAuthProfileStoreWithLock(params: {
22
+ agentDir?: string;
23
+ updater: (store: AuthProfileStore) => boolean;
24
+ }): Promise<AuthProfileStore | null> {
25
+ const authPath = resolveAuthStorePath(params.agentDir);
26
+ ensureAuthStoreFile(authPath);
27
+
28
+ let release: (() => Promise<void>) | undefined;
29
+ try {
30
+ release = await lockfile.lock(authPath, AUTH_STORE_LOCK_OPTIONS);
31
+ const store = ensureAuthProfileStore(params.agentDir);
32
+ const shouldSave = params.updater(store);
33
+ if (shouldSave) {
34
+ saveAuthProfileStore(store, params.agentDir);
35
+ }
36
+ return store;
37
+ } catch {
38
+ return null;
39
+ } finally {
40
+ if (release) {
41
+ try {
42
+ await release();
43
+ } catch {
44
+ // ignore unlock errors
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
51
+ if (!raw || typeof raw !== "object") {
52
+ return null;
53
+ }
54
+ const record = raw as Record<string, unknown>;
55
+ if ("profiles" in record) {
56
+ return null;
57
+ }
58
+ const entries: LegacyAuthStore = {};
59
+ for (const [key, value] of Object.entries(record)) {
60
+ if (!value || typeof value !== "object") {
61
+ continue;
62
+ }
63
+ const typed = value as Partial<AuthProfileCredential>;
64
+ if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
65
+ continue;
66
+ }
67
+ entries[key] = {
68
+ ...typed,
69
+ provider: String(typed.provider ?? key),
70
+ } as AuthProfileCredential;
71
+ }
72
+ return Object.keys(entries).length > 0 ? entries : null;
73
+ }
74
+
75
+ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
76
+ if (!raw || typeof raw !== "object") {
77
+ return null;
78
+ }
79
+ const record = raw as Record<string, unknown>;
80
+ if (!record.profiles || typeof record.profiles !== "object") {
81
+ return null;
82
+ }
83
+ const profiles = record.profiles as Record<string, unknown>;
84
+ const normalized: Record<string, AuthProfileCredential> = {};
85
+ for (const [key, value] of Object.entries(profiles)) {
86
+ if (!value || typeof value !== "object") {
87
+ continue;
88
+ }
89
+ const typed = value as Partial<AuthProfileCredential>;
90
+ if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
91
+ continue;
92
+ }
93
+ if (!typed.provider) {
94
+ continue;
95
+ }
96
+ normalized[key] = typed as AuthProfileCredential;
97
+ }
98
+ const order =
99
+ record.order && typeof record.order === "object"
100
+ ? Object.entries(record.order as Record<string, unknown>).reduce(
101
+ (acc, [provider, value]) => {
102
+ if (!Array.isArray(value)) {
103
+ return acc;
104
+ }
105
+ const list = value
106
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
107
+ .filter(Boolean);
108
+ if (list.length === 0) {
109
+ return acc;
110
+ }
111
+ acc[provider] = list;
112
+ return acc;
113
+ },
114
+ {} as Record<string, string[]>,
115
+ )
116
+ : undefined;
117
+ return {
118
+ version: Number(record.version ?? AUTH_STORE_VERSION),
119
+ profiles: normalized,
120
+ order,
121
+ lastGood:
122
+ record.lastGood && typeof record.lastGood === "object"
123
+ ? (record.lastGood as Record<string, string>)
124
+ : undefined,
125
+ usageStats:
126
+ record.usageStats && typeof record.usageStats === "object"
127
+ ? (record.usageStats as Record<string, ProfileUsageStats>)
128
+ : undefined,
129
+ };
130
+ }
131
+
132
+ function mergeRecord<T>(
133
+ base?: Record<string, T>,
134
+ override?: Record<string, T>,
135
+ ): Record<string, T> | undefined {
136
+ if (!base && !override) {
137
+ return undefined;
138
+ }
139
+ if (!base) {
140
+ return { ...override };
141
+ }
142
+ if (!override) {
143
+ return { ...base };
144
+ }
145
+ return { ...base, ...override };
146
+ }
147
+
148
+ function mergeAuthProfileStores(
149
+ base: AuthProfileStore,
150
+ override: AuthProfileStore,
151
+ ): AuthProfileStore {
152
+ if (
153
+ Object.keys(override.profiles).length === 0 &&
154
+ !override.order &&
155
+ !override.lastGood &&
156
+ !override.usageStats
157
+ ) {
158
+ return base;
159
+ }
160
+ return {
161
+ version: Math.max(base.version, override.version ?? base.version),
162
+ profiles: { ...base.profiles, ...override.profiles },
163
+ order: mergeRecord(base.order, override.order),
164
+ lastGood: mergeRecord(base.lastGood, override.lastGood),
165
+ usageStats: mergeRecord(base.usageStats, override.usageStats),
166
+ };
167
+ }
168
+
169
+ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
170
+ const oauthPath = resolveOAuthPath();
171
+ const oauthRaw = loadJsonFile(oauthPath);
172
+ if (!oauthRaw || typeof oauthRaw !== "object") {
173
+ return false;
174
+ }
175
+ const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
176
+ let mutated = false;
177
+ for (const [provider, creds] of Object.entries(oauthEntries)) {
178
+ if (!creds || typeof creds !== "object") {
179
+ continue;
180
+ }
181
+ const profileId = `${provider}:default`;
182
+ if (store.profiles[profileId]) {
183
+ continue;
184
+ }
185
+ store.profiles[profileId] = {
186
+ type: "oauth",
187
+ provider,
188
+ ...creds,
189
+ };
190
+ mutated = true;
191
+ }
192
+ return mutated;
193
+ }
194
+
195
+ export function loadAuthProfileStore(): AuthProfileStore {
196
+ const authPath = resolveAuthStorePath();
197
+ const raw = loadJsonFile(authPath);
198
+ const asStore = coerceAuthStore(raw);
199
+ if (asStore) {
200
+ // Sync from external CLI tools on every load
201
+ const synced = syncExternalCliCredentials(asStore);
202
+ if (synced) {
203
+ saveJsonFile(authPath, asStore);
204
+ }
205
+ return asStore;
206
+ }
207
+
208
+ const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
209
+ const legacy = coerceLegacyStore(legacyRaw);
210
+ if (legacy) {
211
+ const store: AuthProfileStore = {
212
+ version: AUTH_STORE_VERSION,
213
+ profiles: {},
214
+ };
215
+ for (const [provider, cred] of Object.entries(legacy)) {
216
+ const profileId = `${provider}:default`;
217
+ if (cred.type === "api_key") {
218
+ store.profiles[profileId] = {
219
+ type: "api_key",
220
+ provider: String(cred.provider ?? provider),
221
+ key: cred.key,
222
+ ...(cred.email ? { email: cred.email } : {}),
223
+ };
224
+ } else if (cred.type === "token") {
225
+ store.profiles[profileId] = {
226
+ type: "token",
227
+ provider: String(cred.provider ?? provider),
228
+ token: cred.token,
229
+ ...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
230
+ ...(cred.email ? { email: cred.email } : {}),
231
+ };
232
+ } else {
233
+ store.profiles[profileId] = {
234
+ type: "oauth",
235
+ provider: String(cred.provider ?? provider),
236
+ access: cred.access,
237
+ refresh: cred.refresh,
238
+ expires: cred.expires,
239
+ ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
240
+ ...(cred.projectId ? { projectId: cred.projectId } : {}),
241
+ ...(cred.accountId ? { accountId: cred.accountId } : {}),
242
+ ...(cred.email ? { email: cred.email } : {}),
243
+ };
244
+ }
245
+ }
246
+ syncExternalCliCredentials(store);
247
+ return store;
248
+ }
249
+
250
+ const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
251
+ syncExternalCliCredentials(store);
252
+ return store;
253
+ }
254
+
255
+ function loadAuthProfileStoreForAgent(
256
+ agentDir?: string,
257
+ _options?: { allowKeychainPrompt?: boolean },
258
+ ): AuthProfileStore {
259
+ const authPath = resolveAuthStorePath(agentDir);
260
+ const raw = loadJsonFile(authPath);
261
+ const asStore = coerceAuthStore(raw);
262
+ if (asStore) {
263
+ // Sync from external CLI tools on every load
264
+ const synced = syncExternalCliCredentials(asStore);
265
+ if (synced) {
266
+ saveJsonFile(authPath, asStore);
267
+ }
268
+ return asStore;
269
+ }
270
+
271
+ // Fallback: inherit auth-profiles from main agent if subagent has none
272
+ if (agentDir) {
273
+ const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
274
+ const mainRaw = loadJsonFile(mainAuthPath);
275
+ const mainStore = coerceAuthStore(mainRaw);
276
+ if (mainStore && Object.keys(mainStore.profiles).length > 0) {
277
+ // Clone main store to subagent directory for auth inheritance
278
+ saveJsonFile(authPath, mainStore);
279
+ log.info("inherited auth-profiles from main agent", { agentDir });
280
+ return mainStore;
281
+ }
282
+ }
283
+
284
+ const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
285
+ const legacy = coerceLegacyStore(legacyRaw);
286
+ const store: AuthProfileStore = {
287
+ version: AUTH_STORE_VERSION,
288
+ profiles: {},
289
+ };
290
+ if (legacy) {
291
+ for (const [provider, cred] of Object.entries(legacy)) {
292
+ const profileId = `${provider}:default`;
293
+ if (cred.type === "api_key") {
294
+ store.profiles[profileId] = {
295
+ type: "api_key",
296
+ provider: String(cred.provider ?? provider),
297
+ key: cred.key,
298
+ ...(cred.email ? { email: cred.email } : {}),
299
+ };
300
+ } else if (cred.type === "token") {
301
+ store.profiles[profileId] = {
302
+ type: "token",
303
+ provider: String(cred.provider ?? provider),
304
+ token: cred.token,
305
+ ...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
306
+ ...(cred.email ? { email: cred.email } : {}),
307
+ };
308
+ } else {
309
+ store.profiles[profileId] = {
310
+ type: "oauth",
311
+ provider: String(cred.provider ?? provider),
312
+ access: cred.access,
313
+ refresh: cred.refresh,
314
+ expires: cred.expires,
315
+ ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
316
+ ...(cred.projectId ? { projectId: cred.projectId } : {}),
317
+ ...(cred.accountId ? { accountId: cred.accountId } : {}),
318
+ ...(cred.email ? { email: cred.email } : {}),
319
+ };
320
+ }
321
+ }
322
+ }
323
+
324
+ const mergedOAuth = mergeOAuthFileIntoStore(store);
325
+ const syncedCli = syncExternalCliCredentials(store);
326
+ const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
327
+ if (shouldWrite) {
328
+ saveJsonFile(authPath, store);
329
+ }
330
+
331
+ // PR #368: legacy auth.json could get re-migrated from other agent dirs,
332
+ // overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
333
+ // after we've successfully written auth-profiles.json.
334
+ if (shouldWrite && legacy !== null) {
335
+ const legacyPath = resolveLegacyAuthStorePath(agentDir);
336
+ try {
337
+ fs.unlinkSync(legacyPath);
338
+ } catch (err) {
339
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
340
+ log.warn("failed to delete legacy auth.json after migration", {
341
+ err,
342
+ legacyPath,
343
+ });
344
+ }
345
+ }
346
+ }
347
+
348
+ return store;
349
+ }
350
+
351
+ export function ensureAuthProfileStore(
352
+ agentDir?: string,
353
+ options?: { allowKeychainPrompt?: boolean },
354
+ ): AuthProfileStore {
355
+ const store = loadAuthProfileStoreForAgent(agentDir, options);
356
+ const authPath = resolveAuthStorePath(agentDir);
357
+ const mainAuthPath = resolveAuthStorePath();
358
+ if (!agentDir || authPath === mainAuthPath) {
359
+ return store;
360
+ }
361
+
362
+ const mainStore = loadAuthProfileStoreForAgent(undefined, options);
363
+ const merged = mergeAuthProfileStores(mainStore, store);
364
+
365
+ return merged;
366
+ }
367
+
368
+ export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
369
+ const authPath = resolveAuthStorePath(agentDir);
370
+ const payload = {
371
+ version: AUTH_STORE_VERSION,
372
+ profiles: store.profiles,
373
+ order: store.order ?? undefined,
374
+ lastGood: store.lastGood ?? undefined,
375
+ usageStats: store.usageStats ?? undefined,
376
+ } satisfies AuthProfileStore;
377
+ saveJsonFile(authPath, payload);
378
+ }
src/agents/auth-profiles/types.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OAuthCredentials } from "@mariozechner/pi-ai";
2
+ import type { OpenClawConfig } from "../../config/config.js";
3
+
4
+ export type ApiKeyCredential = {
5
+ type: "api_key";
6
+ provider: string;
7
+ key: string;
8
+ email?: string;
9
+ };
10
+
11
+ export type TokenCredential = {
12
+ /**
13
+ * Static bearer-style token (often OAuth access token / PAT).
14
+ * Not refreshable by OpenClaw (unlike `type: "oauth"`).
15
+ */
16
+ type: "token";
17
+ provider: string;
18
+ token: string;
19
+ /** Optional expiry timestamp (ms since epoch). */
20
+ expires?: number;
21
+ email?: string;
22
+ };
23
+
24
+ export type OAuthCredential = OAuthCredentials & {
25
+ type: "oauth";
26
+ provider: string;
27
+ clientId?: string;
28
+ email?: string;
29
+ };
30
+
31
+ export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential;
32
+
33
+ export type AuthProfileFailureReason =
34
+ | "auth"
35
+ | "format"
36
+ | "rate_limit"
37
+ | "billing"
38
+ | "timeout"
39
+ | "unknown";
40
+
41
+ /** Per-profile usage statistics for round-robin and cooldown tracking */
42
+ export type ProfileUsageStats = {
43
+ lastUsed?: number;
44
+ cooldownUntil?: number;
45
+ disabledUntil?: number;
46
+ disabledReason?: AuthProfileFailureReason;
47
+ errorCount?: number;
48
+ failureCounts?: Partial<Record<AuthProfileFailureReason, number>>;
49
+ lastFailureAt?: number;
50
+ };
51
+
52
+ export type AuthProfileStore = {
53
+ version: number;
54
+ profiles: Record<string, AuthProfileCredential>;
55
+ /**
56
+ * Optional per-agent preferred profile order overrides.
57
+ * This lets you lock/override auth rotation for a specific agent without
58
+ * changing the global config.
59
+ */
60
+ order?: Record<string, string[]>;
61
+ lastGood?: Record<string, string>;
62
+ /** Usage statistics per profile for round-robin rotation */
63
+ usageStats?: Record<string, ProfileUsageStats>;
64
+ };
65
+
66
+ export type AuthProfileIdRepairResult = {
67
+ config: OpenClawConfig;
68
+ changes: string[];
69
+ migrated: boolean;
70
+ fromProfileId?: string;
71
+ toProfileId?: string;
72
+ };
src/agents/auth-profiles/usage.ts ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { OpenClawConfig } from "../../config/config.js";
2
+ import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
3
+ import { normalizeProviderId } from "../model-selection.js";
4
+ import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
5
+
6
+ function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
7
+ const values = [stats.cooldownUntil, stats.disabledUntil]
8
+ .filter((value): value is number => typeof value === "number")
9
+ .filter((value) => Number.isFinite(value) && value > 0);
10
+ if (values.length === 0) {
11
+ return null;
12
+ }
13
+ return Math.max(...values);
14
+ }
15
+
16
+ /**
17
+ * Check if a profile is currently in cooldown (due to rate limiting or errors).
18
+ */
19
+ export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
20
+ const stats = store.usageStats?.[profileId];
21
+ if (!stats) {
22
+ return false;
23
+ }
24
+ const unusableUntil = resolveProfileUnusableUntil(stats);
25
+ return unusableUntil ? Date.now() < unusableUntil : false;
26
+ }
27
+
28
+ /**
29
+ * Mark a profile as successfully used. Resets error count and updates lastUsed.
30
+ * Uses store lock to avoid overwriting concurrent usage updates.
31
+ */
32
+ export async function markAuthProfileUsed(params: {
33
+ store: AuthProfileStore;
34
+ profileId: string;
35
+ agentDir?: string;
36
+ }): Promise<void> {
37
+ const { store, profileId, agentDir } = params;
38
+ const updated = await updateAuthProfileStoreWithLock({
39
+ agentDir,
40
+ updater: (freshStore) => {
41
+ if (!freshStore.profiles[profileId]) {
42
+ return false;
43
+ }
44
+ freshStore.usageStats = freshStore.usageStats ?? {};
45
+ freshStore.usageStats[profileId] = {
46
+ ...freshStore.usageStats[profileId],
47
+ lastUsed: Date.now(),
48
+ errorCount: 0,
49
+ cooldownUntil: undefined,
50
+ disabledUntil: undefined,
51
+ disabledReason: undefined,
52
+ failureCounts: undefined,
53
+ };
54
+ return true;
55
+ },
56
+ });
57
+ if (updated) {
58
+ store.usageStats = updated.usageStats;
59
+ return;
60
+ }
61
+ if (!store.profiles[profileId]) {
62
+ return;
63
+ }
64
+
65
+ store.usageStats = store.usageStats ?? {};
66
+ store.usageStats[profileId] = {
67
+ ...store.usageStats[profileId],
68
+ lastUsed: Date.now(),
69
+ errorCount: 0,
70
+ cooldownUntil: undefined,
71
+ disabledUntil: undefined,
72
+ disabledReason: undefined,
73
+ failureCounts: undefined,
74
+ };
75
+ saveAuthProfileStore(store, agentDir);
76
+ }
77
+
78
+ export function calculateAuthProfileCooldownMs(errorCount: number): number {
79
+ const normalized = Math.max(1, errorCount);
80
+ return Math.min(
81
+ 60 * 60 * 1000, // 1 hour max
82
+ 60 * 1000 * 5 ** Math.min(normalized - 1, 3),
83
+ );
84
+ }
85
+
86
+ type ResolvedAuthCooldownConfig = {
87
+ billingBackoffMs: number;
88
+ billingMaxMs: number;
89
+ failureWindowMs: number;
90
+ };
91
+
92
+ function resolveAuthCooldownConfig(params: {
93
+ cfg?: OpenClawConfig;
94
+ providerId: string;
95
+ }): ResolvedAuthCooldownConfig {
96
+ const defaults = {
97
+ billingBackoffHours: 5,
98
+ billingMaxHours: 24,
99
+ failureWindowHours: 24,
100
+ } as const;
101
+
102
+ const resolveHours = (value: unknown, fallback: number) =>
103
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
104
+
105
+ const cooldowns = params.cfg?.auth?.cooldowns;
106
+ const billingOverride = (() => {
107
+ const map = cooldowns?.billingBackoffHoursByProvider;
108
+ if (!map) {
109
+ return undefined;
110
+ }
111
+ for (const [key, value] of Object.entries(map)) {
112
+ if (normalizeProviderId(key) === params.providerId) {
113
+ return value;
114
+ }
115
+ }
116
+ return undefined;
117
+ })();
118
+
119
+ const billingBackoffHours = resolveHours(
120
+ billingOverride ?? cooldowns?.billingBackoffHours,
121
+ defaults.billingBackoffHours,
122
+ );
123
+ const billingMaxHours = resolveHours(cooldowns?.billingMaxHours, defaults.billingMaxHours);
124
+ const failureWindowHours = resolveHours(
125
+ cooldowns?.failureWindowHours,
126
+ defaults.failureWindowHours,
127
+ );
128
+
129
+ return {
130
+ billingBackoffMs: billingBackoffHours * 60 * 60 * 1000,
131
+ billingMaxMs: billingMaxHours * 60 * 60 * 1000,
132
+ failureWindowMs: failureWindowHours * 60 * 60 * 1000,
133
+ };
134
+ }
135
+
136
+ function calculateAuthProfileBillingDisableMsWithConfig(params: {
137
+ errorCount: number;
138
+ baseMs: number;
139
+ maxMs: number;
140
+ }): number {
141
+ const normalized = Math.max(1, params.errorCount);
142
+ const baseMs = Math.max(60_000, params.baseMs);
143
+ const maxMs = Math.max(baseMs, params.maxMs);
144
+ const exponent = Math.min(normalized - 1, 10);
145
+ const raw = baseMs * 2 ** exponent;
146
+ return Math.min(maxMs, raw);
147
+ }
148
+
149
+ export function resolveProfileUnusableUntilForDisplay(
150
+ store: AuthProfileStore,
151
+ profileId: string,
152
+ ): number | null {
153
+ const stats = store.usageStats?.[profileId];
154
+ if (!stats) {
155
+ return null;
156
+ }
157
+ return resolveProfileUnusableUntil(stats);
158
+ }
159
+
160
+ function computeNextProfileUsageStats(params: {
161
+ existing: ProfileUsageStats;
162
+ now: number;
163
+ reason: AuthProfileFailureReason;
164
+ cfgResolved: ResolvedAuthCooldownConfig;
165
+ }): ProfileUsageStats {
166
+ const windowMs = params.cfgResolved.failureWindowMs;
167
+ const windowExpired =
168
+ typeof params.existing.lastFailureAt === "number" &&
169
+ params.existing.lastFailureAt > 0 &&
170
+ params.now - params.existing.lastFailureAt > windowMs;
171
+
172
+ const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
173
+ const nextErrorCount = baseErrorCount + 1;
174
+ const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
175
+ failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
176
+
177
+ const updatedStats: ProfileUsageStats = {
178
+ ...params.existing,
179
+ errorCount: nextErrorCount,
180
+ failureCounts,
181
+ lastFailureAt: params.now,
182
+ };
183
+
184
+ if (params.reason === "billing") {
185
+ const billingCount = failureCounts.billing ?? 1;
186
+ const backoffMs = calculateAuthProfileBillingDisableMsWithConfig({
187
+ errorCount: billingCount,
188
+ baseMs: params.cfgResolved.billingBackoffMs,
189
+ maxMs: params.cfgResolved.billingMaxMs,
190
+ });
191
+ updatedStats.disabledUntil = params.now + backoffMs;
192
+ updatedStats.disabledReason = "billing";
193
+ } else {
194
+ const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount);
195
+ updatedStats.cooldownUntil = params.now + backoffMs;
196
+ }
197
+
198
+ return updatedStats;
199
+ }
200
+
201
+ /**
202
+ * Mark a profile as failed for a specific reason. Billing failures are treated
203
+ * as "disabled" (longer backoff) vs the regular cooldown window.
204
+ */
205
+ export async function markAuthProfileFailure(params: {
206
+ store: AuthProfileStore;
207
+ profileId: string;
208
+ reason: AuthProfileFailureReason;
209
+ cfg?: OpenClawConfig;
210
+ agentDir?: string;
211
+ }): Promise<void> {
212
+ const { store, profileId, reason, agentDir, cfg } = params;
213
+ const updated = await updateAuthProfileStoreWithLock({
214
+ agentDir,
215
+ updater: (freshStore) => {
216
+ const profile = freshStore.profiles[profileId];
217
+ if (!profile) {
218
+ return false;
219
+ }
220
+ freshStore.usageStats = freshStore.usageStats ?? {};
221
+ const existing = freshStore.usageStats[profileId] ?? {};
222
+
223
+ const now = Date.now();
224
+ const providerKey = normalizeProviderId(profile.provider);
225
+ const cfgResolved = resolveAuthCooldownConfig({
226
+ cfg,
227
+ providerId: providerKey,
228
+ });
229
+
230
+ freshStore.usageStats[profileId] = computeNextProfileUsageStats({
231
+ existing,
232
+ now,
233
+ reason,
234
+ cfgResolved,
235
+ });
236
+ return true;
237
+ },
238
+ });
239
+ if (updated) {
240
+ store.usageStats = updated.usageStats;
241
+ return;
242
+ }
243
+ if (!store.profiles[profileId]) {
244
+ return;
245
+ }
246
+
247
+ store.usageStats = store.usageStats ?? {};
248
+ const existing = store.usageStats[profileId] ?? {};
249
+ const now = Date.now();
250
+ const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
251
+ const cfgResolved = resolveAuthCooldownConfig({
252
+ cfg,
253
+ providerId: providerKey,
254
+ });
255
+
256
+ store.usageStats[profileId] = computeNextProfileUsageStats({
257
+ existing,
258
+ now,
259
+ reason,
260
+ cfgResolved,
261
+ });
262
+ saveAuthProfileStore(store, agentDir);
263
+ }
264
+
265
+ /**
266
+ * Mark a profile as failed/rate-limited. Applies exponential backoff cooldown.
267
+ * Cooldown times: 1min, 5min, 25min, max 1 hour.
268
+ * Uses store lock to avoid overwriting concurrent usage updates.
269
+ */
270
+ export async function markAuthProfileCooldown(params: {
271
+ store: AuthProfileStore;
272
+ profileId: string;
273
+ agentDir?: string;
274
+ }): Promise<void> {
275
+ await markAuthProfileFailure({
276
+ store: params.store,
277
+ profileId: params.profileId,
278
+ reason: "unknown",
279
+ agentDir: params.agentDir,
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Clear cooldown for a profile (e.g., manual reset).
285
+ * Uses store lock to avoid overwriting concurrent usage updates.
286
+ */
287
+ export async function clearAuthProfileCooldown(params: {
288
+ store: AuthProfileStore;
289
+ profileId: string;
290
+ agentDir?: string;
291
+ }): Promise<void> {
292
+ const { store, profileId, agentDir } = params;
293
+ const updated = await updateAuthProfileStoreWithLock({
294
+ agentDir,
295
+ updater: (freshStore) => {
296
+ if (!freshStore.usageStats?.[profileId]) {
297
+ return false;
298
+ }
299
+
300
+ freshStore.usageStats[profileId] = {
301
+ ...freshStore.usageStats[profileId],
302
+ errorCount: 0,
303
+ cooldownUntil: undefined,
304
+ };
305
+ return true;
306
+ },
307
+ });
308
+ if (updated) {
309
+ store.usageStats = updated.usageStats;
310
+ return;
311
+ }
312
+ if (!store.usageStats?.[profileId]) {
313
+ return;
314
+ }
315
+
316
+ store.usageStats[profileId] = {
317
+ ...store.usageStats[profileId],
318
+ errorCount: 0,
319
+ cooldownUntil: undefined,
320
+ };
321
+ saveAuthProfileStore(store, agentDir);
322
+ }
src/agents/bash-process-registry.test.ts ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import type { ProcessSession } from "./bash-process-registry.js";
4
+ import {
5
+ addSession,
6
+ appendOutput,
7
+ drainSession,
8
+ listFinishedSessions,
9
+ markBackgrounded,
10
+ markExited,
11
+ resetProcessRegistryForTests,
12
+ } from "./bash-process-registry.js";
13
+
14
+ describe("bash process registry", () => {
15
+ beforeEach(() => {
16
+ resetProcessRegistryForTests();
17
+ });
18
+
19
+ it("captures output and truncates", () => {
20
+ const session: ProcessSession = {
21
+ id: "sess",
22
+ command: "echo test",
23
+ child: { pid: 123 } as ChildProcessWithoutNullStreams,
24
+ startedAt: Date.now(),
25
+ cwd: "/tmp",
26
+ maxOutputChars: 10,
27
+ pendingMaxOutputChars: 30_000,
28
+ totalOutputChars: 0,
29
+ pendingStdout: [],
30
+ pendingStderr: [],
31
+ pendingStdoutChars: 0,
32
+ pendingStderrChars: 0,
33
+ aggregated: "",
34
+ tail: "",
35
+ exited: false,
36
+ exitCode: undefined,
37
+ exitSignal: undefined,
38
+ truncated: false,
39
+ backgrounded: false,
40
+ };
41
+
42
+ addSession(session);
43
+ appendOutput(session, "stdout", "0123456789");
44
+ appendOutput(session, "stdout", "abcdef");
45
+
46
+ expect(session.aggregated).toBe("6789abcdef");
47
+ expect(session.truncated).toBe(true);
48
+ });
49
+
50
+ it("caps pending output to avoid runaway polls", () => {
51
+ const session: ProcessSession = {
52
+ id: "sess",
53
+ command: "echo test",
54
+ child: { pid: 123 } as ChildProcessWithoutNullStreams,
55
+ startedAt: Date.now(),
56
+ cwd: "/tmp",
57
+ maxOutputChars: 100_000,
58
+ pendingMaxOutputChars: 20_000,
59
+ totalOutputChars: 0,
60
+ pendingStdout: [],
61
+ pendingStderr: [],
62
+ pendingStdoutChars: 0,
63
+ pendingStderrChars: 0,
64
+ aggregated: "",
65
+ tail: "",
66
+ exited: false,
67
+ exitCode: undefined,
68
+ exitSignal: undefined,
69
+ truncated: false,
70
+ backgrounded: true,
71
+ };
72
+
73
+ addSession(session);
74
+ const payload = `${"a".repeat(70_000)}${"b".repeat(20_000)}`;
75
+ appendOutput(session, "stdout", payload);
76
+
77
+ const drained = drainSession(session);
78
+ expect(drained.stdout).toBe("b".repeat(20_000));
79
+ expect(session.pendingStdout).toHaveLength(0);
80
+ expect(session.pendingStdoutChars).toBe(0);
81
+ expect(session.truncated).toBe(true);
82
+ });
83
+
84
+ it("respects max output cap when pending cap is larger", () => {
85
+ const session: ProcessSession = {
86
+ id: "sess",
87
+ command: "echo test",
88
+ child: { pid: 123 } as ChildProcessWithoutNullStreams,
89
+ startedAt: Date.now(),
90
+ cwd: "/tmp",
91
+ maxOutputChars: 5_000,
92
+ pendingMaxOutputChars: 30_000,
93
+ totalOutputChars: 0,
94
+ pendingStdout: [],
95
+ pendingStderr: [],
96
+ pendingStdoutChars: 0,
97
+ pendingStderrChars: 0,
98
+ aggregated: "",
99
+ tail: "",
100
+ exited: false,
101
+ exitCode: undefined,
102
+ exitSignal: undefined,
103
+ truncated: false,
104
+ backgrounded: true,
105
+ };
106
+
107
+ addSession(session);
108
+ appendOutput(session, "stdout", "x".repeat(10_000));
109
+
110
+ const drained = drainSession(session);
111
+ expect(drained.stdout.length).toBe(5_000);
112
+ expect(session.truncated).toBe(true);
113
+ });
114
+
115
+ it("caps stdout and stderr independently", () => {
116
+ const session: ProcessSession = {
117
+ id: "sess",
118
+ command: "echo test",
119
+ child: { pid: 123 } as ChildProcessWithoutNullStreams,
120
+ startedAt: Date.now(),
121
+ cwd: "/tmp",
122
+ maxOutputChars: 100,
123
+ pendingMaxOutputChars: 10,
124
+ totalOutputChars: 0,
125
+ pendingStdout: [],
126
+ pendingStderr: [],
127
+ pendingStdoutChars: 0,
128
+ pendingStderrChars: 0,
129
+ aggregated: "",
130
+ tail: "",
131
+ exited: false,
132
+ exitCode: undefined,
133
+ exitSignal: undefined,
134
+ truncated: false,
135
+ backgrounded: true,
136
+ };
137
+
138
+ addSession(session);
139
+ appendOutput(session, "stdout", "a".repeat(6));
140
+ appendOutput(session, "stdout", "b".repeat(6));
141
+ appendOutput(session, "stderr", "c".repeat(12));
142
+
143
+ const drained = drainSession(session);
144
+ expect(drained.stdout).toBe("a".repeat(4) + "b".repeat(6));
145
+ expect(drained.stderr).toBe("c".repeat(10));
146
+ expect(session.truncated).toBe(true);
147
+ });
148
+
149
+ it("only persists finished sessions when backgrounded", () => {
150
+ const session: ProcessSession = {
151
+ id: "sess",
152
+ command: "echo test",
153
+ child: { pid: 123 } as ChildProcessWithoutNullStreams,
154
+ startedAt: Date.now(),
155
+ cwd: "/tmp",
156
+ maxOutputChars: 100,
157
+ pendingMaxOutputChars: 30_000,
158
+ totalOutputChars: 0,
159
+ pendingStdout: [],
160
+ pendingStderr: [],
161
+ pendingStdoutChars: 0,
162
+ pendingStderrChars: 0,
163
+ aggregated: "",
164
+ tail: "",
165
+ exited: false,
166
+ exitCode: undefined,
167
+ exitSignal: undefined,
168
+ truncated: false,
169
+ backgrounded: false,
170
+ };
171
+
172
+ addSession(session);
173
+ markExited(session, 0, null, "completed");
174
+ expect(listFinishedSessions()).toHaveLength(0);
175
+
176
+ markBackgrounded(session);
177
+ markExited(session, 0, null, "completed");
178
+ expect(listFinishedSessions()).toHaveLength(1);
179
+ });
180
+ });
src/agents/bash-process-registry.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
3
+
4
+ const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
5
+ const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
6
+ const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours
7
+ const DEFAULT_PENDING_OUTPUT_CHARS = 30_000;
8
+
9
+ function clampTtl(value: number | undefined) {
10
+ if (!value || Number.isNaN(value)) {
11
+ return DEFAULT_JOB_TTL_MS;
12
+ }
13
+ return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
14
+ }
15
+
16
+ let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10));
17
+
18
+ export type ProcessStatus = "running" | "completed" | "failed" | "killed";
19
+
20
+ export type SessionStdin = {
21
+ write: (data: string, cb?: (err?: Error | null) => void) => void;
22
+ end: () => void;
23
+ destroyed?: boolean;
24
+ };
25
+
26
+ export interface ProcessSession {
27
+ id: string;
28
+ command: string;
29
+ scopeKey?: string;
30
+ sessionKey?: string;
31
+ notifyOnExit?: boolean;
32
+ exitNotified?: boolean;
33
+ child?: ChildProcessWithoutNullStreams;
34
+ stdin?: SessionStdin;
35
+ pid?: number;
36
+ startedAt: number;
37
+ cwd?: string;
38
+ maxOutputChars: number;
39
+ pendingMaxOutputChars?: number;
40
+ totalOutputChars: number;
41
+ pendingStdout: string[];
42
+ pendingStderr: string[];
43
+ pendingStdoutChars: number;
44
+ pendingStderrChars: number;
45
+ aggregated: string;
46
+ tail: string;
47
+ exitCode?: number | null;
48
+ exitSignal?: NodeJS.Signals | number | null;
49
+ exited: boolean;
50
+ truncated: boolean;
51
+ backgrounded: boolean;
52
+ }
53
+
54
+ export interface FinishedSession {
55
+ id: string;
56
+ command: string;
57
+ scopeKey?: string;
58
+ startedAt: number;
59
+ endedAt: number;
60
+ cwd?: string;
61
+ status: ProcessStatus;
62
+ exitCode?: number | null;
63
+ exitSignal?: NodeJS.Signals | number | null;
64
+ aggregated: string;
65
+ tail: string;
66
+ truncated: boolean;
67
+ totalOutputChars: number;
68
+ }
69
+
70
+ const runningSessions = new Map<string, ProcessSession>();
71
+ const finishedSessions = new Map<string, FinishedSession>();
72
+
73
+ let sweeper: NodeJS.Timeout | null = null;
74
+
75
+ function isSessionIdTaken(id: string) {
76
+ return runningSessions.has(id) || finishedSessions.has(id);
77
+ }
78
+
79
+ export function createSessionSlug(): string {
80
+ return createSessionSlugId(isSessionIdTaken);
81
+ }
82
+
83
+ export function addSession(session: ProcessSession) {
84
+ runningSessions.set(session.id, session);
85
+ startSweeper();
86
+ }
87
+
88
+ export function getSession(id: string) {
89
+ return runningSessions.get(id);
90
+ }
91
+
92
+ export function getFinishedSession(id: string) {
93
+ return finishedSessions.get(id);
94
+ }
95
+
96
+ export function deleteSession(id: string) {
97
+ runningSessions.delete(id);
98
+ finishedSessions.delete(id);
99
+ }
100
+
101
+ export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
102
+ session.pendingStdout ??= [];
103
+ session.pendingStderr ??= [];
104
+ session.pendingStdoutChars ??= sumPendingChars(session.pendingStdout);
105
+ session.pendingStderrChars ??= sumPendingChars(session.pendingStderr);
106
+ const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
107
+ const bufferChars = stream === "stdout" ? session.pendingStdoutChars : session.pendingStderrChars;
108
+ const pendingCap = Math.min(
109
+ session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
110
+ session.maxOutputChars,
111
+ );
112
+ buffer.push(chunk);
113
+ let pendingChars = bufferChars + chunk.length;
114
+ if (pendingChars > pendingCap) {
115
+ session.truncated = true;
116
+ pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
117
+ }
118
+ if (stream === "stdout") {
119
+ session.pendingStdoutChars = pendingChars;
120
+ } else {
121
+ session.pendingStderrChars = pendingChars;
122
+ }
123
+ session.totalOutputChars += chunk.length;
124
+ const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
125
+ session.truncated =
126
+ session.truncated || aggregated.length < session.aggregated.length + chunk.length;
127
+ session.aggregated = aggregated;
128
+ session.tail = tail(session.aggregated, 2000);
129
+ }
130
+
131
+ export function drainSession(session: ProcessSession) {
132
+ const stdout = session.pendingStdout.join("");
133
+ const stderr = session.pendingStderr.join("");
134
+ session.pendingStdout = [];
135
+ session.pendingStderr = [];
136
+ session.pendingStdoutChars = 0;
137
+ session.pendingStderrChars = 0;
138
+ return { stdout, stderr };
139
+ }
140
+
141
+ export function markExited(
142
+ session: ProcessSession,
143
+ exitCode: number | null,
144
+ exitSignal: NodeJS.Signals | number | null,
145
+ status: ProcessStatus,
146
+ ) {
147
+ session.exited = true;
148
+ session.exitCode = exitCode;
149
+ session.exitSignal = exitSignal;
150
+ session.tail = tail(session.aggregated, 2000);
151
+ moveToFinished(session, status);
152
+ }
153
+
154
+ export function markBackgrounded(session: ProcessSession) {
155
+ session.backgrounded = true;
156
+ }
157
+
158
+ function moveToFinished(session: ProcessSession, status: ProcessStatus) {
159
+ runningSessions.delete(session.id);
160
+ if (!session.backgrounded) {
161
+ return;
162
+ }
163
+ finishedSessions.set(session.id, {
164
+ id: session.id,
165
+ command: session.command,
166
+ scopeKey: session.scopeKey,
167
+ startedAt: session.startedAt,
168
+ endedAt: Date.now(),
169
+ cwd: session.cwd,
170
+ status,
171
+ exitCode: session.exitCode,
172
+ exitSignal: session.exitSignal,
173
+ aggregated: session.aggregated,
174
+ tail: session.tail,
175
+ truncated: session.truncated,
176
+ totalOutputChars: session.totalOutputChars,
177
+ });
178
+ }
179
+
180
+ export function tail(text: string, max = 2000) {
181
+ if (text.length <= max) {
182
+ return text;
183
+ }
184
+ return text.slice(text.length - max);
185
+ }
186
+
187
+ function sumPendingChars(buffer: string[]) {
188
+ let total = 0;
189
+ for (const chunk of buffer) {
190
+ total += chunk.length;
191
+ }
192
+ return total;
193
+ }
194
+
195
+ function capPendingBuffer(buffer: string[], pendingChars: number, cap: number) {
196
+ if (pendingChars <= cap) {
197
+ return pendingChars;
198
+ }
199
+ const last = buffer.at(-1);
200
+ if (last && last.length >= cap) {
201
+ buffer.length = 0;
202
+ buffer.push(last.slice(last.length - cap));
203
+ return cap;
204
+ }
205
+ while (buffer.length && pendingChars - buffer[0].length >= cap) {
206
+ pendingChars -= buffer[0].length;
207
+ buffer.shift();
208
+ }
209
+ if (buffer.length && pendingChars > cap) {
210
+ const overflow = pendingChars - cap;
211
+ buffer[0] = buffer[0].slice(overflow);
212
+ pendingChars = cap;
213
+ }
214
+ return pendingChars;
215
+ }
216
+
217
+ export function trimWithCap(text: string, max: number) {
218
+ if (text.length <= max) {
219
+ return text;
220
+ }
221
+ return text.slice(text.length - max);
222
+ }
223
+
224
+ export function listRunningSessions() {
225
+ return Array.from(runningSessions.values()).filter((s) => s.backgrounded);
226
+ }
227
+
228
+ export function listFinishedSessions() {
229
+ return Array.from(finishedSessions.values());
230
+ }
231
+
232
+ export function clearFinished() {
233
+ finishedSessions.clear();
234
+ }
235
+
236
+ export function resetProcessRegistryForTests() {
237
+ runningSessions.clear();
238
+ finishedSessions.clear();
239
+ stopSweeper();
240
+ }
241
+
242
+ export function setJobTtlMs(value?: number) {
243
+ if (value === undefined || Number.isNaN(value)) {
244
+ return;
245
+ }
246
+ jobTtlMs = clampTtl(value);
247
+ stopSweeper();
248
+ startSweeper();
249
+ }
250
+
251
+ function pruneFinishedSessions() {
252
+ const cutoff = Date.now() - jobTtlMs;
253
+ for (const [id, session] of finishedSessions.entries()) {
254
+ if (session.endedAt < cutoff) {
255
+ finishedSessions.delete(id);
256
+ }
257
+ }
258
+ }
259
+
260
+ function startSweeper() {
261
+ if (sweeper) {
262
+ return;
263
+ }
264
+ sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, jobTtlMs / 6));
265
+ sweeper.unref?.();
266
+ }
267
+
268
+ function stopSweeper() {
269
+ if (!sweeper) {
270
+ return;
271
+ }
272
+ clearInterval(sweeper);
273
+ sweeper = null;
274
+ }