@runuai/host 0.9.166 → 0.9.168

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.
@@ -1,3 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AssignmentEvidenceJournal } from "./assignment-evidence";
3
+ import { durableAgentSessionId } from "./agents/transport";
4
+ import { agentOutputSource } from "./agents/output-source";
5
+ import { taskDir } from "./env";
6
+ import { readTaskRunEpochState } from "./runtime-state";
7
+ import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
8
+
1
9
  /**
2
10
  * Orchestrator — the chat channel runtime.
3
11
  *
@@ -292,6 +300,7 @@ interface Channel {
292
300
  /** ADR-083: host-derived role assignment for execution-profile enforcement. */
293
301
  mode: "open" | "secretary";
294
302
  secretaryAgentId?: string;
303
+ leadRunId?: string | null;
295
304
  sessions: Map<string, AgentSession>;
296
305
  /** Session-spawn inputs, kept so sessions can be started lazily. */
297
306
  containerName: string;
@@ -386,10 +395,10 @@ interface Channel {
386
395
  * boundary. Adapters do not share one mid-turn input contract, so only this
387
396
  * cross-lane role is serialized at the host boundary; open mode keeps its
388
397
  * legacy immediate-concurrent delivery behavior. */
389
- pendingPrompts: Map<string, string[]>;
398
+ pendingPrompts: Map<string, AcceptedPrompt[]>;
390
399
  /** Prompts accepted by channelDeliver whose final session boundary rejected
391
400
  * specifically because the container runtime became unavailable. */
392
- runtimeDeferredPrompts: Map<string, string[]>;
401
+ runtimeDeferredPrompts: Map<string, AcceptedPrompt[]>;
393
402
  /** Missing/blocked sessions to reconcile exactly once after runtime ready. */
394
403
  runtimeDeferredAgents: Set<string>;
395
404
  /** Agents whose current turn was interrupted (ESC) — their next
@@ -471,7 +480,7 @@ interface Channel {
471
480
  accountByAgent: Map<string, string>;
472
481
  /** ADR-076: the last prompt delivered to each agent, re-sent after an
473
482
  * account rotation so the interrupted turn resumes on the new account. */
474
- lastPrompt: Map<string, string>;
483
+ lastPrompt: Map<string, AcceptedPrompt>;
475
484
  /** ADR-076: per-agent account-rotation budget (reset on a successful turn) —
476
485
  * bounds a rate-limit storm across accounts, mirroring the respawn budget. */
477
486
  rotations: Map<string, number>;
@@ -537,8 +546,27 @@ function mcpAgentMembershipFingerprint(
537
546
  );
538
547
  }
539
548
 
