@runuai/host 0.9.0 → 0.9.2

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.
@@ -35,7 +35,7 @@ import {
35
35
  } from "./agents/types";
36
36
  import { ACTIVE_STATUSES } from "./task-status";
37
37
  import { getHostTask, upsertHostTask } from "./runtime-state";
38
- import { clearRefresh, setupTaskGithub } from "./github-tokens";
38
+ import { clearRefresh, reconcileTaskGitAuth } from "./github-tokens";
39
39
  import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
40
40
  import { dockerCli } from "./docker-exec";
41
41
  import {
@@ -82,6 +82,9 @@ export type HostEventSubscriber = (event: HostEvent) => void;
82
82
  interface Channel {
83
83
  taskId: string;
84
84
  roster: Roster;
85
+ /** ADR-083: host-derived role assignment for execution-profile enforcement. */
86
+ mode: "open" | "secretary";
87
+ secretaryAgentId?: string;
85
88
  sessions: Map<string, AgentSession>;
86
89
  /** Session-spawn inputs, kept so sessions can be started lazily. */
87
90
  containerName: string;
@@ -299,6 +302,33 @@ export class Orchestrator {
299
302
  // -- channel lifecycle ----------------------------------------------------
300
303
 
301
304
  registerChannelSpec(spec: ChannelEnsureInput): void {
305
+ // Host-side fail closed. The cloud validates this too, but the adapter's
306
+ // advertised communicator guarantee must not depend on every caller
307
+ // spelling the id correctly: an unmatched designation would otherwise
308
+ // make `executionProfileFor` return undefined and spawn a full-access
309
+ // process in a task explicitly marked Secretary.
310
+ if (!hasValidSecretarySelection(spec)) {
311
+ throw new Error(
312
+ "secretary mode requires exactly one roster agent matching secretaryAgentId",
313
+ );
314
+ }
315
+ const channel = this.channels.get(spec.taskId);
316
+ const nextMode = spec.mode === "secretary" ? "secretary" : "open";
317
+ if (
318
+ channel &&
319
+ (channel.mode !== nextMode ||
320
+ (nextMode === "secretary" &&
321
+ channel.secretaryAgentId !== spec.secretaryAgentId))
322
+ ) {
323
+ // v0 makes mode/role creation-time only. More importantly, a live
324
+ // Open→Secretary rewrite cannot be applied as ordinary roster refresh:
325
+ // the designated agent may already be a durable full-access process.
326
+ // Changing these fields without recycling that exact session would
327
+ // advertise a communicator boundary the running process does not have.
328
+ throw new Error(
329
+ "a live channel cannot change secretary mode or designation",
330
+ );
331
+ }
302
332
  this.channelSpecs.set(spec.taskId, spec);
303
333
  if (this.blockedTasks.has(spec.taskId)) return;
304
334
  // ADR-049: the cloud re-sends the spec on every message AND right after a
@@ -306,7 +336,6 @@ export class Orchestrator {
306
336
  // fold the fresh spec in: append new roster agents (their sessions spawn
307
337
  // in the reconcile pass of ensureSessions) and rebuild every preamble so
308
338
  // a later respawn briefs agents with the CURRENT roster + humans.
309
- const channel = this.channels.get(spec.taskId);
310
339
  if (channel) this.refreshChannel(channel, spec);
311
340
  }
312
341
 
@@ -335,6 +364,8 @@ export class Orchestrator {
335
364
  }
336
365
  }
337
366
  channel.humans = spec.humans ?? [];
367
+ channel.mode = spec.mode === "secretary" ? "secretary" : "open";
368
+ channel.secretaryAgentId = spec.secretaryAgentId;
338
369
  channel.browserTesting = spec.browserTesting === true;
339
370
  channel.sharedFiles = spec.sharedFiles ?? "ro";
340
371
  channel.mcpConnections = spec.mcpConnections ?? [];
@@ -358,6 +389,8 @@ export class Orchestrator {
358
389
  channel.humans,
359
390
  channel.browserTesting,
360
391
  channel.sharedFiles,
392
+ spec.mode,
393
+ spec.secretaryAgentId,
361
394
  ),
362
395
  );
363
396
  }
@@ -429,6 +462,8 @@ export class Orchestrator {
429
462
  spec.humans,
430
463
  spec.browserTesting,
431
464
  spec.sharedFiles ?? "ro",
465
+ spec.mode,
466
+ spec.secretaryAgentId,
432
467
  ),
433
468
  );
