@runuai/host 0.9.167 → 0.9.169

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.
@@ -269,21 +269,25 @@ export class CursorSession implements AgentSession {
269
269
  }
270
270
 
271
271
  /**
272
- * One dashboard probe for a leased account, overlapped with the turn. A
273
- * missing lease, a probe still inside the five-minute gate, or any failure
274
- * sends nothing. The token never leaves this method except as the bearer
275
- * of the two dashboard calls.
272
+ * One dashboard probe for a leased account. The read runs after the CLI
273
+ * child has exited: the CLI rewrites ~/.config/cursor/auth.json at
274
+ * startup, and a probe overlapped with the turn reads the expired access
275
+ * token. A missing lease, a probe still inside the five-minute gate, or
276
+ * any failure sends nothing. The token never leaves this method except
277
+ * as the bearer of the two dashboard calls.
276
278
  *
277
279
  * The credential is marked before the first await. Two sessions that start
278
- * together must not both pass the gate.
280
+ * together must not both pass the gate. The returned function is the read;
281
+ * call it once the child has exited, still before turn_complete.
279
282
  */
280
- private probePeriodUsage(): Promise<void> {
283
+ private probePeriodUsage(): () => Promise<void> {
284
+ const skip = (): Promise<void> => Promise.resolve();
281
285
  const credentialId = this.agentEnv[ENGINE_ACCOUNT_ENV];
282
- if (!credentialId || this.closed) return Promise.resolve();
286
+ if (!credentialId || this.closed) return skip;
283
287
  const now = Date.now();
284
- if (!cursorPeriodProbeDue(credentialId, now)) return Promise.resolve();
288
+ if (!cursorPeriodProbeDue(credentialId, now)) return skip;
285
289
  markCursorPeriodProbed(credentialId, now);
286
- return this.readPeriodUsage();
290
+ return () => this.readPeriodUsage();
287
291
  }
288
292
 
289
293
  /** Auth read and both dashboard calls share one deadline. On expiry the
@@ -388,6 +392,8 @@ export class CursorSession implements AgentSession {
388
392
 
389
393
  const child = this.spawner(this.buildRequest(prompt));
390
394
  this.current = child;
395
+ // Mark the gate now, before the child-exit await. The read itself waits
396
+ // until the CLI has rewritten auth.json.
391
397
  const periodUsage = this.probePeriodUsage();
392
398
 
393
399
  let acc = "";
@@ -520,7 +526,7 @@ export class CursorSession implements AgentSession {
520
526
  child.on("exit", (code) => finish(code));
521
527
  child.on("error", (err) => finish(null, err.message));
522
528
  });
523
- await periodUsage;
529
+ await periodUsage();
524
530
  if (!this.closed) this.emit({ type: "turn_complete", usage: turnUsage });
525
531
  }
526
532
 
@@ -1,3 +1,5 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { agentOutputSource } from "./output-source";
1
3
  /**
2
4
  * DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
3
5
  * the agent CLI's pipes, it talks to the in-container runner
@@ -84,6 +86,8 @@ export class DurableProcess {
84
86
  private readonly inbox: readonly string[];
85
87
  private readonly heartbeat: readonly string[];
86
88
 
89
+ private readonly decoder = new StringDecoder("utf8");
90
+ private lineOffset = 0;
87
91
  private readonly lineHandlers = new Set<LineHandler>();
88
92
  private readonly exitHandlers = new Set<ExitHandler>();
89
93
 
@@ -108,6 +112,7 @@ export class DurableProcess {
108
112
  this.inbox = [...opts.sessionFiles.parts, "inbox.jsonl"];
109
113
  this.heartbeat = [...opts.sessionFiles.parts, "heartbeat"];
110
114
  this.offset = opts.initialOutboxOffset ?? 0;
115
+ this.lineOffset = this.offset;
111
116
  this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
112
117
  this.onCloseRequested = opts.onCloseRequested ?? null;
113
118
  if (opts.ready) {
@@ -156,7 +161,7 @@ export class DurableProcess {
156
161
  if (read === null) break; // outbox not created yet
157
162
  if (read.bytes.length === 0) break;
158
163
  this.offset += read.bytes.length;
159
- this.lineBuf += read.bytes.toString("utf8");
164
+ this.lineBuf += this.decoder.write(read.bytes);
160
165
  this.drainLines();
161
166
  if (this.offset >= read.size) break;
162
167
  }
@@ -171,7 +176,7 @@ export class DurableProcess {
171
176
  }
172
177
  if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
173
178
  try {
174
- this.onOffsetAdvance(this.offset);
179
+ this.onOffsetAdvance(this.lineOffset);
175
180
  } catch {
176
181
  // Persistence hiccup — the next batch retries with a larger offset.
177
182
  }
@@ -182,7 +187,9 @@ export class DurableProcess {
182
187
  private drainLines(): void {
183
188
  let nl: number;
184
189
  while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
185
- const line = this.lineBuf.slice(0, nl).trim();
190
+ const rawLine = this.lineBuf.slice(0, nl);
191
+ this.lineOffset += Buffer.byteLength(rawLine) + 1;
192
+ const line = rawLine.trim();
186
193
  this.lineBuf = this.lineBuf.slice(nl + 1);
187
194
  if (line.length === 0) continue;
188
195
  if (line.startsWith('{"__uai"')) {
@@ -192,7 +199,7 @@ export class DurableProcess {
192
199
  if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
193
200
  for (const h of this.lineHandlers) {
194
201
  try {
195
- h(line);
202
+ agentOutputSource.run(this.durableSessionDir + ":" + this.lineOffset, () => h(line));
196
203
  } catch {
197
204
  // A broken handler must not wedge the tail loop.
198
205
  }
@@ -293,7 +300,7 @@ export class DurableProcess {
293
300
 
294
301
  /** Consumed-outbox byte offset — persist this to survive host restarts. */
295
302
  get outboxOffset(): number {
296
- return this.offset;
303
+ return this.lineOffset;
297
304
  }
298
305
 
299
306
  /**
@@ -1,3 +1,5 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { agentOutputSource } from "./output-source";
1
3
  /**
2
4
  * ADR-121 + ADR-061: durable agent sessions for MACHINE-backed tasks.
3
5
  *
@@ -139,6 +141,8 @@ export class MachineDurableProcess implements LineTransport {
139
141
  readonly #lineHandlers = new Set<LineHandler>();
140
142
  readonly #exitHandlers = new Set<ExitHandler>();
141
143
 
144
+ readonly #decoder = new StringDecoder("utf8");
145
+ #lineOffset = 0;
142
146
  #offset: number;
143
147
  #lineBuf = "";
144
148
  #stderrBuf = "";
@@ -166,6 +170,7 @@ export class MachineDurableProcess implements LineTransport {
166
170
  this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
167
171
  this.#inboxPath = posix.join(opts.sessionDir, "inbox.jsonl");
168
172
  this.#offset = opts.initialOutboxOffset ?? 0;
173
+ this.#lineOffset = this.#offset;
169
174
  this.#onOffsetAdvance = opts.onOffsetAdvance ?? null;
170
175
  this.#onCloseRequested = opts.onCloseRequested ?? null;
171
176
  this.#debug =
@@ -248,11 +253,11 @@ export class MachineDurableProcess implements LineTransport {
248
253
  if (generation !== this.#tailGeneration) break;
249
254
  const text = Buffer.from(chunk);
250
255
  this.#offset += text.byteLength;
251
- this.#lineBuf += text.toString("utf8");
256
+ this.#lineBuf += this.#decoder.write(text);
252
257
  this.#drainLines();
253
258
  if (this.#onOffsetAdvance && !this.#closed) {
254
259
  try {
255
- this.#onOffsetAdvance(this.#offset);
260
+ this.#onOffsetAdvance(this.#lineOffset);
256
261
  } catch {
257
262
  // Persistence hiccup — the next batch retries larger.
258
263
  }
@@ -282,7 +287,9 @@ export class MachineDurableProcess implements LineTransport {
282
287
  #drainLines(): void {
283
288
  let nl: number;
284
289
  while ((nl = this.#lineBuf.indexOf("\n")) >= 0) {
285
- const line = this.#lineBuf.slice(0, nl).trim();
290
+ const rawLine = this.#lineBuf.slice(0, nl);
291
+ this.#lineOffset += Buffer.byteLength(rawLine) + 1;
292
+ const line = rawLine.trim();
286
293
  this.#lineBuf = this.#lineBuf.slice(nl + 1);
287
294
  if (line.length === 0) continue;
288
295
  if (line.startsWith('{"__uai"')) {
@@ -292,7 +299,7 @@ export class MachineDurableProcess implements LineTransport {
292
299
  if (this.#debug) this.#log(`<- ${line.slice(0, 1000)}`);
293
300
  for (const handler of this.#lineHandlers) {
294
301
  try {
295
- handler(line);
302
+ agentOutputSource.run(this.durableSessionDir + ":" + this.#lineOffset, () => handler(line));
296
303
  } catch {
297
304
  // A broken handler must not wedge the tail loop.
298
305
  }
@@ -464,7 +471,7 @@ export class MachineDurableProcess implements LineTransport {
464
471
  }
465
472
 
466
473
  get outboxOffset(): number {
467
- return this.#offset;
474
+ return this.#lineOffset;
468
475
  }
469
476
 
470
477
  /** Stop consuming WITHOUT touching the runner (host restart in miniature). */
@@ -0,0 +1,3 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /** Stable outbox position follows adapter async work, never a timestamp. */
3
+ export const agentOutputSource = new AsyncLocalStorage<string>();
@@ -118,6 +118,11 @@ interface DetachableTail extends LineTransport {
118
118
 
119
119
  const liveTails = new Map<string, DetachableTail>();
120
120
 
121
+ export function durableAgentSessionId(taskId: string, agentId: string): string | undefined {
122
+ const tail = liveTails.get(`${taskId}:${agentId}`);
123
+ return tail && !tail.isClosed ? tail.durableSessionDir : undefined;
124
+ }
125
+
121
126
  function claimTail<T extends DetachableTail>(key: string, proc: T): T {
122
127
  liveTails.get(key)?.detach();
123
128
  liveTails.set(key, proc);
@@ -0,0 +1,93 @@
1
+ /** Host-owned durable correlation, independent of a CLI's mutable history. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
6
+
7
+ interface State {
8
+ sessionId: string;
9
+ current: AssignmentDelivery | null;
10
+ active: AssignmentTurnEvidence[];
11
+ deliveries: string[];
12
+ observations: Record<string, AssignmentTurnEvidence>;
13
+ }
14
+ /** One journal per task/agent, in runner-private task data, never /workspace. */
15
+ export class AssignmentEvidenceJournal {
16
+ constructor(private readonly path: string) {}
17
+ private read(sessionId: string): State {
18
+ let state: State;
19
+ try { state = JSON.parse(readFileSync(this.path, "utf8")) as State; }
20
+ catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ return { sessionId, current: null, active: [], deliveries: [], observations: {} };
23
+ }
24
+ // A replacement CLI is a new execution generation; never attach the
25
+ // predecessor's turn to it. Explicit redispatch can restore the assignment.
26
+ return state.sessionId === sessionId ? state : { sessionId, current: state.current, active: [], deliveries: [], observations: {} };
27
+ }
28
+ private write(state: State) {
29
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
30
+ const temp = this.path + "." + randomUUID();
31
+ const fd = openSync(temp, "wx", 0o600);
32
+ try { writeFileSync(fd, JSON.stringify(state)); fsyncSync(fd); } finally { closeSync(fd); }
33
+ renameSync(temp, this.path);
34
+ const dir = openSync(dirname(this.path), "r");
35
+ try { fsyncSync(dir); } finally { closeSync(dir); }
36
+ }
37
+ begin(input: { sessionId: string; engine: string; runEpoch: number; runtimeVersion: string | null; recipientGeneration: string | null; assignment?: AssignmentDelivery | null; replay?: boolean; leadRunId?: string | null }): (AssignmentTurnEvidence & { assignment: AssignmentDelivery | null }) | null {
38
+ const state = this.read(input.sessionId);
39
+ if (state.current && (state.current.runEpoch !== input.runEpoch || state.current.recipientGeneration !== input.recipientGeneration)) {
40
+ state.current = null;
41
+ state.active = [];
42
+ state.deliveries = [];
43
+ }
44
+ // Assignment id, not attempt id: a lost cloud ack must not send the same
45
+ // instruction twice. Old attempts retain their original provenance.
46
+ if (!input.replay && input.assignment && state.deliveries.includes(input.assignment.assignmentId)) return null;
47
+ if (input.assignment) {
48
+ // A replay carries its original identity without retargeting subsequent
49
+ // new inputs to an older assignment. Deduplication applies to cloud
50
+ // deliveries; an internally retained prompt is an execution retry.
51
+ if (!input.replay) state.current = input.assignment;
52
+ if (!state.deliveries.includes(input.assignment.assignmentId)) state.deliveries.push(input.assignment.assignmentId);
53
+ }
54
+ const current = input.assignment === null ? null : input.assignment ?? state.current;
55
+ const evidence: AssignmentTurnEvidence = {
56
+ turnId: randomUUID(), sessionId: input.sessionId, runEpoch: input.runEpoch,
57
+ assignmentId: current?.assignmentId ?? null, deliveryId: current?.deliveryId ?? null,
58
+ recipientGeneration: current?.recipientGeneration ?? (input.leadRunId ? input.recipientGeneration : null),
59
+ ...(input.leadRunId ? { leadRunId: input.leadRunId } : {}),
60
+ correlation: input.assignment || input.leadRunId ? "explicit" : current ? "followup" : "unknown",
61
+ acceptedAt: Date.now(), engine: input.engine, runtimeVersion: input.runtimeVersion,
62
+ };
63
+ // Concurrent prompts aren't assumed to execute FIFO. Without a provider
64
+ // turn/request join, every overlapping turn is explicitly unallocated.
65
+ if (state.active.length) {
66
+ for (const turn of state.active) { turn.assignmentId = null; turn.deliveryId = null; turn.leadRunId = null; turn.correlation = "ambiguous"; }
67
+ evidence.assignmentId = null; evidence.deliveryId = null; evidence.leadRunId = null; evidence.correlation = "ambiguous";
68
+ }
69
+ state.active.push(evidence);
70
+ this.write(state);
71
+ return { ...evidence, assignment: current };
72
+ }
73
+ observe(sessionId: string, sourceId: string | undefined, complete: boolean): AssignmentTurnEvidence | undefined {
74
+ const state = this.read(sessionId);
75
+ if (sourceId && state.observations[sourceId]) return state.observations[sourceId];
76
+ const active = state.active[0];
77
+ if (!active) return undefined;
78
+ const evidence = { ...active, ...(complete ? { endedAt: Date.now() } : {}) };
79
+ if (sourceId) state.observations[sourceId] = evidence;
80
+ if (complete) state.active.shift();
81
+ this.write(state);
82
+ return evidence;
83
+ }
84
+ failed(sessionId: string, turnId: string): void {
85
+ const state = this.read(sessionId);
86
+ const turn = state.active.find(t => t.turnId === turnId);
87
+ state.active = state.active.filter(t => t.turnId !== turnId);
88
+ // Failure before acceptance allows retry. Ambiguous sends stay unknown;
89
+ // never manufacture a successful execution from delivery alone.
90
+ if (turn?.assignmentId) state.deliveries = state.deliveries.filter(id => id !== turn.assignmentId);
91
+ this.write(state);
92
+ }
93
+ }
@@ -1,3 +1,4 @@
1
+ import { parseTaskSecretMaterial, type TaskSecretManifestEntry, type TaskSecretMaterial } from "../src/task-secrets";
1
2
  /**
2
3
  * ADR-154 task-scoped credential leases.
3
4
  *
@@ -86,7 +87,7 @@ export type LeaseGateCloseReason =
86
87
  export type CredentialCleanupReason = LeaseGateCloseReason | "lease-replaced";
87
88
 
88
89
  export interface CredentialCleanupIdentity {
89
- class: "engine" | "mcp" | "github" | "ssh";
90
+ class: "engine" | "mcp" | "github" | "ssh" | "task-secret";
90
91
  credentialId: string;
91
92
  revision: string | null;
92
93
  }
@@ -136,6 +137,7 @@ export interface TaskStaticCredential<T extends LeasedValue> {
136
137
 
137
138
  /** A task-facing view. It contains material, but never STS credentials. */
138
139
  export interface TaskCredentialView {
140
+ taskSecrets?: readonly { manifest: Readonly<TaskSecretManifestEntry>; material: Readonly<TaskSecretMaterial> | null }[];
139
141
  assignment: Readonly<TaskAssignment>;
140
142
  leaseId: string;
141
143
  manifestRevision: number;
@@ -293,12 +295,13 @@ type CredentialDescriptor = {
293
295
  sessionId: string | null;
294
296
  authKind: "env" | "config-dir" | "oauth" | "token" | "none" | null;
295
297
  location: CredentialLocation | null;
298
+ taskSecret?: { entry: TaskSecretManifestEntry; orgId: string; taskId: string };
296
299
  };
297
300
 
298
301
  type CredentialRecord = {
299
302
  descriptor: CredentialDescriptor;
300
303
  availability: CredentialMaterialAvailability;
301
- material: LeasedValue | null;
304
+ material: LeasedValue | TaskSecretMaterial | null;
302
305
  observedVersion: number | null;
303
306
  };
304
307
 
@@ -527,13 +530,16 @@ function manifestDescriptors(lease: TaskLeaseV1): CredentialDescriptor[] {
527
530
  ...lease.manifest.engines.map(descriptorFromEngine),
528
531
  ...lease.manifest.mcp.map(descriptorFromMcp),
529
532
  ];
533
+ for (const entry of lease.manifest.taskSecrets ?? []) out.push({ class: "task-secret", credentialId: entry.credentialId,
534
+ revision: String(entry.generation), parameter: entry.parameter, version: entry.version, sessionId: entry.sessionId,
535
+ authKind: null, location: null, taskSecret: { entry, orgId: lease.orgId, taskId: lease.taskId } });
530
536
  if (lease.manifest.github) out.push(descriptorFromStatic(lease.manifest.github, "github"));
531
537
  if (lease.manifest.ssh) out.push(descriptorFromStatic(lease.manifest.ssh, "ssh"));
532
538
  return out;
533
539
  }
534
540
 
535
541
  function materialMatchesDescriptor(
536
- material: LeasedValue,
542
+ material: LeasedValue | TaskSecretMaterial,
537
543
  descriptor: CredentialDescriptor,
538
544
  ): boolean {
539
545
  if (material.class !== descriptor.class) return false;
@@ -542,7 +548,7 @@ function materialMatchesDescriptor(
542
548
  return descriptor.authKind === null;
543
549
  }
544
550
 
545
- function freezeMaterial<T extends LeasedValue>(material: T): Readonly<T> {
551
+ function freezeMaterial<T extends LeasedValue | TaskSecretMaterial>(material: T): Readonly<T> {
546
552
  Object.freeze(material.location);
547
553
  if (material.class === "engine") {
548
554
  if (material.authKind === "env") Object.freeze(material.env);
@@ -628,6 +634,11 @@ function freezeView(
628
634
  manifestDigest: lease.manifestDigest,
629
635
  expiresAt,
630
636
  projectIds: Object.freeze(lease.manifest.projects.map((project) => project.projectId)),
637
+ ...(lease.manifest.taskSecrets?.length ? { taskSecrets: Object.freeze(lease.manifest.taskSecrets.map(manifest => {
638
+ const record = requireRecord(manifest.credentialId);
639
+ if (record.material !== null && record.material.class !== "task-secret") throw new CredentialLeaseError("task secret material is incomplete");
640
+ return Object.freeze({ manifest: Object.freeze({ ...manifest, domains: Object.freeze([...manifest.domains]) as unknown as string[] }), material: record.material as Readonly<TaskSecretMaterial> | null });
641
+ })) } : {}),
631
642
  engines: Object.freeze(engines),
632
643
  mcp: Object.freeze(mcp),
633
644
  github: staticCredential<GithubLeasedValue>(lease.manifest.github, "github"),
@@ -1671,7 +1682,7 @@ export class TaskCredentialLeaseRegistry {
1671
1682
  !descriptor.parameter ||
1672
1683
  !descriptor.version ||
1673
1684
  !descriptor.sessionId ||
1674
- !descriptor.location
1685
+ (!descriptor.location && !descriptor.taskSecret)
1675
1686
  ) {
1676
1687
  throw new CredentialLeaseError("manifest credential is incomplete");
1677
1688
  }
@@ -1729,7 +1740,13 @@ export class TaskCredentialLeaseRegistry {
1729
1740
  if (!selectors.includes(selector)) {
1730
1741
  throw new CredentialLeaseError("Parameter Store returned an unrequested credential version");
1731
1742
  }
1732
- const material = parseLeasedValue(parameter.value, descriptor.location!);
1743
+ let material: LeasedValue | TaskSecretMaterial | null;
1744
+ if (descriptor.taskSecret) {
1745
+ const { entry, orgId, taskId } = descriptor.taskSecret;
1746
+ const digest = createHash("sha256").update(entry.name).digest("hex");
1747
+ if (entry.parameter !== `/uai/lease/tasks/${taskId}/secrets/${digest}/${entry.stagingId}`) throw new CredentialLeaseError("task secret parameter does not match its name");
1748
+ material = parseTaskSecretMaterial(parameter.value, { orgId, taskId, name: entry.name, stagingId: entry.stagingId });
1749
+ } else material = parseLeasedValue(parameter.value, descriptor.location!);
1733
1750
  if (!material || !materialMatchesDescriptor(material, descriptor)) {
1734
1751
  throw new CredentialLeaseError("credential envelope does not match its manifest entry");
1735
1752
  }
@@ -1,3 +1,5 @@
1
+ import type { TaskSecretApplied } from "../src/task-secrets";
2
+ import { openTaskSecrets, closeTaskSecrets } from "./credential-consumers/task-secrets";
1
3
  /**
2
4
  * ADR-154 on the runner: the one place a task on cloud credentials is wired.
3
5
  *
@@ -148,7 +150,7 @@ export class AuthoritativeTaskFilesFailure extends Error {
148
150
  export interface CloudCredentialRuntimeDeps {
149
151
  runnerKind: "owned" | "task-machine";
150
152
  /** To the cloud, over the authenticated bridge. False: not sent now. */
151
- send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame): boolean;
153
+ send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame | TaskSecretApplied): boolean;
152
154
  /** Every engine of the run (and older) proven stopped in every runtime it
153
155
  * ran in: the orchestrator's `quiesceLeasedEngineAgents`. */
154
156
  quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
@@ -1069,6 +1071,7 @@ export class CloudCredentialRuntime {
1069
1071
 
1070
1072
  #openGate(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
1071
1073
  if (this.#shutDown) throw new CredentialLeaseUnavailableError("the runner is shutting down");
1074
+ openTaskSecrets(assignment, view);
1072
1075
  openEngineGate(assignment, view);
1073
1076
  openMcpGate(assignment, view);
1074
1077
  openPersonCredentialGate(assignment, view);
@@ -1085,6 +1088,11 @@ export class CloudCredentialRuntime {
1085
1088
  this.#personCredentialsChanged(assignment, view);
1086
1089
  this.#publishMcpMembership(assignment.taskId, view);
1087
1090
  this.#runRecoveredSetup(assignment);
1091
+ // Empty sets matter too: a new runner process may first see a parked task after revocation.
1092
+ queueMicrotask(() => {
1093
+ if (this.#shutDown || this.registry.currentView(assignment) !== view) return;
1094
+ this.#deps.send({ kind: "task.secret.applied", taskId: assignment.taskId, runEpoch: assignment.runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest });
1095
+ });
1088
1096
  }
1089
1097
 
1090
1098
  #personCredentialsChanged(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
@@ -1201,6 +1209,7 @@ export class CloudCredentialRuntime {
1201
1209
  attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
1202
1210
  }
1203
1211
  }
1212
+ attempt(() => closeTaskSecrets(assignment));
1204
1213
  attempt(() => closeEngineGate(assignment));
1205
1214
  attempt(() => {
1206
1215
  const mcp = leasedMcpFor(assignment.taskId);
@@ -0,0 +1,10 @@
1
+ import type { TaskAssignment, TaskCredentialView } from "../cloud-credential-leases";
2
+ const served = new Map<string, TaskCredentialView>();
3
+ export function openTaskSecrets(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void { if (view.assignment.taskId !== assignment.taskId || view.assignment.runEpoch !== assignment.runEpoch) throw new Error("task secret assignment mismatch"); served.set(assignment.taskId, view); }
4
+ export function closeTaskSecrets(assignment: Readonly<TaskAssignment>): void { const view = served.get(assignment.taskId); if (view && view.assignment.runEpoch <= assignment.runEpoch) served.delete(assignment.taskId); }
5
+ export function taskSecretsView(taskId: string, runEpoch?: number | null) { const view = served.get(taskId); return view && (runEpoch == null || view.assignment.runEpoch === runEpoch) ? view.taskSecrets ?? [] : []; }
6
+ export function deliveredTaskSecrets(taskId: string, runEpoch?: number | null): { revision: string; withheld: string[]; values: Record<string, string> } {
7
+ const entries = taskSecretsView(taskId, runEpoch); const values: Record<string,string> = Object.create(null) as Record<string,string>;
8
+ for (const e of entries) if (e.manifest.mode === "delivered" && e.material) values[e.manifest.name] = e.material.value;
9
+ return { revision: JSON.stringify(entries.map(e => [e.manifest.name, e.manifest.generation, e.material !== null])), withheld: entries.map(e => e.manifest.name), values };
10
+ }
@@ -0,0 +1,42 @@
1
+ import { execFile } from "node:child_process";
2
+ import { statfs } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import type { HostCapabilities } from "../src/protocol";
5
+
6
+ type DiskSample = NonNullable<HostCapabilities["disk"]>;
7
+ const dockerInfo = (args: string[]): Promise<string | null> => new Promise(resolve => {
8
+ execFile("docker", args, { timeout: 5_000, maxBuffer: 4096 }, (error, stdout) => {
9
+ resolve(error ? null : stdout.trim());
10
+ });
11
+ });
12
+ async function dockerRoot(): Promise<string | null> {
13
+ // A remote daemon (including Docker Desktop's VM) has no local filesystem
14
+ // we can measure. Never report a coincidentally matching local path.
15
+ if (process.platform !== "linux") return null;
16
+ const endpoint = process.env.DOCKER_HOST && !process.env.DOCKER_CONTEXT
17
+ ? process.env.DOCKER_HOST
18
+ : await dockerInfo(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]);
19
+ if (!endpoint?.startsWith("unix://")) return null;
20
+ // Do not guess /var/lib/docker: data-root can be configured elsewhere.
21
+ const root = await dockerInfo(["info", "--format", "{{.DockerRootDir}}"]);
22
+ return root?.startsWith("/") ? root : null;
23
+ }
24
+
25
+ async function measure(path: string | null): Promise<DiskSample["home"]> {
26
+ if (!path) return null;
27
+ try {
28
+ const fs = await statfs(path);
29
+ const totalBytes = fs.blocks * fs.bsize;
30
+ // bavail (not bfree) accounts for root-reserved blocks: the host's
31
+ // unprivileged updater cannot use those even though root can.
32
+ const availableBytes = Math.max(0, fs.bavail * fs.bsize);
33
+ if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0 || !Number.isSafeInteger(availableBytes) || availableBytes < 0 || availableBytes > totalBytes) return null;
34
+ return { totalBytes, availableBytes };
35
+ } catch { return null; }
36
+ }
37
+
38
+ export async function sampleDiskPressure(): Promise<DiskSample> {
39
+ const [home, docker] = await Promise.all([measure(homedir()), dockerRoot().then(measure)]);
40
+ // No local paths are sent to the cloud; an unavailable probe is unknown.
41
+ return { sampledAt: Date.now(), home, docker };
42
+ }
@@ -1,3 +1,4 @@
1
+ import { ENGINE_ACCOUNT_PATHS } from "../src/engine-account-paths";
1
2
  /**
2
3
  * Engine accounts (ADR-076) — multiple credentials per engine kind, selected
3
4
  * by ENV at each `docker exec`, rotated automatically with rate-limit failover.
@@ -97,26 +98,23 @@ const KINDS: Record<string, KindDescriptor> = {
97
98
  grok: {
98
99
  authKind: "config-dir",
99
100
  defaultHostDir: (owner) => join(owner, ".grok"),
100
- defaultContainerDir: "/home/node/.grok",
101
+ ...ENGINE_ACCOUNT_PATHS.grok,
101
102
  selectVar: "GROK_HOME",
102
- containerDirPrefix: "/home/node/.grok-acct-",
103
103
  extraHostDir: (owner, id) => join(owner, `.grok-acct-${id}`),
104
104
  },
105
105
  cursor: { authKind: "env" },
106
106
  codex: {
107
107
  authKind: "config-dir",
108
108
  defaultHostDir: (owner) => join(owner, ".codex"),
109
- defaultContainerDir: "/home/node/.codex",
109
+ ...ENGINE_ACCOUNT_PATHS.codex,
110
110
  selectVar: "CODEX_HOME",
111
- containerDirPrefix: "/home/node/.codex-acct-",
112
111
  extraHostDir: (owner, id) => join(owner, `.codex-acct-${id}`),
113
112
  },
114
113
  opencode: {
115
114
  authKind: "config-dir",
116
115
  defaultHostDir: (owner) => join(owner, ".local", "share", "opencode"),
117
- defaultContainerDir: "/home/node/.local/share/opencode",
116
+ ...ENGINE_ACCOUNT_PATHS.opencode,
118
117
  selectVar: "OPENCODE_DATA_DIR",
119
- containerDirPrefix: "/home/node/.local/share/opencode-acct-",
120
118
  extraHostDir: (owner, id) => join(owner, ".local", "share", `opencode-acct-${id}`),
121
119
  },
122
120
  };
package/lib/host-env.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  queueStoreWrite,
24
24
  type CredentialStore,
25
25
  } from "./credential-store";
26
- import { ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
26
+ import { isReservedTaskEnvName } from "../src/task-env-names";
27
27
  import { getDb, schema } from "./db";
28
28
  import { sealAesGcm, openAesGcm } from "./secrets";
29
29
  import { signInBoxMode } from "./signin-box";
@@ -401,32 +401,7 @@ export function getDecryptedForProject(projectId: string): Record<string, string
401
401
  * container tasks: git/gh plumbing, loader and shell hooks, toolchain config,
402
402
  * the host's own `UAI_*` namespace, engine credentials, and the browser path.
403
403
  */
404
- const RESERVED_TASK_ENV_EXACT = new Set([
405
- "GH_TOKEN", "GITHUB_TOKEN", "GH_CONFIG_DIR", "GH_HOST",
406
- "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY",
407
- "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE", "GIT_EXEC_PATH",
408
- "GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
409
- "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
410
- "PATH", "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "NODE_OPTIONS",
411
- "NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
412
- "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
413
- "__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
414
- // Every name an env-token engine's account may set (ADR-154's contract):
415
- // a project value never stands in for an engine's credential.
416
- ...Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat(),
417
- "PLAYWRIGHT_BROWSERS_PATH",
418
- ]);
419
- const RESERVED_TASK_ENV_NPM = /^npm_config_(prefix|cache|logs[_-]dir|umask)$/i;
420
- /** Toolchain prefixes, in any case, as task-up.sh matches them. */
421
- const RESERVED_TASK_ENV_TOOLCHAIN = /^(asdf|corepack)_/i;
422
-
423
- export function isReservedTaskEnvName(name: string): boolean {
424
- if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
425
- if (name.startsWith("GIT_CONFIG") || RESERVED_TASK_ENV_TOOLCHAIN.test(name)) return true;
426
- if (name.startsWith("UAI_")) return true;
427
- if (name.startsWith("__UAI_ENV_")) return true;
428
- return RESERVED_TASK_ENV_NPM.test(name);
429
- }
404
+ export { isReservedTaskEnvName } from "../src/task-env-names";
430
405
 
431
406
  /**
432
407
  * Rollout-only project env for an ADR-121 machine task whose persisted mode