@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.
@@ -44,6 +44,7 @@ import {
44
44
  spawnEnvironmentProcess,
45
45
  type AgentEnvironmentProcess,
46
46
  } from "./proc";
47
+ import { rateLimitErrorUsage } from "./rate-limit";
47
48
  import { register } from "./registry";
48
49
  import { GROK_MODELS } from "../../src/engine-models";
49
50
  import type {
@@ -58,10 +59,9 @@ import type {
58
59
  const GROK_BIN = "/home/node/.grok/bin/grok";
59
60
  export const GROK_LOADER_LOGIN_ENV = "0";
60
61
 
61
- // `grok models` lists what the account can run. grok-4.5 is the current
62
- // default; UPDATE WHEN xAI CHANGES the lineup.
63
- // The model list is host-agent/src/engine-models.ts (GROK_MODELS).
64
- const GROK_DEFAULT_MODEL = "grok-4.5";
62
+ // Static fallback catalog verified against `grok models`; keep the shared list
63
+ // in host-agent/src/engine-models.ts (GROK_MODELS) current with the pinned CLI.
64
+ const GROK_DEFAULT_MODEL = "grok-4.7";
65
65
  // Grok has no per-invocation reasoning-effort flag.
66
66
  const GROK_EFFORTS: string[] = [];
67
67
 
@@ -313,10 +313,23 @@ export class GrokSession implements AgentSession {
313
313
  this.emit({ type: "error", message: `grok spawn failed: ${spawnErr}` });
314
314
  } else if (code !== 0) {
315
315
  const tail = stderr.trim().slice(-500);
316
- this.emit({
317
- type: "error",
318
- message: `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
319
- });
316
+ const message = `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
317
+ // Same path as Claude/Codex/OpenCode: a usage-cap / 429 error emits
318
+ // account_usage (source error-text) so the orchestrator can rotate.
319
+ const accountUsage = rateLimitErrorUsage(message);
320
+ if (accountUsage) {
321
+ this.emit({ type: "account_usage", usage: accountUsage });
322
+ this.emit({
323
+ type: "error",
324
+ message,
325
+ retryable: "rate_limit",
326
+ ...(accountUsage.resetsAt === undefined
327
+ ? {}
328
+ : { resetsAt: accountUsage.resetsAt }),
329
+ });
330
+ } else {
331
+ this.emit({ type: "error", message });
332
+ }
320
333
  }
321
334
  // Finalize the streamed message (no-op text if the turn produced none).
322
335
  if (sawText) this.emit({ type: "message_complete", text: acc });
@@ -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>();
@@ -144,25 +144,25 @@ export function capabilities(): AgentKindCapability[] {
144
144
  : (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
145
145
  )
146
146
  .map((adapter) => {
147
- const out: AgentKindCapability = {
148
- kind: adapter.kind,
149
- label: adapter.label,
150
- supportedModels: adapter.supportedModels(),
151
- supportedEfforts: adapter.supportedEfforts(),
152
- };
153
- if (adapter.defaultModel !== undefined) {
154
- out.defaultModel = adapter.defaultModel;
155
- }
156
- if (adapter.defaultEffort !== undefined) {
157
- out.defaultEffort = adapter.defaultEffort;
158
- }
159
- if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
160
- out.executionProfiles = adapter.executionProfiles.map((profile) => ({
161
- ...profile,
162
- }));
163
- }
164
- return out;
165
- });
147
+ const out: AgentKindCapability = {
148
+ kind: adapter.kind,
149
+ label: adapter.label,
150
+ supportedModels: adapter.supportedModels(),
151
+ supportedEfforts: adapter.supportedEfforts(),
152
+ };
153
+ if (adapter.defaultModel !== undefined) {
154
+ out.defaultModel = adapter.defaultModel;
155
+ }
156
+ if (adapter.defaultEffort !== undefined) {
157
+ out.defaultEffort = adapter.defaultEffort;
158
+ }
159
+ if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
160
+ out.executionProfiles = adapter.executionProfiles.map((profile) => ({
161
+ ...profile,
162
+ }));
163
+ }
164
+ return out;
165
+ });
166
166
  }
167
167
 
168
168
  /**
@@ -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);
@@ -124,7 +124,7 @@ export interface AgentAccountUsage {
124
124
  resetsAt?: number;
125
125
  usingOverage?: boolean;
126
126
  windows: EngineUsageWindow[];
127
- source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
127
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text" | "cursor-period-usage";
128
128
  observedAt: number;
129
129
  /** Which provider capacity this reading can prove usable again. Claude emits
130
130
  * one event per named window; Codex snapshots cover every window. Internal
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Shared token/cost extraction for the Claude-Code-compatible stream-json
3
- * `result` envelope (ADR-071 metering). Claude Code AND Cursor Agent both emit
4
- * this exact shape — `usage` token breakdown, an exact `total_cost_usd`, and
5
- * per-model `modelUsage` — so both engines share this one extractor.
2
+ * Shared token/cost extraction for a stream-json `result` envelope (ADR-071
3
+ * metering). Claude Code reports snake_case `usage` plus `total_cost_usd` and
4
+ * per-model `modelUsage`. cursor-agent 2026.10.x reports camelCase counts and
5
+ * no cost: `{inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens}`.
6
+ * Snake_case wins when a field is present, so Claude's path is unchanged.
6
7
  *
7
8
  * Codex (app-server protocol) and Kimi/Grok (plain text streams) report no
8
- * token/cost data, so they have no extractor.
9
+ * token/cost data through this extractor.
9
10
  */
10
11
 
11
12
  import type { AgentUsage } from "./types";
@@ -18,6 +19,12 @@ function num(v: unknown): number | undefined {
18
19
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
19
20
  }
20
21
 
22
+ /** Claude's name when it is a number, otherwise Cursor 2026.10's. */
23
+ function firstNum(claude: unknown, cursor: unknown): number | undefined {
24
+ const primary = num(claude);
25
+ return primary !== undefined ? primary : num(cursor);
26
+ }
27
+
21
28
  /** Pull AgentUsage from a `result` line, or undefined when none is reported. */
22
29
  export function extractResultUsage(
23
30
  json: Record<string, unknown>,
@@ -26,10 +33,10 @@ export function extractResultUsage(
26
33
  const models = isObj(json.modelUsage) ? Object.keys(json.modelUsage) : [];
27
34
  const usage: AgentUsage = {
28
35
  model: models.length ? models.join(", ") : undefined,
29
- inputTokens: num(u.input_tokens),
30
- outputTokens: num(u.output_tokens),
31
- cacheReadTokens: num(u.cache_read_input_tokens),
32
- cacheCreateTokens: num(u.cache_creation_input_tokens),
36
+ inputTokens: firstNum(u.input_tokens, u.inputTokens),
37
+ outputTokens: firstNum(u.output_tokens, u.outputTokens),
38
+ cacheReadTokens: firstNum(u.cache_read_input_tokens, u.cacheReadTokens),
39
+ cacheCreateTokens: firstNum(u.cache_creation_input_tokens, u.cacheWriteTokens),
33
40
  costUsd: num(json.total_cost_usd),
34
41
  };
35
42
  // Nothing usable reported → omit rather than send an empty object.
@@ -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
+ }
@@ -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