larkin 0.4.11 → 0.4.13

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.
@@ -44,13 +44,14 @@ function managedOfficialLarkCli(agent, env, resolve = official_lark_cli_js_1.res
44
44
  }
45
45
  function readPrivateJson(file, label) {
46
46
  const directory = node_fs_1.default.lstatSync(node_path_1.default.dirname(file));
47
- if (!directory.isDirectory() || directory.isSymbolicLink() || !(0, secure_metadata_js_1.exactMode)(directory, 0o700)
47
+ if (!directory.isDirectory() || directory.isSymbolicLink()
48
+ || (!(0, secure_metadata_js_1.exactMode)(directory, 0o700) && !(0, secure_metadata_js_1.exactMode)(directory, 0o500))
48
49
  || (typeof process.getuid === "function" && directory.uid !== process.getuid()))
49
50
  throw new Error(`${label} 目录不安全`);
50
51
  const fd = node_fs_1.default.openSync(file, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW || 0));
51
52
  try {
52
53
  const stat = node_fs_1.default.fstatSync(fd);
53
- if (!stat.isFile() || !(0, secure_metadata_js_1.exactMode)(stat, 0o600)
54
+ if (!stat.isFile() || (!(0, secure_metadata_js_1.exactMode)(stat, 0o600) && !(0, secure_metadata_js_1.exactMode)(stat, 0o400))
54
55
  || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
55
56
  throw new Error(`${label} 文件不安全`);
56
57
  return JSON.parse(node_fs_1.default.readFileSync(fd, "utf8"));
@@ -34,13 +34,14 @@ export function managedOfficialLarkCli(agent, env, resolve = resolveOfficialLark
34
34
  }
35
35
  function readPrivateJson(file, label) {
36
36
  const directory = fs.lstatSync(path.dirname(file));
37
- if (!directory.isDirectory() || directory.isSymbolicLink() || !exactMode(directory, 0o700)
37
+ if (!directory.isDirectory() || directory.isSymbolicLink()
38
+ || (!exactMode(directory, 0o700) && !exactMode(directory, 0o500))
38
39
  || (typeof process.getuid === "function" && directory.uid !== process.getuid()))
39
40
  throw new Error(`${label} 目录不安全`);
40
41
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
41
42
  try {
42
43
  const stat = fs.fstatSync(fd);
43
- if (!stat.isFile() || !exactMode(stat, 0o600)
44
+ if (!stat.isFile() || (!exactMode(stat, 0o600) && !exactMode(stat, 0o400))
44
45
  || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
45
46
  throw new Error(`${label} 文件不安全`);
46
47
  return JSON.parse(fs.readFileSync(fd, "utf8"));
package/dist/app/cli.mjs CHANGED
@@ -87,7 +87,7 @@ Reset replaces one idle, zero-backlog Runtime session. Recover is an explicit op
87
87
  larkin pi-auth logout <provider> [--agent <App ID>]
88
88
  Show non-sensitive official Pi credential metadata or remove one target provider credential.`,
89
89
  comment: `Usage: larkin comment reply --message-id <doc_comment_message_id> --text '<reply>' --json
90
- Reply once, as the Runtime-bound Bot, to the exact cloud-document comment locator supplied by canonical Inbox.`,
90
+ 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
91
  telemetry: `Usage: larkin telemetry <status|export|import|flush>
92
92
  Inspect the durable local trace queue, move an offline bundle, or upload queued OTLP traces.`,
93
93
  };
@@ -304,18 +304,22 @@ function runCommentReply(argv, privateEnv, io, dependencies, store) {
304
304
  if (!store.inboxTargetIsFresh(targetKey))
305
305
  throw new Error("comment reply 需要先 poll 当前 document-comment target 的最新 Inbox 消息");
306
306
  const digest = createHash("sha256").update(input.text).digest("hex");
307
+ const ledgerKey = `${input.messageId}::${digest}`;
307
308
  const claim = store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
308
309
  state.document_comment_replies ??= {};
309
- const prior = state.document_comment_replies[input.messageId];
310
- if (prior?.status === "sent" && prior.digest === digest)
310
+ const legacy = state.document_comment_replies[input.messageId];
311
+ const prior = state.document_comment_replies[ledgerKey]
312
+ ?? (legacy?.digest === digest ? legacy : undefined);
313
+ if (prior?.status === "sent")
311
314
  return "sent";
312
- if (prior?.status === "sending" && prior.digest === digest)
315
+ const messageEntries = Object.entries(state.document_comment_replies)
316
+ .filter(([key]) => key === input.messageId || key.startsWith(`${input.messageId}::`));
317
+ if (messageEntries.some(([, entry]) => entry.status === "sending"))
313
318
  return "ambiguous";
314
- if (prior && prior.status !== "failed" && prior.digest !== digest)
315
- return "conflict";
316
- state.document_comment_replies[input.messageId] = { digest, status: "sending", updated_at: new Date().toISOString() };
317
- const keys = Object.keys(state.document_comment_replies);
318
- for (const stale of keys.slice(0, Math.max(0, keys.length - 512)))
319
+ state.document_comment_replies[ledgerKey] = { digest, status: "sending", updated_at: new Date().toISOString() };
320
+ const terminalKeys = Object.keys(state.document_comment_replies)
321
+ .filter((key) => state.document_comment_replies[key]?.status !== "sending");
322
+ for (const stale of terminalKeys.slice(0, Math.max(0, terminalKeys.length - 512)))
319
323
  delete state.document_comment_replies[stale];
320
324
  return "ready";
321
325
  });
@@ -324,9 +328,7 @@ function runCommentReply(argv, privateEnv, io, dependencies, store) {
324
328
  return 0;
325
329
  }
326
330
  if (claim === "ambiguous")
327
- throw new Error("comment reply 上次调用结果不明确,已 fail-closed 以避免重复评论;请由用户检查原评论线程");
328
- if (claim === "conflict")
329
- throw new Error("comment reply 已为同一 Inbox 消息提交不同正文,拒绝覆盖或重复发送");
331
+ throw new Error("comment reply 上次调用结果不明确,已 fail-closed 以避免重复评论;请勿改写正文重试,请由用户检查原评论线程");
330
332
  const nativeArgs = target.topLevel
331
333
  ? [
332
334
  "drive", "file.comments", "create_v2",
@@ -353,7 +355,7 @@ function runCommentReply(argv, privateEnv, io, dependencies, store) {
353
355
  if (terminalStatus) {
354
356
  store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
355
357
  state.document_comment_replies ??= {};
356
- state.document_comment_replies[input.messageId] = {
358
+ state.document_comment_replies[ledgerKey] = {
357
359
  digest,
358
360
  status: terminalStatus,
359
361
  updated_at: new Date().toISOString(),
@@ -462,11 +462,15 @@ export function removeControlAuthority(larkinHome, expectedToken) {
462
462
  throw error;
463
463
  }
464
464
  }
465
- export function createSupervisorControlServer({ larkinHome, authorityToken, ensureDashboard, }) {
465
+ export function createSupervisorControlServer({ larkinHome, authorityToken, ensureDashboard, onAgentUpserted, }) {
466
466
  let socket = "";
467
467
  let socketRoot = "";
468
468
  let socketIdentity = null;
469
469
  const completed = new Map();
470
+ const operationConflict = (operationId, agentId) => ({
471
+ ok: false, operationId, ...(agentId ? { agentId } : {}), code: "operation_conflict",
472
+ error: "operationId 已绑定其他 Agent 或操作",
473
+ });
470
474
  let server = null;
471
475
  return {
472
476
  async start() {
@@ -490,9 +494,15 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
490
494
  let request;
491
495
  try {
492
496
  request = JSON.parse(line);
493
- if (!OPERATION_ID.test(String(request.operationId || "")) || request.operation !== "ensure-dashboard"
497
+ const isDashboardRecovery = request.operation === "ensure-dashboard";
498
+ const isAgentUpsert = request.operation === "agent-upserted";
499
+ const allowedKeys = isDashboardRecovery
500
+ ? ["operationId", "operation", "authorization"]
501
+ : isAgentUpsert ? ["operationId", "operation", "authorization", "agentId"] : [];
502
+ if (!OPERATION_ID.test(String(request.operationId || "")) || (!isDashboardRecovery && !isAgentUpsert)
494
503
  || !AUTHORIZATION.test(String(request.authorization || ""))
495
- || Object.keys(request).some((key) => !["operationId", "operation", "authorization"].includes(key))) {
504
+ || (isAgentUpsert && !AGENT_ID.test(String(request.agentId || "")))
505
+ || Object.keys(request).some((key) => !allowedKeys.includes(key))) {
496
506
  throw new Error("invalid supervisor control request");
497
507
  }
498
508
  const authority = assertSupervisorAuthority(larkinHome, authorityToken);
@@ -505,17 +515,28 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
505
515
  }
506
516
  const replay = completed.get(request.operationId);
507
517
  if (replay) {
508
- connection.end(`${JSON.stringify(replay)}\n`);
518
+ const sameRequest = replay.operation === request.operation
519
+ && replay.agentId === request.agentId;
520
+ const response = sameRequest ? replay.response : operationConflict(request.operationId, request.agentId);
521
+ connection.end(`${JSON.stringify(response)}\n`);
509
522
  return;
510
523
  }
511
524
  let response;
512
525
  try {
513
- response = { ok: true, operationId: request.operationId, state: await ensureDashboard() };
526
+ if (request.operation === "agent-upserted") {
527
+ await onAgentUpserted?.(request.agentId);
528
+ response = { ok: true, operationId: request.operationId, state: "agent-recorded" };
529
+ }
530
+ else {
531
+ response = { ok: true, operationId: request.operationId, state: await ensureDashboard() };
532
+ }
514
533
  }
515
534
  catch (error) {
516
535
  response = { ok: false, operationId: request.operationId, error: error instanceof Error ? error.message : String(error) };
517
536
  }
518
- completed.set(request.operationId, response);
537
+ completed.set(request.operationId, {
538
+ operation: request.operation, agentId: request.agentId, response,
539
+ });
519
540
  while (completed.size > 256)
520
541
  completed.delete(completed.keys().next().value);
521
542
  connection.end(`${JSON.stringify(response)}\n`);
@@ -812,6 +833,43 @@ async function sendSupervisorRecovery(larkinHome, operationId, timeoutMs) {
812
833
  });
813
834
  });
814
835
  }
836
+ async function sendSupervisorAgentUpsert(larkinHome, agentId, operationId, timeoutMs) {
837
+ const authority = assertSupervisorAuthority(larkinHome);
838
+ assertSecureSocketDirectory(authority.socketRoot);
839
+ const socket = authority.supervisorSocketPath;
840
+ const stat = fs.lstatSync(socket);
841
+ if (!stat.isSocket() || stat.isSymbolicLink()
842
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
843
+ || !notGroupOrWorldAccessible(stat))
844
+ throw new Error("supervisor control socket 不安全");
845
+ return await new Promise((resolve, reject) => {
846
+ const client = net.createConnection(socket);
847
+ const timer = setTimeout(() => { client.destroy(); reject(new Error("supervisor agent upsert control timeout")); }, timeoutMs);
848
+ let input = "";
849
+ client.setEncoding("utf8");
850
+ client.once("error", (error) => { clearTimeout(timer); reject(error); });
851
+ client.once("connect", () => client.write(`${JSON.stringify({
852
+ operationId, operation: "agent-upserted", agentId, authorization: authority.token,
853
+ })}\n`));
854
+ client.on("data", (chunk) => {
855
+ input += chunk;
856
+ const newline = input.indexOf("\n");
857
+ if (newline < 0)
858
+ return;
859
+ clearTimeout(timer);
860
+ client.end();
861
+ try {
862
+ resolve(JSON.parse(input.slice(0, newline)));
863
+ }
864
+ catch (error) {
865
+ reject(error);
866
+ }
867
+ });
868
+ });
869
+ }
870
+ export async function requestSupervisorAgentUpsert({ larkinHome, agentId, operationId = crypto.randomUUID(), timeoutMs = 30_000, }) {
871
+ return sendSupervisorAgentUpsert(larkinHome, agentId, operationId, timeoutMs);
872
+ }
815
873
  export async function requestDashboardRecovery({ larkinHome, operationId = crypto.randomUUID(), timeoutMs = 30_000, }) {
816
874
  const deadline = Date.now() + timeoutMs;
817
875
  let lastError;
package/dist/app/run.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import * as larkinConfig from "../platform/config.mjs";
9
9
  import { currentProcessMetadata } from "../platform/process-inspect.cjs";
10
10
  import { acquireProcessLock, readProcessState, terminateOwnedProcess, waitForProcessExit, } from "../platform/process-state.mjs";
11
- import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
11
+ import { hydrateRuntimeAgent, syncAgentProfile, validateAgentProfile } from "./runtime-agent-config.mjs";
12
12
  import { cleanupStaleAgentControlSocket, createSupervisorControlServer, initializeControlAuthority, removeControlAuthority, requestDashboardRecovery, } from "./local-control.mjs";
13
13
  import { internalCommandSpec, processCommandToken } from "./internal-command.mjs";
14
14
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
@@ -80,6 +80,9 @@ export async function main() {
80
80
  catch (error) {
81
81
  return die(error.message);
82
82
  }
83
+ // Capture this supervisor's startup membership before setup can persist a new
84
+ // Agent. Unqualified selection must not expand from later durable config.
85
+ const initialNames = new Set(names);
83
86
  const before = readProcessState(configDir);
84
87
  if (before.supervisor.state === "owned") {
85
88
  if (before.dashboard.state !== "owned") {
@@ -124,11 +127,16 @@ export async function main() {
124
127
  die("无法取得 supervisor start identity");
125
128
  }
126
129
  const controlToken = initializeControlAuthority(configDir, { pid: process.pid, processStartToken: supervisorStartToken });
130
+ // This supervisor's successful hot-attaches are the only membership additions
131
+ // that survive a daemon crash. Durable config apply state describes freshness,
132
+ // not which Agents this explicitly selected supervisor owns.
133
+ const hotAttachedNames = new Set();
127
134
  let ensureDashboardHandler = () => "starting";
128
135
  const supervisorControl = createSupervisorControlServer({
129
136
  larkinHome: configDir,
130
137
  authorityToken: controlToken,
131
138
  ensureDashboard: () => ensureDashboardHandler(),
139
+ onAgentUpserted: (agentId) => { hotAttachedNames.add(agentId); },
132
140
  });
133
141
  try {
134
142
  await supervisorControl.start();
@@ -141,16 +149,53 @@ export async function main() {
141
149
  catch { /* best effort */ }
142
150
  die(`supervisor control 启动失败:${error instanceof Error ? error.message : String(error)}`);
143
151
  }
144
- const agents = [];
145
- try {
146
- for (const name of names) {
147
- const stored = config.agents[name];
152
+ let agents = [];
153
+ const serverId = config.serverId;
154
+ if (!serverId)
155
+ die("配置缺少 serverId");
156
+ const runtimeEnv = {
157
+ ...process.env,
158
+ LARKIN_HOME: configDir,
159
+ LARKIN_CONFIG_DIR: configDir,
160
+ LARKIN_AGENT_TRANSPORT_MODULE: path.join(HERE, "..", "agent", "agent-transport.cjs"),
161
+ LARKIN_SERVER_ID: String(serverId),
162
+ LARKIN_AGENTS_CONFIG: "[]",
163
+ LARKIN_CONTROL_AUTHORIZATION: controlToken,
164
+ };
165
+ if (argv.includes("--dry-run"))
166
+ runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
167
+ // Re-read durable config for every daemon launch instead of reusing the startup
168
+ // snapshot: a hot attach updates the child and durable config, but cannot mutate
169
+ // this supervisor's old closure. Membership remains initialNames plus successful
170
+ // hot attaches recorded during this supervisor lifetime.
171
+ const refreshDaemonAgents = (reuseExistingProfiles) => {
172
+ const latest = larkinConfig.loadConfig(process.env);
173
+ if (latest.configDir !== configDir)
174
+ throw new Error("配置目录在 supervisor 运行期间发生变化");
175
+ if (!latest.config.serverId || !Object.keys(latest.config.agents).length)
176
+ throw new Error("配置缺少 serverId/agents");
177
+ const latestNames = [...new Set([...initialNames, ...hotAttachedNames])];
178
+ const refreshed = [];
179
+ for (const name of latestNames) {
180
+ const stored = latest.config.agents[name];
148
181
  if (!stored)
149
182
  throw new Error(`Agent ${name} 不存在`);
150
183
  const agent = hydrateRuntimeAgent(configDir, stored);
151
- syncAgentProfile(agent, { ...process.env, LARKIN_CONFIG_DIR: configDir });
152
- agents.push(agent);
184
+ if (reuseExistingProfiles)
185
+ validateAgentProfile(agent);
186
+ else
187
+ syncAgentProfile(agent, { ...process.env, LARKIN_CONFIG_DIR: configDir });
188
+ refreshed.push(agent);
153
189
  }
190
+ agents = refreshed;
191
+ names = latestNames;
192
+ runtimeEnv.LARKIN_SERVER_ID = String(latest.config.serverId);
193
+ runtimeEnv.LARKIN_AGENTS_CONFIG = JSON.stringify(agents);
194
+ for (const agent of agents)
195
+ traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
196
+ };
197
+ try {
198
+ refreshDaemonAgents(false);
154
199
  }
155
200
  catch (error) {
156
201
  await supervisorControl.close().catch(() => { });
@@ -183,22 +228,6 @@ export async function main() {
183
228
  launchLock.release();
184
229
  die(`dashboard PID ${before.dashboard.pid} 身份无法确认;拒绝替换`);
185
230
  }
186
- const serverId = config.serverId;
187
- if (!serverId)
188
- die("配置缺少 serverId");
189
- const runtimeEnv = {
190
- ...process.env,
191
- LARKIN_HOME: configDir,
192
- LARKIN_CONFIG_DIR: configDir,
193
- LARKIN_AGENT_TRANSPORT_MODULE: path.join(HERE, "..", "agent", "agent-transport.cjs"),
194
- LARKIN_SERVER_ID: String(serverId),
195
- LARKIN_AGENTS_CONFIG: JSON.stringify(agents),
196
- LARKIN_CONTROL_AUTHORIZATION: controlToken,
197
- };
198
- if (argv.includes("--dry-run"))
199
- runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
200
- for (const agent of agents)
201
- traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
202
231
  let stopping = false;
203
232
  let dashboard = null;
204
233
  // Set when the daemon-restart path stops the dashboard on purpose (port
@@ -222,7 +251,19 @@ export async function main() {
222
251
  // exiting on transient failures (e.g. network/proxy ECONNRESET during Feishu
223
252
  // auth). Only when the budget is exhausted does the supervisor exit so the
224
253
  // external process manager (launchd) takes over as the final respawn layer.
254
+ let initialRefreshPending = true;
225
255
  const launchDaemon = () => {
256
+ if (initialRefreshPending)
257
+ initialRefreshPending = false;
258
+ else
259
+ try {
260
+ refreshDaemonAgents(true);
261
+ }
262
+ catch (error) {
263
+ console.error(`✗ daemon 启动前重新加载配置失败:${error instanceof Error ? error.message : String(error)}`);
264
+ resolveDaemonFinal?.({ code: null, signal: null });
265
+ return;
266
+ }
226
267
  daemon = spawn(daemonSpec.command, daemonSpec.args, { env: runtimeEnv, stdio: "inherit" });
227
268
  for (const agent of agents)
228
269
  traceProcessBoundary(runtimeEnv, "supervisor:daemon-spawned", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId), childPid: daemon.pid ?? null });
@@ -257,7 +298,6 @@ export async function main() {
257
298
  const oldDashboard = dashboard;
258
299
  if (oldDashboard && oldDashboard.exitCode === null && oldDashboard.signalCode === null) {
259
300
  intentionalDashboardExit = oldDashboard;
260
- dashboard = null;
261
301
  oldDashboard.kill("SIGTERM");
262
302
  // If the old dashboard ignores SIGTERM (observed in production holding
263
303
  // the dashboard port for minutes), escalate so the port is released
@@ -17,6 +17,13 @@ function assertSecureProfileDirectory(directory) {
17
17
  || !exactMode(stat, 0o700))
18
18
  throw new Error("lark-cli profile 目录不安全");
19
19
  }
20
+ function assertValidProfileDirectory(directory) {
21
+ const stat = fs.lstatSync(directory);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink()
23
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
24
+ || (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
25
+ throw new Error("lark-cli profile 目录不安全");
26
+ }
20
27
  function captureProfileSnapshot(file) {
21
28
  let fd = null;
22
29
  try {
@@ -233,22 +240,44 @@ function runOfficialLarkCliAsync(command, args, env, dependencies) {
233
240
  });
234
241
  });
235
242
  }
243
+ function expectedRuntimeCommandShim() {
244
+ const standalone = process.env.LARKIN_STANDALONE === "1";
245
+ const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
246
+ const argumentsPrefix = standalone ? [] : [binaryEntry];
247
+ const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
248
+ return `#!/bin/sh\nexec ${command} "$@"\n`;
249
+ }
236
250
  export function installRuntimeCommandShims(agent) {
237
251
  const stateDir = path.resolve(agent.stateDir);
238
252
  const commandDir = path.join(stateDir, "runtime-bin");
239
253
  assertSecureRuntimeCommandDirectory(commandDir);
240
- const standalone = process.env.LARKIN_STANDALONE === "1";
241
- const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
242
- for (const [name, argumentsPrefix] of [["larkin", standalone ? [] : [binaryEntry]]]) {
254
+ const fileContents = expectedRuntimeCommandShim();
255
+ for (const name of ["larkin"]) {
243
256
  const file = path.join(commandDir, name);
244
257
  const temporary = path.join(commandDir, `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`);
245
- const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
246
- fs.writeFileSync(temporary, `#!/bin/sh\nexec ${command} "$@"\n`, { mode: 0o700, flag: "wx" });
258
+ fs.writeFileSync(temporary, fileContents, { mode: 0o700, flag: "wx" });
247
259
  fs.renameSync(temporary, file);
248
260
  fs.chmodSync(file, 0o700);
249
261
  }
250
262
  return commandDir;
251
263
  }
264
+ function validateRuntimeCommandShims(agent) {
265
+ const commandDir = path.join(path.resolve(agent.stateDir), "runtime-bin");
266
+ const directory = fs.lstatSync(commandDir);
267
+ if (!directory.isDirectory() || directory.isSymbolicLink()
268
+ || (typeof process.getuid === "function" && directory.uid !== process.getuid())
269
+ || (!exactMode(directory, 0o700) && !exactMode(directory, 0o500)))
270
+ throw new Error("Runtime command shim 目录不安全");
271
+ const file = path.join(commandDir, "larkin");
272
+ const stat = fs.lstatSync(file);
273
+ if (!stat.isFile() || stat.isSymbolicLink()
274
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
275
+ || (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
276
+ throw new Error("Runtime command shim 不安全");
277
+ if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim()) {
278
+ throw new Error("Runtime command shim 内容无效");
279
+ }
280
+ }
252
281
  export function sourceProjection(agent, env) {
253
282
  const configDir = path.resolve(env.LARKIN_CONFIG_DIR || "");
254
283
  if (!configDir)
@@ -299,7 +328,31 @@ function validateSourceProjection(file, agent) {
299
328
  throw new Error(`Agent ${agent.agentId} lark-channel source projection tenant mismatch`);
300
329
  }
301
330
  }
331
+ export function validateAgentProfile(agent) {
332
+ // Validate the profile root before resolving any leaf paths. The supervisor's
333
+ // restart fast path must not follow a replaced symlink or unsafe root.
334
+ assertValidProfileDirectory(agent.larkConfigDir);
335
+ const sourceFile = larkChannelSourceConfigPath(agent);
336
+ const workspaceFile = larkChannelWorkspaceConfigPath(agent);
337
+ const source = captureProfileSnapshot(sourceFile);
338
+ if (!source)
339
+ throw new Error(`Agent ${agent.agentId} lark-channel source projection missing`);
340
+ validateSourceProjection(sourceFile, agent);
341
+ const workspace = captureProfileSnapshot(workspaceFile);
342
+ if (!workspace)
343
+ throw new Error(`Agent ${agent.agentId} lark-channel workspace config missing`);
344
+ validateExclusiveBotProfile(workspace, agent);
345
+ validateRuntimeCommandShims(agent);
346
+ assertAgentWorkspaceBound(agent);
347
+ }
302
348
  export function syncAgentProfile(agent, env, dependencies = {}) {
349
+ if (!dependencies.forceRebind) {
350
+ try {
351
+ validateAgentProfile(agent);
352
+ return;
353
+ }
354
+ catch { /* absent or stale state requires exactly one bind */ }
355
+ }
303
356
  const expected = path.join(path.resolve(env.LARKIN_CONFIG_DIR || ""), "state", "agents", agent.agentId, "lark-cli-config");
304
357
  if (path.resolve(agent.larkConfigDir) !== expected)
305
358
  throw new Error("lark-cli profile 路径不是 canonical contained 路径");
@@ -8,7 +8,7 @@ import { createRuntimeHost } from "../runtime/runtime-host.mjs";
8
8
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
9
9
  import { loadConfig, markConfigApplied, runtimeConfigSignature } from "../platform/config.mjs";
10
10
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
11
- import { createAgentControlServer } from "./local-control.mjs";
11
+ import { createAgentControlServer, requestSupervisorAgentUpsert } from "./local-control.mjs";
12
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";
@@ -104,6 +104,11 @@ export async function main(env = process.env, overrides = {}) {
104
104
  // Only the selected profile is synchronized; active profiles and their directory
105
105
  // are never quarantined or rebuilt during hot attach.
106
106
  await hostShell.upsertAgent(agent);
107
+ const tracked = await requestSupervisorAgentUpsert({
108
+ larkinHome: env.LARKIN_HOME, agentId: agent.agentId, operationId: request.operationId,
109
+ });
110
+ if (!tracked.ok)
111
+ throw new Error(tracked.error || "supervisor 未记录 Agent 热挂载");
107
112
  },
108
113
  async resetSession(request) {
109
114
  const result = await hostShell.resetSession(request.agentId, request.waitReadyMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [