Spaces:
Running
Running
Upload 2526 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/acp/client.ts +191 -0
- src/acp/commands.ts +40 -0
- src/acp/event-mapper.test.ts +31 -0
- src/acp/event-mapper.ts +95 -0
- src/acp/index.ts +4 -0
- src/acp/meta.ts +47 -0
- src/acp/server.ts +144 -0
- src/acp/session-mapper.test.ts +56 -0
- src/acp/session-mapper.ts +98 -0
- src/acp/session.test.ts +25 -0
- src/acp/session.ts +94 -0
- src/acp/translator.ts +454 -0
- src/acp/types.ts +29 -0
- src/agents/agent-paths.test.ts +56 -0
- src/agents/agent-paths.ts +25 -0
- src/agents/agent-scope.test.ts +203 -0
- src/agents/agent-scope.ts +178 -0
- src/agents/anthropic-payload-log.ts +229 -0
- src/agents/anthropic.setup-token.live.test.ts +226 -0
- src/agents/apply-patch-update.ts +199 -0
- src/agents/apply-patch.test.ts +73 -0
- src/agents/apply-patch.ts +503 -0
- src/agents/auth-health.test.ts +89 -0
- src/agents/auth-health.ts +252 -0
- src/agents/auth-profiles.auth-profile-cooldowns.test.ts +12 -0
- src/agents/auth-profiles.chutes.test.ts +100 -0
- src/agents/auth-profiles.ensureauthprofilestore.test.ts +125 -0
- src/agents/auth-profiles.markauthprofilefailure.test.ts +131 -0
- src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +234 -0
- src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.test.ts +142 -0
- src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.test.ts +96 -0
- src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts +169 -0
- src/agents/auth-profiles.ts +40 -0
- src/agents/auth-profiles/constants.ts +26 -0
- src/agents/auth-profiles/display.ts +17 -0
- src/agents/auth-profiles/doctor.ts +47 -0
- src/agents/auth-profiles/external-cli-sync.ts +135 -0
- src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +173 -0
- src/agents/auth-profiles/oauth.ts +285 -0
- src/agents/auth-profiles/order.ts +210 -0
- src/agents/auth-profiles/paths.ts +33 -0
- src/agents/auth-profiles/profiles.ts +92 -0
- src/agents/auth-profiles/repair.ts +169 -0
- src/agents/auth-profiles/session-override.test.ts +63 -0
- src/agents/auth-profiles/session-override.ts +151 -0
- src/agents/auth-profiles/store.ts +378 -0
- src/agents/auth-profiles/types.ts +72 -0
- src/agents/auth-profiles/usage.ts +322 -0
- src/agents/bash-process-registry.test.ts +180 -0
- 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 |
+
}
|