larkin 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -462,7 +462,9 @@ if (kind === "pi-distribution") {
462
462
  assertBuiltinPiAgentDirectory(piAgentDirectory(configDir, selectedKey));
463
463
  }
464
464
  catch {
465
- die("内置 Pi provider 尚未为该 Agent 配置;请先运行 larkin setup 完成内置 Pi provider 配置,或使用 --import-external-profile");
465
+ die(agent.piDistribution === "builtin"
466
+ ? "内置 Pi provider 尚未为该 Agent 配置。请使用 Dashboard Provider Credentials 或 larkin pi-auth login。"
467
+ : "内置 Pi provider 尚未为该 Agent 配置。");
466
468
  }
467
469
  }
468
470
  try {
package/dist/app/cli.mjs CHANGED
@@ -84,8 +84,10 @@ Credentials, internal paths, serverId, activeAgent, and raw config are never exp
84
84
  larkin session recover --agent <App ID> --reason context-overflow --json [--wait-ready <seconds>]
85
85
  Reset replaces one idle, zero-backlog Runtime session. Recover is an explicit operator-only context-window recovery that preserves and replays canonical Inbox deliveries.`,
86
86
  "pi-auth": `Usage: larkin pi-auth status [--agent <App ID>] [--json]
87
+ larkin pi-auth providers [--json]
88
+ larkin pi-auth login <preset|custom> --agent <App ID> [--model <model>] [--base-url <url>] [--api-key-stdin] [--json]
87
89
  larkin pi-auth logout <provider> [--agent <App ID>]
88
- Show non-sensitive official Pi credential metadata or remove one target provider credential.`,
90
+ Show a redacted builtin Pi provider catalog, configure one existing Agent with a known preset or custom OpenAI-compatible endpoint, or remove one target provider credential. API keys are accepted only from a TTY prompt or --api-key-stdin.`,
89
91
  comment: `Usage: larkin comment reply --message-id <doc_comment_message_id> --text '<reply>' --json
90
92
  Reply as the Runtime-bound Bot to the exact cloud-document comment locator supplied by canonical Inbox. Retrying the same body is idempotent; a different body appends a follow-up reply to the same comment.`,
91
93
  telemetry: `Usage: larkin telemetry <status|export|import|flush>
@@ -130,7 +132,7 @@ Usage: larkin <command>
130
132
  config Inspect effective config/source, edit mention inheritance, or explicitly apply runtime changes
131
133
  session reset Replace one idle, zero-backlog Agent Runtime session for a fresh scenario
132
134
  session recover Explicitly recover a context-overflowed session and replay retained Inbox deliveries
133
- pi-auth Show non-sensitive built-in Pi auth status or logout one provider
135
+ pi-auth Show, configure, or logout one built-in Pi Agent provider credential
134
136
  comment reply Reply to the exact cloud-document comment bound by a polled Inbox message
135
137
  telemetry Inspect, export, import, or flush the durable OpenTelemetry trace queue
136
138
  <lark-cli 命令组> im/docs/wiki/drive 等 lark-cli 命令原样转发,机器人身份已锁定(如 larkin im +chat-list)
@@ -144,7 +146,15 @@ Process persistence is managed externally. larkin start stays in the foreground
144
146
  }
145
147
  const [mode, ...presetArguments] = routes[command];
146
148
  const childSpec = internalCommandSpec(mode, [...presetArguments, ...rest]);
147
- const child = spawn(childSpec.command, childSpec.args, { stdio: "inherit" });
149
+ // 管道 stdin 必须显式转发:Linux 上 inherit 会在父进程排空后再交给子进程,导致 --api-key-stdin 读到空。
150
+ const pipeStdin = command === "pi-auth" && rest.includes("--api-key-stdin") && !process.stdin.isTTY;
151
+ if (pipeStdin && typeof process.stdin.pause === "function")
152
+ process.stdin.pause();
153
+ const child = spawn(childSpec.command, childSpec.args, {
154
+ stdio: [pipeStdin ? "pipe" : "inherit", "inherit", "inherit"],
155
+ });
156
+ if (pipeStdin && child.stdin)
157
+ process.stdin.pipe(child.stdin);
148
158
  // Package-manager bin shims add a wrapper process. Forward terminal signals so the actual command
149
159
  // does not become an orphan that keeps the machine lock or Feishu connection.
150
160
  const signalForwarders = new Map();
@@ -14,6 +14,7 @@ const WINDOWS_SOCKET_BINDING = { device: "0", inode: "0", owner: "0", changeTime
14
14
  const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
15
15
  const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
16
16
  const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
17
+ const CONFIG_SIGNATURE = /^sha256:[a-f0-9]{64}$/;
17
18
  const UNIX_SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
18
19
  function recoveryErrorText(code) {
19
20
  switch (code) {
@@ -159,9 +160,13 @@ function parseRequest(line) {
159
160
  if (value.waitReadyMs !== undefined) {
160
161
  throw new Error("invalid session reset waitReadyMs");
161
162
  }
162
- if (Object.keys(value).some((key) => !["operationId", "agentId", "authorization"].includes(key))) {
163
+ if (Object.keys(value).some((key) => !["operationId", "agentId", "authorization", "expectedSignature"].includes(key))) {
163
164
  throw new Error("agent control request 包含未知字段");
164
165
  }
166
+ if (value.expectedSignature !== undefined
167
+ && (typeof value.expectedSignature !== "string" || !CONFIG_SIGNATURE.test(value.expectedSignature))) {
168
+ throw new Error("invalid agent upsert expectedSignature");
169
+ }
165
170
  return value;
166
171
  }
167
172
  function atomicWritePrivateJson(file, value) {
@@ -741,7 +746,11 @@ export function createAgentControlServer({ larkinHome, authorityToken, upsert, r
741
746
  }
742
747
  const execute = async () => {
743
748
  try {
744
- await upsert({ operationId: upsertRequest.operationId, agentId: upsertRequest.agentId });
749
+ await upsert({
750
+ operationId: upsertRequest.operationId,
751
+ agentId: upsertRequest.agentId,
752
+ ...(upsertRequest.expectedSignature ? { expectedSignature: upsertRequest.expectedSignature } : {}),
753
+ });
745
754
  return { ok: true, operationId: upsertRequest.operationId, agentId: upsertRequest.agentId };
746
755
  }
747
756
  catch (error) {
@@ -926,7 +935,11 @@ async function requestAgentControl({ larkinHome, timeoutMs = 30_000, request, })
926
935
  }
927
936
  export async function requestAgentUpsert(input) {
928
937
  return requestAgentControl({ larkinHome: input.larkinHome, timeoutMs: input.timeoutMs,
929
- request: { operationId: input.operationId ?? crypto.randomUUID(), agentId: input.agentId } });
938
+ request: {
939
+ operationId: input.operationId ?? crypto.randomUUID(),
940
+ agentId: input.agentId,
941
+ ...(input.expectedSignature ? { expectedSignature: input.expectedSignature } : {}),
942
+ } });
930
943
  }
931
944
  export async function requestSessionReset({ larkinHome, agentId, waitReadyMs = 30_000, }) {
932
945
  return requestAgentControl({
@@ -1,13 +1,234 @@
1
+ import fs from "node:fs";
1
2
  import * as larkinConfig from "../platform/config.mjs";
2
- import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime, createOfficialPiLogoutRuntime, logoutOfficialPiProvider, officialPiAuthStatus, } from "../runtime/pi-official-auth.mjs";
3
+ import { createOfficialPiCredentialRuntime, officialPiAuthStatus, } from "../runtime/pi-official-auth.mjs";
4
+ import { configureBuiltinPiProvider, listBuiltinPiProviderCatalog, logoutBuiltinPiProvider, } from "../runtime/pi-provider-login.mjs";
5
+ import { MAX_BUILTIN_PI_API_KEY_LENGTH } from "../runtime/pi-provider-config.mjs";
3
6
  const APP_ID = /^cli_[A-Za-z0-9]+$/;
7
+ const USAGE = "Usage: larkin pi-auth status [--agent <App ID>] [--json] | larkin pi-auth providers [--json] | larkin pi-auth login <preset|custom> --agent <App ID> [--model <model>] [--base-url <url>] [--api-key-stdin] [--json] | larkin pi-auth logout <provider> [--agent <App ID>]";
8
+ const API_KEY_ARGV_MESSAGE = "existing-Agent login does not accept --api-key. Use --api-key-stdin or an interactive TTY prompt.";
9
+ const API_KEY_BUDGET_MESSAGE = "API Key 不能为空或包含控制字符";
10
+ const STDIN_CHUNK_BYTES = 1024;
11
+ const STDIN_BOM_BYTES = 3;
12
+ const STDIN_TRAILING_NEWLINE_SLACK = 32;
13
+ const MAX_STDIN_API_KEY_BYTES = MAX_BUILTIN_PI_API_KEY_LENGTH + STDIN_BOM_BYTES + STDIN_TRAILING_NEWLINE_SLACK;
4
14
  function flag(args, name) {
5
15
  const index = args.indexOf(name);
6
16
  return index >= 0 ? args[index + 1] : undefined;
7
17
  }
8
- export async function main(args = process.argv.slice(2), env = process.env) {
18
+ function hasApiKeyArgv(args) {
19
+ return args.some((argument) => argument === "--api-key" || argument.startsWith("--api-key="));
20
+ }
21
+ function consumeFlags(args, allowed, unary) {
22
+ let index = 0;
23
+ while (index < args.length) {
24
+ const argument = args[index];
25
+ if (!argument.startsWith("--"))
26
+ return false;
27
+ if (unary.has(argument)) {
28
+ index += 1;
29
+ continue;
30
+ }
31
+ if (allowed.has(argument) && args[index + 1] && !args[index + 1].startsWith("--")) {
32
+ index += 2;
33
+ continue;
34
+ }
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ /** 去掉 BOM 与全部尾部换行;保留内部控制字符给后续校验拒绝。 */
40
+ export function normalizeStdinApiKey(raw) {
41
+ return raw.replace(/^\uFEFF/, "").replace(/[\r\n]+$/g, "");
42
+ }
43
+ function assertStdinApiKeyBudget(size) {
44
+ if (size > MAX_STDIN_API_KEY_BYTES)
45
+ throw new Error(API_KEY_BUDGET_MESSAGE);
46
+ }
47
+ function finishStdinApiKey(chunks) {
48
+ const normalized = normalizeStdinApiKey(Buffer.concat(chunks).toString("utf8"));
49
+ if (normalized.length > MAX_BUILTIN_PI_API_KEY_LENGTH)
50
+ throw new Error(API_KEY_BUDGET_MESSAGE);
51
+ return normalized;
52
+ }
53
+ function readFdChunk(fd, buffer) {
54
+ return new Promise((resolve, reject) => {
55
+ fs.read(fd, buffer, 0, buffer.length, null, (error, bytesRead) => {
56
+ if (error)
57
+ reject(error);
58
+ else
59
+ resolve(bytesRead);
60
+ });
61
+ });
62
+ }
63
+ /** 按块读取 fd,超过 key 上限立即停止,不等待管道 EOF。 */
64
+ async function readBoundedFdSecret(fd) {
65
+ const chunks = [];
66
+ let size = 0;
67
+ const buffer = Buffer.alloc(STDIN_CHUNK_BYTES);
68
+ while (true) {
69
+ const bytesRead = await readFdChunk(fd, buffer);
70
+ if (bytesRead === 0)
71
+ break;
72
+ size += bytesRead;
73
+ assertStdinApiKeyBudget(size);
74
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
75
+ }
76
+ return finishStdinApiKey(chunks);
77
+ }
78
+ async function readBoundedStreamSecret(stdin) {
79
+ const chunks = [];
80
+ let size = 0;
81
+ for await (const chunk of stdin) {
82
+ const bytes = Buffer.from(chunk);
83
+ size += bytes.length;
84
+ assertStdinApiKeyBudget(size);
85
+ chunks.push(bytes);
86
+ }
87
+ return finishStdinApiKey(chunks);
88
+ }
89
+ export async function readStdinSecret(stdin) {
90
+ const stream = stdin;
91
+ if (typeof stream.fd === "number" && stream.fd >= 0 && !stream.isTTY) {
92
+ if (typeof stream.pause === "function")
93
+ stream.pause();
94
+ try {
95
+ return await readBoundedFdSecret(stream.fd);
96
+ }
97
+ catch (error) {
98
+ if (error instanceof Error && error.message === API_KEY_BUDGET_MESSAGE)
99
+ throw error;
100
+ // 回退到异步收集,例如测试传入的假 stream。
101
+ }
102
+ }
103
+ return readBoundedStreamSecret(stdin);
104
+ }
105
+ function createNoEchoQuestioner(stdin, stdout) {
106
+ return {
107
+ close() { },
108
+ secret(prompt) {
109
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
110
+ throw new Error("non-interactive API-key login requires --api-key-stdin");
111
+ }
112
+ stdout.write(prompt);
113
+ const wasRaw = stdin.isRaw;
114
+ stdin.setRawMode(true);
115
+ stdin.resume();
116
+ return new Promise((resolve, reject) => {
117
+ let value = "";
118
+ const finish = (error) => {
119
+ stdin.off("data", onData);
120
+ stdin.setRawMode(Boolean(wasRaw));
121
+ stdin.pause();
122
+ stdout.write("\n");
123
+ if (error)
124
+ reject(error);
125
+ else
126
+ resolve(value);
127
+ };
128
+ const onData = (chunk) => {
129
+ const bytes = Buffer.from(chunk);
130
+ for (const byte of bytes) {
131
+ if (byte === 3)
132
+ return finish(new Error("pi-auth login cancelled"));
133
+ if (byte === 10 || byte === 13)
134
+ return finish();
135
+ if (byte === 8 || byte === 127) {
136
+ value = value.slice(0, -1);
137
+ continue;
138
+ }
139
+ if (byte >= 32 && byte <= 126)
140
+ value += String.fromCharCode(byte);
141
+ }
142
+ };
143
+ stdin.on("data", onData);
144
+ });
145
+ },
146
+ };
147
+ }
148
+ function resolveAgentId(args, env) {
149
+ const loaded = larkinConfig.loadConfig(env);
150
+ const agentId = flag(args, "--agent") || loaded.config.activeAgent || undefined;
151
+ if (!agentId || !APP_ID.test(agentId))
152
+ throw new Error("请用 --agent <App ID> 选择 Agent");
153
+ const agent = loaded.config.agents[agentId];
154
+ if (!agent)
155
+ throw new Error(`Agent ${agentId} 不存在`);
156
+ if (agent.runtime !== "pi" || agent.piDistribution !== "builtin")
157
+ throw new Error(`Agent ${agentId} 不是内置 Pi`);
158
+ return agentId;
159
+ }
160
+ export async function main(args = process.argv.slice(2), env = process.env, io = { stdin: process.stdin, stdout: process.stdout, isTTY: Boolean(process.stdin.isTTY) }) {
9
161
  const action = args[0] || "status";
10
162
  const json = args.includes("--json");
163
+ if (hasApiKeyArgv(args))
164
+ throw new Error(API_KEY_ARGV_MESSAGE);
165
+ if (action === "providers") {
166
+ if (!consumeFlags(args.slice(1), new Set(), new Set(["--json"])))
167
+ throw new Error(USAGE);
168
+ const providers = listBuiltinPiProviderCatalog();
169
+ if (json) {
170
+ console.log(JSON.stringify({ providers }));
171
+ return;
172
+ }
173
+ console.log("Builtin Pi providers:");
174
+ for (const entry of providers) {
175
+ const model = entry.defaultModel ? ` default=${entry.defaultModel}` : " (requires --base-url and --model)";
176
+ console.log(` ${entry.id} ${entry.name}${model}`);
177
+ }
178
+ return;
179
+ }
180
+ if (action === "login") {
181
+ const preset = args[1];
182
+ const rest = args.slice(2);
183
+ if (!preset || preset.startsWith("--")
184
+ || !consumeFlags(rest, new Set(["--agent", "--model", "--base-url"]), new Set(["--json", "--api-key-stdin"]))) {
185
+ throw new Error(USAGE);
186
+ }
187
+ const agentId = resolveAgentId(rest, env);
188
+ const stdinKey = rest.includes("--api-key-stdin");
189
+ let apiKey = "";
190
+ if (stdinKey)
191
+ apiKey = await readStdinSecret(io.stdin);
192
+ else if (io.isTTY) {
193
+ const questioner = createNoEchoQuestioner(io.stdin, io.stdout);
194
+ try {
195
+ apiKey = await questioner.secret("API Key:\n> ");
196
+ }
197
+ finally {
198
+ questioner.close();
199
+ }
200
+ }
201
+ else {
202
+ throw new Error("non-interactive API-key login requires --api-key-stdin");
203
+ }
204
+ const result = await configureBuiltinPiProvider({
205
+ agentId,
206
+ preset,
207
+ apiKey,
208
+ model: flag(rest, "--model"),
209
+ baseUrl: flag(rest, "--base-url"),
210
+ env,
211
+ });
212
+ const payload = {
213
+ agentId: result.agentId,
214
+ provider: result.provider,
215
+ model: result.model,
216
+ credentialType: result.credentialType,
217
+ applyState: result.applyState,
218
+ ...(result.applyError ? { applyError: result.applyError } : {}),
219
+ };
220
+ if (json)
221
+ console.log(JSON.stringify(payload));
222
+ else {
223
+ const pending = result.applyState === "applied"
224
+ ? "applied"
225
+ : result.applyState === "pending"
226
+ ? `pending${result.applyError ? ` (${result.applyError})` : ""}`
227
+ : "saved; will apply on larkin start";
228
+ console.log(`Agent ${result.agentId} provider=${result.provider} model=${result.model} credential=${result.credentialType} apply=${pending}`);
229
+ }
230
+ return;
231
+ }
11
232
  const requestedAgent = flag(args, "--agent");
12
233
  let index = action === "logout" ? 2 : 1;
13
234
  let valid = action === "status" || action === "logout";
@@ -23,9 +244,8 @@ export async function main(args = process.argv.slice(2), env = process.env) {
23
244
  }
24
245
  valid = false;
25
246
  }
26
- if (!valid) {
27
- throw new Error("Usage: larkin pi-auth status [--agent <App ID>] [--json] | larkin pi-auth logout <provider> [--agent <App ID>]");
28
- }
247
+ if (!valid)
248
+ throw new Error(USAGE);
29
249
  const loaded = larkinConfig.loadConfig(env);
30
250
  const agentId = requestedAgent || loaded.config.activeAgent || undefined;
31
251
  if (!agentId || !APP_ID.test(agentId))
@@ -50,18 +270,8 @@ export async function main(args = process.argv.slice(2), env = process.env) {
50
270
  return;
51
271
  }
52
272
  const providerId = args[1];
53
- if (!providerId || providerId.startsWith("--") || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(providerId)) {
273
+ if (!providerId || providerId.startsWith("--"))
54
274
  throw new Error("logout 需要安全的 provider ID");
55
- }
56
- const transaction = beginBuiltinPiCredentialTransaction(loaded.configDir, agentId);
57
- try {
58
- const runtime = await createOfficialPiLogoutRuntime(loaded.configDir, agentId);
59
- await logoutOfficialPiProvider(runtime, providerId);
60
- transaction.commit();
61
- }
62
- catch (error) {
63
- transaction.rollback();
64
- throw error;
65
- }
275
+ await logoutBuiltinPiProvider({ agentId, providerId, env });
66
276
  console.log(`Agent ${agentId} 已退出 ${providerId};其他 provider 未修改`);
67
277
  }
@@ -6,22 +6,58 @@ import { ContextPromptBuilder } from "../agent/context-prompt.mjs";
6
6
  import { createNativeRuntimeAdapter } from "../runtime/runtime-adapters.mjs";
7
7
  import { createRuntimeHost } from "../runtime/runtime-host.mjs";
8
8
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
9
- import { loadConfig, markConfigApplied, runtimeConfigSignature } from "../platform/config.mjs";
9
+ import { loadConfig, markConfigApplied, runtimeConfigSignature, withConfigMutationLock, withConfigMutationLockAsync, } from "../platform/config.mjs";
10
10
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
11
11
  import { createAgentControlServer, requestSupervisorAgentUpsert } from "./local-control.mjs";
12
- import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
12
+ import { hydrateRuntimeAgent, syncAgentProfile, } from "./runtime-agent-config.mjs";
13
13
  import { loadTelemetryConfig } from "../platform/telemetry-config.mjs";
14
14
  import { telemetrySingleton } from "../platform/telemetry-tracing.mjs";
15
15
  import { packageVersion } from "../platform/build-info.mjs";
16
- export function loadAndSyncRuntimeAgent(env, agentId, dependencies = {}) {
16
+ function readRuntimeAgent(env, agentId, expectedSignature) {
17
17
  const loaded = loadConfig(env);
18
18
  const stored = loaded.config.agents[agentId];
19
19
  if (!stored)
20
20
  throw new Error(`Agent ${agentId} 不存在于 canonical config`);
21
+ if (expectedSignature) {
22
+ const current = runtimeConfigSignature(loaded.config, agentId);
23
+ if (current !== expectedSignature) {
24
+ throw new Error("配置在 apply 期间发生变化;未热加载已切换的 Agent");
25
+ }
26
+ }
27
+ return { loaded, stored };
28
+ }
29
+ function hydrateAndSyncRuntimeAgent(env, loaded, stored, dependencies) {
21
30
  const agent = hydrateRuntimeAgent(loaded.configDir, stored);
22
31
  syncAgentProfile(agent, { ...env, LARKIN_CONFIG_DIR: loaded.configDir }, dependencies);
23
32
  return agent;
24
33
  }
34
+ export function loadAndSyncRuntimeAgent(env, agentId, dependencies = {}, options = {}) {
35
+ const snapshot = readRuntimeAgent(env, agentId, options.expectedSignature);
36
+ options.afterCanonicalValidate?.();
37
+ if (!options.expectedSignature) {
38
+ return hydrateAndSyncRuntimeAgent(env, snapshot.loaded, snapshot.stored, dependencies);
39
+ }
40
+ return withConfigMutationLock(env, () => {
41
+ const locked = readRuntimeAgent(env, agentId, options.expectedSignature);
42
+ return hydrateAndSyncRuntimeAgent(env, locked.loaded, locked.stored, dependencies);
43
+ });
44
+ }
45
+ /** 签名校验贯穿实际 Host upsert;并发 config mutation 不能热挂载已失效的 snapshot。 */
46
+ export async function applyRuntimeAgentUpsert(env, agentId, dependencies = {}, options = {}, upsert = () => undefined) {
47
+ const snapshot = readRuntimeAgent(env, agentId, options.expectedSignature);
48
+ options.afterCanonicalValidate?.();
49
+ if (!options.expectedSignature) {
50
+ const agent = hydrateAndSyncRuntimeAgent(env, snapshot.loaded, snapshot.stored, dependencies);
51
+ await upsert(agent);
52
+ return agent;
53
+ }
54
+ return withConfigMutationLockAsync(env, async () => {
55
+ const locked = readRuntimeAgent(env, agentId, options.expectedSignature);
56
+ const agent = hydrateAndSyncRuntimeAgent(env, locked.loaded, locked.stored, dependencies);
57
+ await upsert(agent);
58
+ return agent;
59
+ });
60
+ }
25
61
  export async function markConfigAppliedAfterRuntimeReady(env, runningAgents, runtimeReady) {
26
62
  await runtimeReady;
27
63
  try {
@@ -100,10 +136,11 @@ export async function main(env = process.env, overrides = {}) {
100
136
  larkinHome: env.LARKIN_HOME,
101
137
  authorityToken: env.LARKIN_CONTROL_AUTHORIZATION,
102
138
  async upsert(request) {
103
- const agent = loadAndSyncRuntimeAgent(env, request.agentId);
139
+ const agent = await applyRuntimeAgentUpsert(env, request.agentId, {}, {
140
+ ...(request.expectedSignature ? { expectedSignature: request.expectedSignature } : {}),
141
+ }, (candidate) => hostShell.upsertAgent(candidate));
104
142
  // Only the selected profile is synchronized; active profiles and their directory
105
143
  // are never quarantined or rebuilt during hot attach.
106
- await hostShell.upsertAgent(agent);
107
144
  const tracked = await requestSupervisorAgentUpsert({
108
145
  larkinHome: env.LARKIN_HOME, agentId: agent.agentId, operationId: request.operationId,
109
146
  });
@@ -9,6 +9,8 @@ import { discoverPiModelCatalog } from "../runtime/pi-model-catalog.mjs";
9
9
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
10
10
  import { ownedPiCatalogAgentDir, piCatalogCommandSpec } from "../runtime/pi-provider-config.mjs";
11
11
  import { RUNTIME_OPTIONS, piCatalogDistributionForUserRuntime, toUserRuntime } from "../runtime/user-runtime.mjs";
12
+ import { configureBuiltinPiProvider, listBuiltinPiProviderCatalog, logoutBuiltinPiProvider, sanitizeProviderLoginError, } from "../runtime/pi-provider-login.mjs";
13
+ import { createOfficialPiCredentialRuntime, officialPiAuthStatus, } from "../runtime/pi-official-auth.mjs";
12
14
  function pruneCache(cache, expired, maxEntries) {
13
15
  for (const [key, value] of cache)
14
16
  if (expired(value))
@@ -170,9 +172,38 @@ export function createPiModelDirectoryResolver(options = {}) {
170
172
  const cache = new Map();
171
173
  const failures = new Map();
172
174
  const inFlight = new Map();
175
+ const generations = new Map();
176
+ // 单调序号:targeted 后再 global 时,in-flight 的 started 不能与新 generation 相等。
177
+ let seq = 0;
178
+ let baseline = 0;
179
+ const generationOf = (agentId) => Math.max(baseline, generations.get(agentId) ?? 0);
180
+ const dropPrefixed = (store, agentId) => {
181
+ if (!agentId) {
182
+ store.clear();
183
+ return;
184
+ }
185
+ const prefix = `${agentId}\u0000`;
186
+ for (const key of store.keys())
187
+ if (key.startsWith(prefix))
188
+ store.delete(key);
189
+ };
173
190
  return {
191
+ invalidate(agentId) {
192
+ dropPrefixed(cache, agentId);
193
+ dropPrefixed(failures, agentId);
194
+ dropPrefixed(inFlight, agentId);
195
+ seq += 1;
196
+ if (!agentId) {
197
+ baseline = seq;
198
+ generations.clear();
199
+ }
200
+ else {
201
+ generations.set(agentId, seq);
202
+ }
203
+ },
174
204
  async resolve(input) {
175
205
  const key = [input.agentId, input.cwd, input.agentDir, input.command ?? "", ...(input.commandArgs ?? [])].join("\u0000");
206
+ const started = generationOf(input.agentId);
176
207
  pruneCache(cache, (entry) => now() >= entry.expiresAt, 64);
177
208
  pruneCache(failures, (entry) => now() >= entry.expiresAt, 64);
178
209
  const cached = cache.get(key);
@@ -196,17 +227,23 @@ export function createPiModelDirectoryResolver(options = {}) {
196
227
  id, label, ...(contextWindow ? { contextWindow } : {}), supportedReasoningEfforts, ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
197
228
  })),
198
229
  ];
199
- failures.delete(key);
200
- cache.set(key, { expiresAt: now() + ttlMs, value: models });
230
+ if (generationOf(input.agentId) === started) {
231
+ failures.delete(key);
232
+ cache.set(key, { expiresAt: now() + ttlMs, value: models });
233
+ }
201
234
  return models;
202
235
  }
203
236
  catch (error) {
204
- failures.set(key, { expiresAt: now() + negativeTtlMs });
237
+ if (generationOf(input.agentId) === started)
238
+ failures.set(key, { expiresAt: now() + negativeTtlMs });
205
239
  throw error;
206
240
  }
207
241
  })();
208
242
  inFlight.set(key, pending);
209
- void pending.finally(() => inFlight.delete(key)).catch(() => undefined);
243
+ void pending.finally(() => {
244
+ if (inFlight.get(key) === pending)
245
+ inFlight.delete(key);
246
+ }).catch(() => undefined);
210
247
  }
211
248
  return await pending;
212
249
  },
@@ -383,7 +420,7 @@ function dashboardMutation(value) {
383
420
  return { kind: operation, agentId, model: String(value.model || "") };
384
421
  return { kind: "set-agent-effort", agentId, effort: value.effort === null || value.effort === "default" ? null : String(value.effort || "") };
385
422
  }
386
- export function createDashboardConfigController({ csrfCapability, env = process.env, requestUpsert = requestAgentUpsert, chatDirectoryResolver = createChatDirectoryResolver(), claudeModelDirectoryResolver = createClaudeModelDirectoryResolver(), codexModelDirectoryResolver = createCodexModelDirectoryResolver(), piModelDirectoryResolver = createPiModelDirectoryResolver(), }) {
423
+ export function createDashboardConfigController({ csrfCapability, env = process.env, requestUpsert = requestAgentUpsert, chatDirectoryResolver = createChatDirectoryResolver(), claudeModelDirectoryResolver = createClaudeModelDirectoryResolver(), codexModelDirectoryResolver = createCodexModelDirectoryResolver(), piModelDirectoryResolver = createPiModelDirectoryResolver(), configureProvider = configureBuiltinPiProvider, logoutProvider = logoutBuiltinPiProvider, listProviderCatalog = listBuiltinPiProviderCatalog, loadProviderStatus = async (configDir, agentId) => officialPiAuthStatus(await createOfficialPiCredentialRuntime(configDir, agentId)), }) {
387
424
  const resolvePiModelDirectory = async (agentId, userRuntime) => {
388
425
  const { config, configDir } = loadConfig(env);
389
426
  const agent = config.agents[agentId];
@@ -511,6 +548,94 @@ export function createDashboardConfigController({ csrfCapability, env = process.
511
548
  }
512
549
  return true;
513
550
  }
551
+ if (requestUrl.pathname === "/api/pi-auth/providers" && req.method === "GET") {
552
+ try {
553
+ assertPrivateReadRequest(req, csrfCapability);
554
+ json(res, 200, { providers: listProviderCatalog() });
555
+ }
556
+ catch (error) {
557
+ json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "provider catalog unavailable" });
558
+ }
559
+ return true;
560
+ }
561
+ if (requestUrl.pathname === "/api/pi-auth/status" && req.method === "GET") {
562
+ try {
563
+ assertPrivateReadRequest(req, csrfCapability);
564
+ const agentId = requestUrl.searchParams.get("agent") || "";
565
+ const { config, configDir } = loadConfig(env);
566
+ const agent = config.agents[agentId];
567
+ if (!agent || agent.runtime !== "pi" || agent.piDistribution !== "builtin")
568
+ throw new Error("unknown agent");
569
+ const credentials = await loadProviderStatus(configDir, agentId);
570
+ json(res, 200, { agentId, model: agent.model, credentials });
571
+ }
572
+ catch (error) {
573
+ json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "provider status unavailable" });
574
+ }
575
+ return true;
576
+ }
577
+ if (requestUrl.pathname === "/api/pi-auth/login" && req.method === "POST") {
578
+ let secret = "";
579
+ try {
580
+ assertWriteRequest(req, csrfCapability);
581
+ const body = await boundedJson(req);
582
+ const allowed = ["agentId", "preset", "apiKey", "model", "baseUrl"];
583
+ if (Object.keys(body).some((key) => !allowed.includes(key)))
584
+ throw new Error("unsupported field");
585
+ const agentId = String(body.agentId || "");
586
+ const preset = String(body.preset || "");
587
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey : "";
588
+ secret = apiKey;
589
+ if (!/^cli_[A-Za-z0-9]+$/.test(agentId) || !preset)
590
+ throw new Error("invalid login request");
591
+ const { config } = loadConfig(env);
592
+ const agent = config.agents[agentId];
593
+ if (!agent || agent.runtime !== "pi" || agent.piDistribution !== "builtin")
594
+ throw new Error("Agent is not builtin Pi");
595
+ const result = await configureProvider({
596
+ agentId,
597
+ preset,
598
+ apiKey,
599
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
600
+ ...(typeof body.baseUrl === "string" ? { baseUrl: body.baseUrl } : {}),
601
+ env,
602
+ });
603
+ piModelDirectoryResolver.invalidate?.(agentId);
604
+ json(res, 200, {
605
+ ok: true,
606
+ agentId: result.agentId,
607
+ provider: result.provider,
608
+ preset: result.preset,
609
+ model: result.model,
610
+ credentialType: result.credentialType,
611
+ applyState: result.applyState,
612
+ ...(result.applyError ? { applyError: sanitizeProviderLoginError(result.applyError, secret || undefined) } : {}),
613
+ });
614
+ }
615
+ catch (error) {
616
+ json(res, 400, { error: sanitizeProviderLoginError(error, secret || undefined) });
617
+ }
618
+ return true;
619
+ }
620
+ if (requestUrl.pathname === "/api/pi-auth/logout" && req.method === "POST") {
621
+ try {
622
+ assertWriteRequest(req, csrfCapability);
623
+ const body = await boundedJson(req);
624
+ if (Object.keys(body).sort().join(",") !== "agentId,provider")
625
+ throw new Error("invalid logout request");
626
+ const agentId = String(body.agentId || "");
627
+ const provider = String(body.provider || "");
628
+ if (!/^cli_[A-Za-z0-9]+$/.test(agentId))
629
+ throw new Error("invalid logout request");
630
+ const result = await logoutProvider({ agentId, providerId: provider, env });
631
+ piModelDirectoryResolver.invalidate?.(agentId);
632
+ json(res, 200, { ok: true, agentId: result.agentId, provider: result.provider });
633
+ }
634
+ catch (error) {
635
+ json(res, 400, { error: sanitizeProviderLoginError(error) });
636
+ }
637
+ return true;
638
+ }
514
639
  if (requestUrl.pathname === "/api/config/apply" && req.method === "POST") {
515
640
  try {
516
641
  assertWriteRequest(req, csrfCapability);