549
+ const evidenceSessionIds = new WeakMap<AgentSession, string>();
550
+ function evidenceSessionId(taskId: string, agentId: string, session: AgentSession): string {
551
+ let id = evidenceSessionIds.get(session);
552
+ if (!id) {
553
+ // Pin to this session object, including its final/aborted boundary. The
554
+ // registry may already contain its replacement by the time output ends.
555
+ id = durableAgentSessionId(taskId, agentId) ?? randomUUID();
556
+ evidenceSessionIds.set(session, id);
557
+ }
558
+ return id;
559
+ }
560
+ const evidenceRuntimeVersion: string | null = (() => {
561
+ try { return (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; }
562
+ catch { return null; }
563
+ })();
564
+
540
565
  interface AcceptedPrompt {
541
566
  text: string;
567
+ /** Frozen at acceptance. Null means unallocated, not inherit the latest. */
568
+ assignment: AssignmentDelivery | null;
569
+ leadRunId?: string | null;
542
570
  }
543
571
 
544
572
  interface RosterSessionChange {
@@ -927,6 +955,16 @@ export class Orchestrator {
927
955
  }
928
956
 
929
957
  private emitHost(event: HostEvent): void {
958
+ if (event.kind === "agent.message_complete" || event.kind === "agent.turn_complete") {
959
+ const session = this.channels.get(event.taskId)?.sessions.get(event.agentId);
960
+ const path = join(taskDir(event.taskId), ".uai", "assignment-evidence", `${event.agentId}.json`);
961
+ if (session && existsSync(path)) {
962
+ const evidence = new AssignmentEvidenceJournal(path).observe(
963
+ evidenceSessionId(event.taskId, event.agentId, session), agentOutputSource.getStore() ? agentOutputSource.getStore() + ":" + event.kind : undefined, event.kind === "agent.turn_complete",
964
+ );
965
+ if (evidence) event = { ...event, evidence };
966
+ }
967
+ }
930
968
  for (const fn of this.hostSubscribers) {
931
969
  try {
932
970
  fn(event);
@@ -1090,6 +1128,7 @@ export class Orchestrator {
1090
1128
  channel.humans = spec.humans ?? [];
1091
1129
  channel.mode = spec.mode === "secretary" ? "secretary" : "open";
1092
1130
  channel.secretaryAgentId = spec.secretaryAgentId;
1131
+ channel.leadRunId = spec.leadRunId ?? null;
1093
1132
  channel.browserTesting = spec.browserTesting === true;
1094
1133
  if (channel.browserTesting) {
1095
1134
  const affectedKinds = browserWasEnabled
@@ -1390,6 +1429,7 @@ export class Orchestrator {
1390
1429
  HOST_ACCOUNT_SCOPE,
1391
1430
  mode: spec.mode === "secretary" ? "secretary" : "open",
1392
1431
  secretaryAgentId: spec.secretaryAgentId,
1432
+ leadRunId: spec.leadRunId ?? null,
1393
1433
  sessions: new Map(),
1394
1434
  containerName: `task-${taskId.toLowerCase()}-app-1`,
1395
1435
  agentEnvironment: null,
@@ -2667,6 +2707,7 @@ export class Orchestrator {
2667
2707
  task.ownerUserId,
2668
2708
  apiUrl,
2669
2709
  cliSecret,
2710
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
2670
2711
  ),
2671
2712
  task,
2672
2713
  ),
@@ -4450,6 +4491,7 @@ export class Orchestrator {
4450
4491
  task.ownerUserId,
4451
4492
  apiUrl,
4452
4493
  cliSecret,
4494
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
4453
4495
  ),
4454
4496
  task,
4455
4497
  ),
@@ -4686,28 +4728,48 @@ export class Orchestrator {
4686
4728
  channel: Channel,
4687
4729
  agentId: string,
4688
4730
  session: AgentSession,
4689
- prompt: string,
4731
+ prompt: string | AcceptedPrompt,
4732
+ assignment?: AssignmentDelivery,
4690
4733
  ): void {
4734
+ const replay = typeof prompt !== "string";
4735
+ const text = typeof prompt === "string" ? prompt : prompt.text;
4736
+ const leadRunId = replay ? (prompt as AcceptedPrompt).leadRunId ?? null : this.isSecretary(channel, agentId) ? channel.leadRunId ?? null : null;
4737
+ const originalAssignment = typeof prompt === "string" ? (this.isSecretary(channel, agentId) ? null : assignment) : prompt.assignment;
4738
+ let retainedAssignment = originalAssignment ?? null;
4739
+ const journalPath = join(taskDir(channel.taskId), ".uai", "assignment-evidence", `${agentId}.json`);
4740
+ let turn: AssignmentTurnEvidence | undefined;
4741
+ const journal = assignment || leadRunId || existsSync(journalPath) ? new AssignmentEvidenceJournal(journalPath) : null;
4742
+ const sessionId = evidenceSessionId(channel.taskId, agentId, session);
4743
+ if (journal) {
4744
+ const begun = journal.begin({ sessionId, engine: session.kind,
4745
+ runEpoch: readTaskRunEpochState(channel.taskId)?.highestRunEpoch ?? 0,
4746
+ recipientGeneration: channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null,
4747
+ runtimeVersion: evidenceRuntimeVersion, assignment: originalAssignment, replay, leadRunId });
4748
+ if (!begun) return; // stable assignment replay, already accepted
4749
+ turn = begun;
4750
+ retainedAssignment = begun.assignment;
4751
+ }
4691
4752
  // ADR-076: this is the prompt actually in flight. A queued follow-up must
4692
4753
  // not replace it or account rotation would replay the wrong turn.
4693
- channel.lastPrompt.set(agentId, prompt);
4694
- const accepted = { text: prompt };
4754
+ const accepted: AcceptedPrompt = { text, assignment: retainedAssignment, leadRunId };
4755
+ channel.lastPrompt.set(agentId, accepted);
4695
4756
  const acceptedForAgent = channel.acceptedPrompts.get(agentId) ?? [];
4696
4757
  acceptedForAgent.push(accepted);
4697
4758
  channel.acceptedPrompts.set(agentId, acceptedForAgent);
4698
4759
  this.incrementActiveTurns(channel, agentId);
4699
- void session.send(prompt).catch((err: unknown) => {
4760
+ void session.send(text).catch((err: unknown) => {
4761
+ if (turn) journal?.failed(sessionId, turn.turnId);
4700
4762
  if (channel.sessions.get(agentId) === session) {
4701
4763
  this.removeAcceptedPrompt(channel, agentId, accepted);
4702
4764
  const remaining = this.decrementActiveTurns(channel, agentId);
4703
4765
  if (
4704
4766
  remaining === 0 &&
4705
- channel.lastPrompt.get(agentId) === prompt
4767
+ channel.lastPrompt.get(agentId) === accepted
4706
4768
  ) {
4707
4769
  channel.lastPrompt.delete(agentId);
4708
4770
  }
4709
4771
  if (err instanceof ContainerRuntimeUnavailableError) {
4710
- this.deferPromptUntilRuntimeReady(channel, agentId, prompt);
4772
+ this.deferPromptUntilRuntimeReady(channel, agentId, accepted);
4711
4773
  } else {
4712
4774
  this.startNextPendingPrompt(channel, agentId);
4713
4775
  }
@@ -4745,7 +4807,7 @@ export class Orchestrator {
4745
4807
  private deferPromptUntilRuntimeReady(
4746
4808
  channel: Channel,
4747
4809
  agentId: string,
4748
- prompt: string,
4810
+ prompt: AcceptedPrompt,
4749
4811
  ): void {
4750
4812
  channel.runtimeDeferredAgents.add(agentId);
4751
4813
  if (this.isSecretary(channel, agentId)) {
@@ -4810,6 +4872,7 @@ export class Orchestrator {
4810
4872
  /** ADR-141 rung 1: only a human turn is shadow-routed. An older cloud
4811
4873
  * sends nothing, which reads as "not human" and keeps the pass off. */
4812
4874
  origin?: DeliveryOrigin,
4875
+ assignment?: AssignmentDelivery,
4813
4876
  ): Promise<{ ok: true } | { ok: false; error: string }> {
4814
4877
  // Slash command: re-run GitHub auth setup for this task (ADR-027). Handled
4815
4878
  // before sessions so it works even when an agent isn't ready.
@@ -4829,11 +4892,15 @@ export class Orchestrator {
4829
4892
  const session = channel.sessions.get(agentId);
4830
4893
  if (!session) return { ok: false, error: `no such agent: ${agentId}` };
4831
4894
 
4895
+ if (assignment && (assignment.runEpoch !== (readTaskRunEpochState(taskId)?.highestRunEpoch ?? 0) ||
4896
+ assignment.recipientGeneration !== (channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null))) {
4897
+ return { ok: false, error: "assignment belongs to another run or agent generation" };
4898
+ }
4832
4899
  const prompt = rewriteAttachmentRefs(text);
4833
4900
  if (this.isSecretary(channel, agentId)) {
4834
4901
  const queue = channel.pendingPrompts.get(agentId) ?? [];
4835
4902
  if ((channel.activeTurns.get(agentId) ?? 0) > 0 || queue.length > 0) {
4836
- queue.push(prompt);
4903
+ queue.push({ text: prompt, assignment: assignment ?? null, leadRunId: channel.leadRunId ?? null });
4837
4904
  channel.pendingPrompts.set(agentId, queue);
4838
4905
  // A replacement session may have bound after the queue was observed
4839
4906
  // but before this delivery. Preserve FIFO by starting the oldest item.
@@ -4845,7 +4912,7 @@ export class Orchestrator {
4845
4912
  // Preserve the legacy open-room contract: adapters receive concurrent
4846
4913
  // deliveries immediately. Only the designated Secretary needs the hard
4847
4914
  // turn boundary because crew replies can arrive during its dispatch turn.
4848
- this.startPrompt(channel, agentId, session, prompt);
4915
+ this.startPrompt(channel, agentId, session, prompt, assignment);
4849
4916
  }
4850
4917
  // ADR-141 rung 1: after the turn is on its way, ask what Auto would have
4851
4918
  // done and report it. Fire-and-forget by design — it must never delay or
@@ -5409,12 +5476,26 @@ export class Orchestrator {
5409
5476
  // ADR-076: a turn that completed means the current account is healthy —
5410
5477
  // reset its rotation budget so a LATER rate-limit gets a fresh failover.
5411
5478
  channel.rotations.delete(agentId);
5479
+ // Name the leased AI account on the turn usage report so the cloud
5480
+ // ledger can roll up observed spend per credential (host 0.9.167+).
5481
+ // Only a cloud-leased account identity is reportable; runner-default
5482
+ // and Managed Engine identities stay unnamed (same rule as account_usage).
5483
+ const leasedIdentity = channel.leasedIdentityByAgent.get(agentId);
5484
+ const usage =
5485
+ event.usage === undefined
5486
+ ? undefined
5487
+ : {
5488
+ ...event.usage,
5489
+ ...(leasedIdentity?.subject === "account"
5490
+ ? { credentialId: leasedIdentity.credentialId }
5491
+ : {}),
5492
+ };
5412
5493
  this.emitHost({
5413
5494
  kind: "agent.turn_complete",
5414
5495
  taskId: channel.taskId,
5415
5496
  agentId,
5416
5497
  aborted,
5417
- usage: event.usage,
5498
+ usage,
5418
5499
  });
5419
5500
  if (remainingTurns === 0) {
5420
5501
  channel.lastPrompt.delete(agentId);
@@ -5487,7 +5568,12 @@ export class Orchestrator {
5487
5568
  /** An engine error can race a live launch edit after a partial turn. Keep
5488
5569
  * the obsolete adapter owned until the stale recycler closes it, but close
5489
5570
  * the cloud turn boundary immediately so restarting/ready cannot leave the
5490
- * crew UI stuck in a working state. */
5571
+ * crew UI stuck in a working state.
5572
+ *
5573
+ * The error is still reported, as the exit notice every other engine error
5574
+ * sends: whatever the agent was doing is lost, and without it nobody (the
5575
+ * human, or a Secretary waiting on the agent) was told why. Sent before the
5576
+ * reconcile below, so the cloud sees it ahead of the respawn's `restarting`. */
5491
5577
  private reconcileErroredRosterChange(
5492
5578
  channel: Channel,
5493
5579
  agentId: string,
@@ -5501,6 +5587,12 @@ export class Orchestrator {
5501
5587
  if (change) this.publishRosterChangeAbort(channel, agentId, change);
5502
5588
  else this.emitRetiredTurnAbort(channel, agentId);
5503
5589
  }
5590
+ this.emitHost({
5591
+ kind: "agent.exit",
5592
+ taskId: channel.taskId,
5593
+ agentId,
5594
+ reason,
5595
+ });
5504
5596
  void this.reconcileAfterRosterRefresh(channel).catch((err: unknown) => {
5505
5597
  console.warn(
5506
5598
  `[orchestrator] ${channel.taskId}/${agentId}: roster-change error (${reason}) reconcile failed: ${
@@ -5536,7 +5628,7 @@ export class Orchestrator {
5536
5628
  channel.lastPrompt.delete(agentId);
5537
5629
  if (accepted.length > 0 && !interrupted) {
5538
5630
  for (const prompt of accepted) {
5539
- this.deferPromptUntilRuntimeReady(channel, agentId, prompt.text);
5631
+ this.deferPromptUntilRuntimeReady(channel, agentId, prompt);
5540
5632
  }
5541
5633
  } else {
5542
5634
  // Recreate even an idle session after readiness, without fabricating a
@@ -5670,6 +5762,7 @@ export class Orchestrator {
5670
5762
  task.ownerUserId,
5671
5763
  apiUrl,
5672
5764
  cliSecret,
5765
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
5673
5766
  );
5674
5767
  const boundAccountId = channel.accountByAgent.get(agentId);
5675
5768
  const boundAccount = boundAccountId
@@ -5884,6 +5977,7 @@ export class Orchestrator {
5884
5977
  task.ownerUserId,
5885
5978
  apiUrl,
5886
5979
  cliSecret,
5980
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
5887
5981
  );
5888
5982
  channel.accountByAgent.set(agentId, next.id);
5889
5983
  noteEngineAccountUsed(next.id);
@@ -6093,6 +6187,7 @@ export class Orchestrator {
6093
6187
  task.ownerUserId,
6094
6188
  apiUrl,
6095
6189
  cliSecret,
6190
+ this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
6096
6191
  );
6097
6192
  channel.accountByAgent.set(agentId, account.id);
6098
6193
  noteEngineAccountUsed(account.id);
@@ -7459,7 +7554,13 @@ export function buildSystemPreamble(
7459
7554
  `\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
7460
7555
  "`people list`, `task list`" +
7461
7556
  (agent.permissions?.includes("tasks.create")
7462
- ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
7557
+ ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]`"
7558
+ : "") +
7559
+ (agent.permissions?.includes("tasks.read")
7560
+ ? ", `task watch <childId> [--after <cursor>]`, `task usage <childId>`"
7561
+ : "") +
7562
+ (agent.permissions?.includes("tasks.chat")
7563
+ ? ", `task send <childId> <text>`, `task answer <childId> <cardId> <option>`"
7463
7564
  : "") +
7464
7565
  (agent.permissions?.includes("tasks.close")
7465
7566
  ? ", `task close --status finished|canceled [--reason \"final report\"]`"
@@ -7495,8 +7596,13 @@ export function buildSystemPreamble(
7495
7596
  `shell command \`node ${CONTAINER_CLI_PATH} task create …\`. Do NOT`,
7496
7597
  "use your built-in TaskCreate / task tools for this — those are just",
7497
7598
  "your own private to-do notes and are invisible to the human in Uai.",
7498
- "Uai tasks you create are DRAFTS — a human reviews and starts them;",
7499
- "nothing runs (or spends) until then. Discover options first with",
7599
+ "Tasks you create are your own direct children, DRAFTS by default.",
7600
+ "Only an explicit tasks.start grant lets --start launch them,",
7601
+ "under the owner's limits and this task's account lane.",
7602
+ "Only children you started are delegated to you; a live chat-capable Secretary receives coalesced events. Otherwise the child addresses the person.",
7603
+ "Closing the parent leaves running children alive. Child permissions never exceed your live creation ceiling.",
7604
+ "Send/answer are visibly delegated agent inputs, never human approval.",
7605
+ "Discover options first with",
7500
7606
  "`projects list` / `people list`.",
7501
7607
  ]
7502
7608
  : []),
@@ -181,6 +181,7 @@ export function taskProjectEnvResolver(
181
181
  readonly placeholders: Readonly<Record<string, string>>;
182
182
  } = () => ({ withheld: [], placeholders: {} }),
183
183
  taskSource: () => TaskEnvSource = () => STORED,
184
+ taskSecrets: () => { revision: string; withheld: readonly string[]; values: Readonly<Record<string, string>> } = () => ({ revision: "", withheld: [], values: {} }),
184
185
  ): TaskGatewayEnvResolver {
185
186
  const projectIds = [...new Set(spec.projects.map((project) => project.projectId))];
186
187
  let revisions: number[] | undefined;
@@ -192,7 +193,9 @@ export function taskProjectEnvResolver(
192
193
  const currentUai = uaiEnv();
193
194
  const currentProxied = proxiedEnv();
194
195
  const currentSource = taskSource();
196
+ const secrets = taskSecrets();
195
197
  const currentRunnerKey = JSON.stringify([
198
+ secrets.revision,
196
199
  Object.entries(currentUai).sort(([a], [b]) => compareAscii(a, b)),
197
200
  [...currentProxied.withheld].sort(compareAscii),
198
201
  Object.entries(currentProxied.placeholders).sort(([a], [b]) => compareAscii(a, b)),
@@ -214,6 +217,7 @@ export function taskProjectEnvResolver(
214
217
  Object.assign(values, resolved.values);
215
218
  const withheld = new Set([
216
219
  ...currentProxied.withheld,
220
+ ...secrets.withheld,
217
221
  ...(currentSource.kind === "view" ? currentSource.view.notDelivered : []),
218
222
  ]);
219
223
  for (const name of withheld) delete values[name];
@@ -225,6 +229,7 @@ export function taskProjectEnvResolver(
225
229
  values[name] = placeholder;
226
230
  }
227
231
  }
232
+ Object.assign(values, secrets.values);
228
233
  for (const [name, value] of Object.entries(currentUai)) {
229
234
  // Only reserved names: Uai's own variables, never a project's.
230
235
  if (ENV_NAME.test(name) && isReservedTaskEnvName(name) && !value.includes("\0")) values[name] = value;
@@ -1,3 +1,4 @@
1
+ import { taskSecretsView } from "../credential-consumers/task-secrets";
1
2
  /**
2
3
  * Which keys each task has in proxy mode (ADR-153's effective policy: the
3
4
  * owner's binding ∩ the task's schema observation), and what of that the
@@ -36,21 +37,29 @@ export function createTaskProxyPolicySource(
36
37
  return {
37
38
  proxiedKeys: (taskId) => {
38
39
  if (!isServed(taskId)) return null;
39
- return (viewOf(taskId)?.proxied ?? []).map(({ projectId, key, domains, placeholder, head }) => ({
40
+ const secrets = taskSecretsView(taskId);
41
+ const taskKeys = secrets.filter(s => s.manifest.mode === "proxied").map(s => ({ projectId: `task-secret:${taskId}`, key: s.manifest.name, domains: s.manifest.domains, placeholder: s.manifest.placeholder, head: { revision: s.manifest.generation, generation: s.manifest.generation, tombstone: false as const, valueRef: { kind: "staged" as const, stagingId: s.manifest.stagingId } } }));
42
+ return [...taskKeys, ...(viewOf(taskId)?.proxied ?? []).filter(e => !secrets.some(s => s.manifest.name === e.key)).map(({ projectId, key, domains, placeholder, head }) => ({
40
43
  projectId,
41
44
  key,
42
45
  domains,
43
46
  placeholder,
44
47
  head,
45
- }));
48
+ }))];
46
49
  },
47
50
  realValue: (taskId, ref) => {
51
+ if (!isServed(taskId)) return null;
52
+ if (ref.projectId === `task-secret:${taskId}`) {
53
+ const secret = taskSecretsView(taskId).find(s => s.manifest.name === ref.key && s.manifest.mode === "proxied");
54
+ return secret && ref.head?.generation === secret.manifest.generation && ref.head.valueRef?.kind === "staged" && ref.head.valueRef.stagingId === secret.manifest.stagingId ? secret.material?.value ?? null : null;
55
+ }
56
+ if (taskSecretsView(taskId).some(s => s.manifest.name === ref.key)) return null;
48
57
  const { head } = ref;
49
58
  if (head === undefined) return null;
50
59
  const proxied = viewOf(taskId)?.proxied.find((entry) => entry.projectId === ref.projectId && entry.key === ref.key);
51
60
  return proxied !== undefined && sameTaskEnvHead(proxied.head, head) ? proxied.value : null;
52
61
  },
53
- notDelivered: (taskId) => viewOf(taskId)?.notDelivered ?? null,
62
+ notDelivered: (taskId) => [...(viewOf(taskId)?.notDelivered ?? []), ...taskSecretsView(taskId).map(s => s.manifest.name)],
54
63
  };
55
64
  }
56
65
 
@@ -67,7 +67,7 @@ export const ASDF_DATA_VOLUME = "uai-asdf-data";
67
67
  export const STANDARD_DEFAULT_NODE_VERSION = "22.11.0";
68
68
 
69
69
  /** Exact Codex CLI release exposed to task runtimes through the shared volume. */
70
- export const STANDARD_CODEX_CLI_VERSION = "0.157.0";
70
+ export const STANDARD_CODEX_CLI_VERSION = "0.164.0";
71
71
 
72
72
  /**
73
73
  * Runtimes the standard image's asdf plugins can satisfy, with a sensible set
@@ -1,3 +1,4 @@
1
+ import { deliveredTaskSecrets } from "./credential-consumers/task-secrets";
1
2
  /**
2
3
  * ADR-153 §4 and §6: where a task's project env comes from, for its /env and
3
4
  * its proxy alike, on an owned runner and on a task machine's sidecar.
@@ -233,6 +234,7 @@ export function taskEnvResolver(taskId: string, spec: TaskProjectEnvSpec, runEpo
233
234
  () => taskTrustEnv(taskId),
234
235
  () => taskProxiedEnv(taskId),
235
236
  () => taskEnvSource(taskId, spec, runEpoch),
237
+ () => deliveredTaskSecrets(taskId, runEpoch),
236
238
  );
237
239
  }
238
240
 
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ADR-161 §2: the live-project commands wired to this runner's real state —
3
+ * the task's persisted environment, its run-epoch admission, its lifecycle
4
+ * slot, its lease grant and env replica, and its persisted env spec.
5
+ */
6
+
7
+ import { activeCloudCredentials, credentialsMode } from "./cloud-credential-host";
8
+ import { getOrchestrator } from "./orchestrator";
9
+ import {
10
+ getHostTask,
11
+ hostTaskProjectEnvSpec,
12
+ readTaskRunEpochState,
13
+ taskProjectEnvSpec,
14
+ upsertHostTask,
15
+ type TaskProjectEnvSpec,
16
+ } from "./runtime-state";
17
+ import { taskUaiDir } from "./env";
18
+ import { captureTaskEnvSchemaFromClone, taskEnvSchemaObservation } from "./project-env-schema-capture";
19
+ import { reconstructPersistedTaskEnvironment } from "./task-environment";
20
+ import { forgetTaskEnvSnapshot, taskEnvResolver } from "./task-env-source";
21
+ import { currentTaskGatewayRegistration, setTaskGatewayEnvResolver } from "./task-gateway-state";
22
+ import { TaskProjectJournalStore, TaskProjectOps } from "./task-project-ops";
23
+ import type { TaskCommandProject } from "../src/protocol";
24
+
25
+ function runIsCurrent(taskId: string, runEpoch: number): boolean {
26
+ const task = getHostTask(taskId);
27
+ if (!task || task.statusMirror !== "running") return false;
28
+ const state = readTaskRunEpochState(taskId);
29
+ // A task started before run epochs carries none; the cloud never sends
30
+ // these commands to it (it has no `live-task-projects-v1` contract).
31
+ return state !== null && state.assignmentOpen && state.highestRunEpoch === runEpoch;
32
+ }
33
+
34
+ /** Names the cloud's literal channel must never set live: the editor theme
35
+ * is the task's, chosen at start. */
36
+ const RESERVED_LITERAL_ENV = new Set(["UAI_EDITOR_THEME"]);
37
+
38
+ /**
39
+ * One project (and its preview URL literals) added to a persisted env spec.
40
+ * Every other entry stays; a literal name the task already has keeps its
41
+ * value, as at start where the first project to declare one wins.
42
+ */
43
+ export function specWithProject(
44
+ spec: TaskProjectEnvSpec,
45
+ project: TaskCommandProject,
46
+ previewEnv: Record<string, string> = {},
47
+ ): TaskProjectEnvSpec {
48
+ const hasProject = spec.projects.some((entry) => entry.projectId === project.id);
49
+ const addedLiterals = Object.entries(previewEnv).filter(
50
+ ([name]) => !RESERVED_LITERAL_ENV.has(name) && !(name in spec.previewEnv),
51
+ );
52
+ if (hasProject && addedLiterals.length === 0) return spec;
53
+ const [entry] = taskProjectEnvSpec({
54
+ projects: [project],
55
+ task: { previewEnv: {} } as Parameters<typeof taskProjectEnvSpec>[0]["task"],
56
+ }).projects;
57
+ return {
58
+ ...spec,
59
+ projects: hasProject
60
+ ? spec.projects
61
+ : [...spec.projects, entry!].sort(
62
+ (left, right) => left.position - right.position || left.projectId.localeCompare(right.projectId),
63
+ ),
64
+ previewEnv: Object.fromEntries(
65
+ [...Object.entries(spec.previewEnv), ...addedLiterals].sort(([left], [right]) => left.localeCompare(right)),
66
+ ),
67
+ };
68
+ }
69
+
70
+ function liveProjectsSupported(taskId: string): boolean {
71
+ const task = getHostTask(taskId);
72
+ if (!task) return false;
73
+ try {
74
+ return hostTaskProjectEnvSpec(task) !== null && currentTaskGatewayRegistration(taskId) !== null;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Persist the extended spec, then ALWAYS re-point the live resolver at the
82
+ * persisted spec, so a repeat repairs a resolver a crash left behind. Throws
83
+ * when the environment can't take it; Activate then answers `incomplete`.
84
+ */
85
+ function installProjectEnv(
86
+ taskId: string,
87
+ runEpoch: number,
88
+ project: TaskCommandProject,
89
+ previewEnv: Record<string, string>,
90
+ ): void {
91
+ const task = getHostTask(taskId);
92
+ if (!task) throw new Error(`no such task: ${taskId}`);
93
+ const spec = hostTaskProjectEnvSpec(task);
94
+ if (spec === null) throw new Error("this task has no persisted env spec to extend");
95
+ const registration = currentTaskGatewayRegistration(taskId);
96
+ if (!registration) throw new Error("this task has no live env gateway");
97
+ const next = specWithProject(spec, project, previewEnv);
98
+ if (next !== spec) upsertHostTask(taskId, { projectEnvSpec: JSON.stringify(next) });
99
+ forgetTaskEnvSnapshot(taskId);
100
+ if (!setTaskGatewayEnvResolver(taskId, registration.generation, taskEnvResolver(taskId, next, runEpoch))) {
101
+ throw new Error("the task's env gateway changed while the project was installed");
102
+ }
103
+ }
104
+
105
+ let singleton: TaskProjectOps | null = null;
106
+
107
+ export function taskProjectOps(): TaskProjectOps {
108
+ singleton ??= new TaskProjectOps({
109
+ journal: new TaskProjectJournalStore(taskUaiDir),
110
+ environment: async (taskId) => {
111
+ const task = getHostTask(taskId);
112
+ if (!task || task.statusMirror !== "running") return null;
113
+ return reconstructPersistedTaskEnvironment(task);
114
+ },
115
+ runIsCurrent,
116
+ inLifecycleSlot: (taskId, fn) => getOrchestrator().runTaskLifecycle(taskId, fn),
117
+ credentials: () => (credentialsMode() === "active" ? "cloud" : "own"),
118
+ installedGrant: (taskId, runEpoch) => {
119
+ const composer = activeCloudCredentials();
120
+ if (!composer) return null;
121
+ const view = composer.registry.currentView({ taskId, runEpoch });
122
+ return view
123
+ ? { runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest }
124
+ : null;
125
+ },
126
+ envServes: (taskId, runEpoch, projectId) => {
127
+ const composer = activeCloudCredentials();
128
+ // The runner's own store serves every project it holds values for.
129
+ if (!composer) return true;
130
+ const view = composer.registry.currentView({ taskId, runEpoch });
131
+ if (!view || !view.projectIds.includes(projectId)) return false;
132
+ return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null;
133
+ },
134
+ liveProjectsSupported,
135
+ captureEnvSchema: (taskId, projectId, git) => captureTaskEnvSchemaFromClone(taskId, projectId, git),
136
+ storedEnvSchema: (taskId, projectId) => taskEnvSchemaObservation(taskId, projectId),
137
+ servedProjectIds: (taskId, runEpoch) => {
138
+ const composer = activeCloudCredentials();
139
+ if (!composer) return null;
140
+ const view = composer.registry.currentView({ taskId, runEpoch });
141
+ if (!view) return null;
142
+ return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null ? [...view.projectIds] : null;
143
+ },
144
+ installProjectEnv,
145
+ });
146
+ return singleton;
147
+ }