434
469
  // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
@@ -447,6 +482,8 @@ export class Orchestrator {
447
482
  const channel: Channel = {
448
483
  taskId,
449
484
  roster,
485
+ mode: spec.mode === "secretary" ? "secretary" : "open",
486
+ secretaryAgentId: spec.secretaryAgentId,
450
487
  sessions: new Map(),
451
488
  containerName: `task-${taskId.toLowerCase()}-app-1`,
452
489
  preambles,
@@ -1163,6 +1200,7 @@ export class Orchestrator {
1163
1200
  agent,
1164
1201
  containerName: channel.containerName,
1165
1202
  systemPreamble: channel.preambles.get(agent.id) ?? "",
1203
+ executionProfile: this.executionProfileFor(channel, agent.id),
1166
1204
  agentEnv: this.accountAgentEnv(
1167
1205
  channel,
1168
1206
  agent,
@@ -1186,6 +1224,21 @@ export class Orchestrator {
1186
1224
  }
1187
1225
  }
1188
1226
 
1227
+ /**
1228
+ * The browser stores only the task-level secretary identity. Derive the
1229
+ * restricted adapter profile here so a roster entry can never self-assert a
1230
+ * weaker/stronger execution policy through its JSON payload.
1231
+ */
1232
+ private executionProfileFor(
1233
+ channel: Channel,
1234
+ agentId: string,
1235
+ ): "communicator" | undefined {
1236
+ return channel.mode === "secretary" &&
1237
+ channel.secretaryAgentId === agentId
1238
+ ? "communicator"
1239
+ : undefined;
1240
+ }
1241
+
1189
1242
  /**
1190
1243
  * ADR-076: per-agent exec env = the uai token base + the SELECTED engine
1191
1244
  * account's env. Picks the least-recently-used, non-cooling account for the
@@ -1309,27 +1362,31 @@ export class Orchestrator {
1309
1362
  const initialRoster = [...channel.roster];
1310
1363
 
1311
1364
  // Set the task creator's git author identity in the container (ADR-029).
1312
- // The SSH key itself is installed earlier by task-up.sh (host clone +
1313
- // container), using the creator's per-user key. Best-effort. Awaited but
1365
+ // The optional SSH signing/fallback key is installed earlier by
1366
+ // task-up.sh using the creator's per-user key. Best-effort. Awaited but
1314
1367
  // async (docker exec via dockerCli) so it doesn't block the event loop —
1315
1368
  // this runs on every channel ensure, including each post-restart reconnect.
1316
1369
  await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);
1317
1370
  if (!this.isActiveChannel(channel)) return false;
1318
1371
 
1319
- // GitHub auth for `gh` (ADR-027): mint + inject the access token and
1320
- // (re)start its refresh schedule. Done here — not only at task-up — so it
1321
- // survives a host restart: recovery recreates the channel and re-runs
1322
- // ensureSessions, whereas the in-memory refresh timer from the original
1323
- // task-up is gone. Guarded + best-effort + non-blocking; the token lands
1324
- // before the agents (spawned just below) make their first `gh` call.
1372
+ // GitHub auth for both `gh` and HTTPS Git (ADR-027): inject the token,
1373
+ // configure gh's credential helper, and (re)start refresh. Await the
1374
+ // serialized reconcile so agents cannot race their first fetch/push after
1375
+ // task creation, reconnect, or host recovery.
1325
1376
  if (task.ownerUserId) {
1326
- void setupTaskGithub(channel.taskId, task.ownerUserId);
1377
+ await reconcileTaskGitAuth(channel.taskId, task.ownerUserId);
1327
1378
  }
1328
1379
 
1329
- // SSH push identity + its git config, re-asserted like the gh token above
1330
- // (one-shot at task-up proved fragile recreated containers lose the key
1331
- // silently; live 2026-07-21). Best-effort + non-blocking.
1332
- void ensureTaskSshIdentity(channel.taskId, task.ownerUserId);
1380
+ // SSH signing identity, re-asserted independently of transport. Connected
1381
+ // users still push through gh over HTTPS; disconnected users select the
1382
+ // SSH transport fallback inside the reconcile above.
1383
+ void ensureTaskSshIdentity(channel.taskId, task.ownerUserId).catch((err) =>
1384
+ console.warn(
1385
+ `[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
1386
+ err instanceof Error ? err.message : String(err)
1387
+ }`,
1388
+ ),
1389
+ );
1333
1390
 
1334
1391
  // ADR-047: install any package skills (native Claude Agent Skills) into the
1335
1392
  // container BEFORE spawning agents, so they're discoverable on the first
@@ -1380,9 +1437,10 @@ export class Orchestrator {
1380
1437
  {
1381
1438
  taskId: channel.taskId,
1382
1439
  agent,
1383
- containerName: channel.containerName,
1384
- systemPreamble: channel.preambles.get(agent.id) ?? "",
1385
- agentEnv: this.accountAgentEnv(
1440
+ containerName: channel.containerName,
1441
+ systemPreamble: channel.preambles.get(agent.id) ?? "",
1442
+ executionProfile: this.executionProfileFor(channel, agent.id),
1443
+ agentEnv: this.accountAgentEnv(
1386
1444
  channel,
1387
1445
  agent,
1388
1446
  agentCliEnv(
@@ -1525,7 +1583,7 @@ export class Orchestrator {
1525
1583
  // Cancel any armed auto-retry chain so this manual attempt runs fresh
1526
1584
  // (setup's duplicate-chain guard would otherwise no-op it).
1527
1585
  clearRefresh(taskId);
1528
- const ok = await setupTaskGithub(taskId, owner);
1586
+ const ok = await reconcileTaskGitAuth(taskId, owner);
1529
1587
  this.emitSystemNote(
1530
1588
  taskId,
1531
1589
  ok
@@ -1785,6 +1843,7 @@ export class Orchestrator {
1785
1843
  agent,
1786
1844
  containerName: channel.containerName,
1787
1845
  systemPreamble: channel.preambles.get(agentId) ?? "",
1846
+ executionProfile: this.executionProfileFor(channel, agentId),
1788
1847
  agentEnv: boundAccount
1789
1848
  ? { ...base, ...boundAccount.execEnv }
1790
1849
  : this.accountAgentEnv(channel, agent, base),
@@ -1903,6 +1962,7 @@ export class Orchestrator {
1903
1962
  agent,
1904
1963
  containerName: channel.containerName,
1905
1964
  systemPreamble: channel.preambles.get(agentId) ?? "",
1965
+ executionProfile: this.executionProfileFor(channel, agentId),
1906
1966
  agentEnv: { ...base, ...next.execEnv },
1907
1967
  },
1908
1968
  );
@@ -2096,6 +2156,18 @@ export class Orchestrator {
2096
2156
  }
2097
2157
  }
2098
2158
 
2159
+ /** Exact host-wire role validation; never trims or canonicalises agent ids. */
2160
+ export function hasValidSecretarySelection(
2161
+ spec: Pick<ChannelEnsureInput, "mode" | "secretaryAgentId" | "agents">,
2162
+ ): boolean {
2163
+ if (spec.mode !== "secretary") return true;
2164
+ if (!spec.secretaryAgentId) return false;
2165
+ return (
2166
+ spec.agents.filter((agent) => agent.id === spec.secretaryAgentId).length ===
2167
+ 1
2168
+ );
2169
+ }
2170
+
2099
2171
  // ---------------------------------------------------------------------------
2100
2172
  // `@mention` addressing.
2101
2173
  // ---------------------------------------------------------------------------
@@ -2295,6 +2367,8 @@ export function buildSystemPreamble(
2295
2367
  humans?: ChannelHuman[],
2296
2368
  browserTesting?: boolean,
2297
2369
  sharedFiles?: string,
2370
+ mode?: "open" | "secretary",
2371
+ secretaryAgentId?: string,
2298
2372
  ): string {
2299
2373
  const channelList = roster
2300
2374
  .map((a) =>
@@ -2343,6 +2417,139 @@ export function buildSystemPreamble(
2343
2417
  (p) =>
2344
2418
  `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
2345
2419
  );
2420
+ const isSecretary =
2421
+ mode === "secretary" && secretaryAgentId === agent.id;
2422
+ const transcriptBrief =
2423
+ mode !== "secretary"
2424
+ ? [
2425
+ // Keep open-mode briefing byte-identical to the legacy preamble.
2426
+ "Because your input is only what you're addressed, you may be missing",
2427
+ "context from messages between the human and the other agents. The full",
2428
+ "channel transcript — every message + who wrote it (no tool calls) — is",
2429
+ "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
2430
+ "context (e.g. the human shared a file or instruction with another",
2431
+ "agent); it's appended live, so re-read it for the latest.",
2432
+ ]
2433
+ : isSecretary
2434
+ ? [
2435
+ "Because your input is only what you're addressed, you may be missing",
2436
+ "context from messages elsewhere in the channel. Secretary mode keeps",
2437
+ "two live transcripts: the backstage crew conversation is logged at",
2438
+ "`/workspace/.uai/chat.md`, and the frontstage human conversation is",
2439
+ "logged at `/workspace/.uai/chat-front.md`. Read BOTH whenever you need",
2440
+ "to catch up; each is appended live, so re-read them for the latest.",
2441
+ ]
2442
+ : [
2443
+ "Because your input is only what you're addressed, you may be missing",
2444
+ "context from the crew conversation. The backstage transcript — crew",
2445
+ "messages + who wrote them (no tool calls) — is logged at",
2446
+ "`/workspace/.uai/chat.md`. Read it whenever you need that context;",
2447
+ "it's appended live, so re-read it for the latest.",
2448
+ ];
2449
+ const secretaryRoleBrief = isSecretary
2450
+ ? [
2451
+ "## Secretary role",
2452
+ "",
2453
+ "You are the channel's sole human-facing communicator. Answer the human",
2454
+ "directly when the transcripts and read-only inspection are sufficient.",
2455
+ "When crew work is needed, address each recipient by @id with a concrete",
2456
+ "instruction; Uai delivers that instruction backstage. Synthesize the",
2457
+ "crew's replies for the human instead of forwarding a pile of raw updates.",
2458
+ "",
2459
+ "Your communicator execution profile is enforced by the engine: you can",
2460
+ "read and search the workspace, but you cannot edit files, run shell",
2461
+ "commands, write git state, deploy, or invoke arbitrary extensions. Do not",
2462
+ "claim that you performed a mutation; dispatch it to a crew agent instead.",
2463
+ "",
2464
+ ]
2465
+ : [];
2466
+ const groupMessageBrief = isSecretary
2467
+ ? [
2468
+ "When a human message names one or more crew agents, those names are",
2469
+ "routing hints for you — the crew has NOT been notified yet. Decide what",
2470
+ "work is actually needed, then dispatch a concrete @id instruction to each",
2471
+ "crew member you need. Do not merely say that someone else will answer.",
2472
+ ]
2473
+ : [
2474
+ "When a message already @-mentions several participants at once (the",
2475
+ "human asking the whole group, or a peer addressing multiple agents),",
2476
+ "it's a group broadcast — this is a GROUP CHAT and everyone named has",
2477
+ "ALREADY been notified and will answer for themselves. Just answer for",
2478
+ "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
2479
+ "question to them, or wait on them — no `I'll let @x speak`, `@x your",
2480
+ "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2481
+ "the message only wakes them again and spirals into duplicate replies.",
2482
+ "Say your piece and stop.",
2483
+ ];
2484
+ const handoffBrief = isSecretary
2485
+ ? [
2486
+ "When crew work finishes, synthesize the outcome for the human and make",
2487
+ "the next decision or blocker explicit. Do not abandon an unresolved",
2488
+ "request silently, and do not wake a peer for acknowledgments alone.",
2489
+ ]
2490
+ : [
2491
+ "Hand off when you finish your part of the work. When you've made",
2492
+ "and committed your changes, or completed a review, end your reply by",
2493
+ "@-mentioning the agent who should act next and telling them what you",
2494
+ "did and what you need (e.g. `@codex changes committed on <branch> —",
2495
+ "please review`, or `@claude review done, N issues to fix`). Don't",
2496
+ "abandon unfinished work silently — but once your part is done and no",
2497
+ "peer needs to act, it's fine to stop; only @-mention @you if you need",
2498
+ "their input or are handing back finished work for them to act on. Don't",
2499
+ "prolong an agent-to-agent exchange just to fill silence.",
2500
+ ];
2501
+ const checkInTranscriptBrief = isSecretary
2502
+ ? [
2503
+ "Read both transcript files named above, and speak ONLY if you have",
2504
+ "something substantive to add; otherwise reply with exactly `PASS` — a",
2505
+ "PASS reply is discarded and never shown to anyone, so it is always a",
2506
+ "safe way to decline a turn.",
2507
+ ]
2508
+ : [
2509
+ "Read the transcript, and speak ONLY if you have something substantive to",
2510
+ "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2511
+ "never shown to anyone, so it is always a safe way to decline a turn.",
2512
+ ];
2513
+ const workspaceBrief = isSecretary
2514
+ ? [
2515
+ "## Workspace layout",
2516
+ "",
2517
+ `Your read-only workspace root is \`${workspacePath}\`. It contains one`,
2518
+ "project worktree per repository:",
2519
+ "",
2520
+ ...projectLines,
2521
+ "",
2522
+ `Those worktrees are on \`${taskBranch}\`. Inspect files when that helps`,
2523
+ "you answer or scope a dispatch. Your profile cannot edit, commit, push,",
2524
+ "or open a PR; send that work to a crew agent.",
2525
+ "",
2526
+ "The `.uai/` directory is Uai scaffolding. Read the two transcript files",
2527
+ "there as described above; do not treat the rest as project content.",
2528
+ "",
2529
+ ]
2530
+ : [
2531
+ "## Workspace layout",
2532
+ "",
2533
+ `Your shell starts in \`${workspacePath}\` (the task workspace).`,
2534
+ "That directory is **not** itself a git repo — it holds one git",
2535
+ "worktree per project this task spans. Every project below is on the",
2536
+ "same task branch. To run git commands, **`cd` into one of the",
2537
+ "project directories first**:",
2538
+ "",
2539
+ ...projectLines,
2540
+ "",
2541
+ `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
2542
+ `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
2543
+ "create` (the container has gh authenticated). For multi-project",
2544
+ "tasks, each project's PR is independent — open one per project whose",
2545
+ "worktree you actually changed.",
2546
+ "",
2547
+ "The `.uai/` directory under each task is uai's own scaffolding",
2548
+ "(rendered Dockerfile, compose file, container scripts) — it is NOT",
2549
+ "part of the project. Never review, edit, stage, commit, or flag it;",
2550
+ "treat it as ignored, even though git may show it as untracked.",
2551
+ "",
2552
+ ];
2346
2553
  const comms = [
2347
2554
  "## uai task channel",
2348
2555
  "",
@@ -2378,63 +2585,21 @@ export function buildSystemPreamble(
2378
2585
  "you're waiting on the human), answer briefly and then wait — you don't",
2379
2586
  "need to @-mention anyone (including @you); they can see the channel.",
2380
2587
  "",
2381
- "When a message already @-mentions several participants at once (the",
2382
- "human asking the whole group, or a peer addressing multiple agents),",
2383
- "it's a group broadcast — this is a GROUP CHAT and everyone named has",
2384
- "ALREADY been notified and will answer for themselves. Just answer for",
2385
- "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
2386
- "question to them, or wait on them — no `I'll let @x speak`, `@x your",
2387
- "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2388
- "the message only wakes them again and spirals into duplicate replies.",
2389
- "Say your piece and stop.",
2588
+ ...groupMessageBrief,
2390
2589
  "",
2391
- "Because your input is only what you're addressed, you may be missing",
2392
- "context from messages between the human and the other agents. The full",
2393
- "channel transcript — every message + who wrote it (no tool calls) — is",
2394
- "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
2395
- "context (e.g. the human shared a file or instruction with another",
2396
- "agent); it's appended live, so re-read it for the latest.",
2590
+ ...transcriptBrief,
2397
2591
  "",
2592
+ ...secretaryRoleBrief,
2398
2593
  "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
2399
2594
  "uai hands it back to whoever prompted you — so when you're ANSWERING,",
2400
2595
  "just answer plainly; you don't need to re-mention the asker. Mention",
2401
2596
  "someone only to bring them in or hand work off. (2) You may occasionally",
2402
2597
  "receive a `[channel check-in]` asking you to catch up on the channel.",
2403
- "Read the transcript, and speak ONLY if you have something substantive to",
2404
- "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2405
- "never shown to anyone, so it is always a safe way to decline a turn.",
2406
- "",
2407
- "Hand off when you finish your part of the work. When you've made",
2408
- "and committed your changes, or completed a review, end your reply by",
2409
- "@-mentioning the agent who should act next and telling them what you",
2410
- "did and what you need (e.g. `@codex changes committed on <branch> —",
2411
- "please review`, or `@claude review done, N issues to fix`). Don't",
2412
- "abandon unfinished work silently — but once your part is done and no",
2413
- "peer needs to act, it's fine to stop; only @-mention @you if you need",
2414
- "their input or are handing back finished work for them to act on. Don't",
2415
- "prolong an agent-to-agent exchange just to fill silence.",
2416
- "",
2417
- "## Workspace layout",
2418
- "",
2419
- `Your shell starts in \`${workspacePath}\` (the task workspace).`,
2420
- "That directory is **not** itself a git repo — it holds one git",
2421
- "worktree per project this task spans. Every project below is on the",
2422
- "same task branch. To run git commands, **`cd` into one of the",
2423
- "project directories first**:",
2424
- "",
2425
- ...projectLines,
2426
- "",
2427
- `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
2428
- `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
2429
- "create` (the container has gh authenticated). For multi-project",
2430
- "tasks, each project's PR is independent — open one per project whose",
2431
- "worktree you actually changed.",
2598
+ ...checkInTranscriptBrief,
2432
2599
  "",
2433
- "The `.uai/` directory under each task is uai's own scaffolding",
2434
- "(rendered Dockerfile, compose file, container scripts) — it is NOT",
2435
- "part of the project. Never review, edit, stage, commit, or flag it;",
2436
- "treat it as ignored, even though git may show it as untracked.",
2600
+ ...handoffBrief,
2437
2601
  "",
2602
+ ...workspaceBrief,
2438
2603
  // ADR-062: shared files — only when this container actually carries the
2439
2604
  // mounts (task-up drops a marker; pre-feature containers have none).
2440
2605
  ...(sharedFiles &&
@@ -2443,12 +2608,12 @@ export function buildSystemPreamble(
2443
2608
  ? [
2444
2609
  "## Shared files",
2445
2610
  "",
2446
- `Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
2611
+ `Non-code files (${sharedFiles === "rw" && !isSecretary ? "read-write" : "READ-ONLY"} for this task):`,
2447
2612
  "- `/workspace/files/org` — the org's shared files (logos, specs,",
2448
2613
  " datasets), visible to every task in the org on this host.",
2449
2614
  "- `/workspace/files/me` — the task owner's personal files, shared",
2450
2615
  " across their tasks on this host.",
2451
- ...(sharedFiles === "rw"
2616
+ ...(sharedFiles === "rw" && !isSecretary
2452
2617
  ? [
2453
2618
  "When producing artifacts for humans, write them here (use a",
2454
2619
  "subdirectory named after the task to avoid collisions).",
@@ -2475,7 +2640,8 @@ export function buildSystemPreamble(
2475
2640
  // ADR-047: package skills are native Claude Agent Skills installed into the
2476
2641
  // container's skills dir (Claude-only). List them so the agent knows they're
2477
2642
  // available even if headless auto-discovery is unreliable.
2478
- ...(agent.kind === "claude" &&
2643
+ ...(!isSecretary &&
2644
+ agent.kind === "claude" &&
2479
2645
  (agent.skills ?? []).some((s) => s.type === "package")
2480
2646
  ? [
2481
2647
  "## Installed skills",
@@ -2489,7 +2655,7 @@ export function buildSystemPreamble(
2489
2655
  ]
2490
2656
  : []),
2491
2657
  // ADR-053: the in-container browser, when the project opted in.
2492
- ...(browserTesting
2658
+ ...(browserTesting && !isSecretary
2493
2659
  ? [
2494
2660
  "## Browser",
2495
2661
  "",
@@ -2512,7 +2678,11 @@ export function buildSystemPreamble(
2512
2678
  ]
2513
2679
  : []),
2514
2680
  // ADR-048: tell agents with permissions about their `uai` CLI.
2515
- ...((agent.permissions?.length ?? 0) > 0
2681
+ // The communicator profile deliberately exposes no Bash or arbitrary MCP
2682
+ // tool, so even a persona carrying CLI permissions cannot invoke cli.mjs.
2683
+ // Do not advertise unusable commands; a future typed communicator tool can
2684
+ // surface the safe task/todo subset without widening this boundary.
2685
+ ...((agent.permissions?.length ?? 0) > 0 && !isSecretary
2516
2686
  ? [
2517
2687
  "## The uai CLI",
2518
2688
  "",
@@ -2602,14 +2772,19 @@ export function buildSystemPreamble(
2602
2772
  "",
2603
2773
  ]
2604
2774
  : []),
2605
- "## Commit policy",
2606
- "",
2607
- "Commits are SSH-signed automatically (git is configured for it) — do",
2608
- "not disable or override signing. Do NOT add any `Co-Authored-By:`",
2609
- "trailers to commit messages, and do NOT add 'Generated with …' or any",
2610
- "tool/agent attribution footer to commit messages or PR/issue bodies.",
2611
- "Write commit messages and PR descriptions plainly, as the author, with",
2612
- "no agent attribution.",
2775
+ ...(!isSecretary
2776
+ ? [
2777
+ "## Commit policy",
2778
+ "",
2779
+ "When this task has an optional SSH signing key configured, Git signs",
2780
+ "commits automatically; do not disable or override it. Do NOT add any",
2781
+ "`Co-Authored-By:`",
2782
+ "trailers to commit messages, and do NOT add 'Generated with …' or any",
2783
+ "tool/agent attribution footer to commit messages or PR/issue bodies.",
2784
+ "Write commit messages and PR descriptions plainly, as the author, with",
2785
+ "no agent attribution.",
2786
+ ]
2787
+ : []),
2613
2788
  ].join("\n");
2614
2789
 
2615
2790
  // Persona / mission layers, always-on so they apply to every turn:
@@ -2958,9 +3133,21 @@ async function recoverOneTask(
2958
3133
  // ~8h later. Cheap with the per-user access-token cache — one exchange
2959
3134
  // per user, every task re-injects from it. Best-effort.
2960
3135
  if (task.ownerUserId) {
2961
- void setupTaskGithub(task.taskId, task.ownerUserId);
3136
+ void reconcileTaskGitAuth(task.taskId, task.ownerUserId).catch((err) =>
3137
+ console.warn(
3138
+ `[github] task ${task.taskId}: running recovery reconciliation failed: ${
3139
+ err instanceof Error ? err.message : String(err)
3140
+ }`,
3141
+ ),
3142
+ );
2962
3143
  }
2963
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
3144
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
3145
+ console.warn(
3146
+ `[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
3147
+ err instanceof Error ? err.message : String(err)
3148
+ }`,
3149
+ ),
3150
+ );
2964
3151
  return true;
2965
3152
  }
2966
3153
 
@@ -2982,11 +3169,35 @@ async function recoverOneTask(
2982
3169
  // possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
2983
3170
  // by the inject itself and must not abort the task's recovery.
2984
3171
  await injectCodexIntoContainer(containerName);
3172
+ // Establish the task owner's GitHub transport BEFORE uai-init performs any
3173
+ // dependency-network work. This matters for package manifests that refer to
3174
+ // private GitHub repositories: gh's credential helper must already be live
3175
+ // when pnpm/npm/uv/cargo resolves them. Reconciliation is best-effort, just
3176
+ // like the prior post-init call, but it is now ordered rather than raced.
3177
+ if (task.ownerUserId) {
3178
+ try {
3179
+ await reconcileTaskGitAuth(task.taskId, task.ownerUserId);
3180
+ } catch (err) {
3181
+ console.warn(
3182
+ `[orchestrator] recovery: ${task.taskId} GitHub auth setup failed before uai-init: ${
3183
+ err instanceof Error ? err.message : String(err)
3184
+ }`,
3185
+ );
3186
+ }
3187
+ }
2985
3188
  // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
2986
3189
  // repo. dockerCli's 30s default would SIGKILL it mid-install.
2987
- if (
2988
- !(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
2989
- ) {
3190
+ const initResult = await dockerCli(
3191
+ [
3192
+ "exec",
3193
+ "-e",
3194
+ "UAI_SKIP_GIT_TRANSPORT=1",
3195
+ containerName,
3196
+ "/usr/local/bin/uai-init",
3197
+ ],
3198
+ { timeoutMs: 10 * 60_000 },
3199
+ );
3200
+ if (initResult.status !== 0) {
2990
3201
  console.warn(
2991
3202
  `[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
2992
3203
  );
@@ -2995,12 +3206,13 @@ async function recoverOneTask(
2995
3206
  db_setRuntime(task.taskId, {
2996
3207
  codeServerPort: port,
2997
3208
  });
2998
- // Same gh re-establish as the running branch — the restarted container's gh
2999
- // config is whatever it held when it exited.
3000
- if (task.ownerUserId) {
3001
- void setupTaskGithub(task.taskId, task.ownerUserId);
3002
- }
3003
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
3209
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
3210
+ console.warn(
3211
+ `[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
3212
+ err instanceof Error ? err.message : String(err)
3213
+ }`,
3214
+ ),
3215
+ );
3004
3216
  console.log(
3005
3217
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
3006
3218
  );