@runuai/host 0.9.155 → 0.9.157

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Bounded recovery for Docker's local-network address pool.
3
+ *
4
+ * Compose allocates one bridge network per local task. A task that fails after
5
+ * creating its network can remain resumable in `error`, and Docker retains the
6
+ * empty network. Enough such networks eventually make every later task fail
7
+ * with "all predefined address pools have been fully subnetted".
8
+ *
9
+ * This sweep is deliberately narrower than `docker network prune`:
10
+ *
11
+ * - the exact task/project identity must be present in trusted host state;
12
+ * - the task must not be starting or running;
13
+ * - Docker must independently report no attached container; and
14
+ * - deletion happens inside that task's lifecycle slot, so Resume/Stop cannot
15
+ * interleave with the proof. The slot is taken only if it is free at once:
16
+ * the sweep never waits on a task's lifecycle.
17
+ *
18
+ * Any unreadable or malformed observation means "do nothing", never
19
+ * permission to delete.
20
+ */
21
+
22
+ import type { HostTask } from "../db/schema";
23
+ import { dockerCli, type DockerResult } from "./docker-exec";
24
+ import { getHostTask } from "./runtime-state";
25
+ import { isSafeHostTaskId } from "./task-identity";
26
+ import { isTerminal } from "./task-status";
27
+ import { parseTaskEnvironmentLocator } from "./task-environment/types";
28
+
29
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
30
+ const NETWORK_LIST_FORMAT =
31
+ `{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
32
+ const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
33
+ const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
34
+ const DOCKER_TIMEOUT_MS = 10_000;
35
+
36
+ interface ComposeNetworkCandidate {
37
+ id: string;
38
+ name: string;
39
+ composeProject: string;
40
+ taskId: string;
41
+ }
42
+
43
+ export interface ComposeNetworkLifecycle {
44
+ /** Runs `operation` in the task's lifecycle slot only if that slot is free
45
+ * at once (the orchestrator's `tryRunTaskLifecycle`); never waits. */
46
+ tryRunTaskLifecycle<T>(
47
+ taskId: string,
48
+ operation: () => Promise<T>,
49
+ ): Promise<{ ran: true; value: T } | { ran: false }>;
50
+ }
51
+
52
+ export interface ComposeNetworkRecoveryDeps {
53
+ docker(args: string[], options?: {
54
+ timeoutMs?: number;
55
+ maxOutputBytes?: number;
56
+ }): Promise<DockerResult>;
57
+ task(taskId: string): HostTask | null;
58
+ }
59
+
60
+ export interface ComposeNetworkRecoveryResult {
61
+ inventory: "complete" | "unavailable" | "invalid";
62
+ candidates: number;
63
+ removed: number;
64
+ /** Candidates left alone because their task's lifecycle slot was busy. */
65
+ busy: number;
66
+ }
67
+
68
+ const defaultDeps: ComposeNetworkRecoveryDeps = {
69
+ docker: dockerCli,
70
+ task: getHostTask,
71
+ };
72
+
73
+ function parseCandidate(
74
+ line: string,
75
+ ): ComposeNetworkCandidate | "unrelated" | null {
76
+ const fields = line.split("\t");
77
+ if (fields.length !== 3) return null;
78
+ const [id, name, composeProject] = fields as [string, string, string];
79
+ if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
80
+ if (!composeProject.startsWith("task-")) return "unrelated";
81
+ const taskId = composeProject.slice("task-".length);
82
+ if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
83
+ if (name !== `${composeProject}_default`) return null;
84
+ return { id, name, composeProject, taskId };
85
+ }
86
+
87
+ function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
88
+ const candidates: ComposeNetworkCandidate[] = [];
89
+ const ids = new Set<string>();
90
+ for (const line of stdout.split(/\r?\n/)) {
91
+ if (line.length === 0) continue;
92
+ const candidate = parseCandidate(line);
93
+ if (candidate === "unrelated") continue;
94
+ if (candidate === null || ids.has(candidate.id)) return null;
95
+ ids.add(candidate.id);
96
+ candidates.push(candidate);
97
+ }
98
+ return candidates;
99
+ }
100
+
101
+ function taskOwnsReclaimableNetwork(
102
+ task: HostTask | null,
103
+ candidate: ComposeNetworkCandidate,
104
+ ): boolean {
105
+ if (
106
+ task === null ||
107
+ task.taskId !== candidate.taskId ||
108
+ (task.environmentProvider !== null &&
109
+ task.environmentProvider !== "docker-compose") ||
110
+ (task.statusMirror !== "error" &&
111
+ task.statusMirror !== "stopped" &&
112
+ !(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
113
+ ) {
114
+ return false;
115
+ }
116
+ if (task.composeProject === candidate.composeProject) return true;
117
+ if (!task.environmentLocator) return false;
118
+ try {
119
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
120
+ if (
121
+ locator.schemaVersion !== 1 ||
122
+ locator.provider !== "docker-compose" ||
123
+ !locator.value ||
124
+ typeof locator.value !== "object" ||
125
+ Array.isArray(locator.value)
126
+ ) {
127
+ return false;
128
+ }
129
+ const value = locator.value as Record<string, unknown>;
130
+ return (
131
+ value.schemaVersion === 2 &&
132
+ value.taskId === candidate.taskId &&
133
+ value.composeProject === candidate.composeProject &&
134
+ value.containerName === `${candidate.composeProject}-app-1`
135
+ );
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function inspectedEmptyNetwork(
142
+ stdout: string,
143
+ candidate: ComposeNetworkCandidate,
144
+ ): { fullId: string } | null {
145
+ let parsed: unknown;
146
+ try {
147
+ parsed = JSON.parse(stdout);
148
+ } catch {
149
+ return null;
150
+ }
151
+ if (!Array.isArray(parsed) || parsed.length !== 1) return null;
152
+ const network = parsed[0];
153
+ if (!network || typeof network !== "object" || Array.isArray(network)) {
154
+ return null;
155
+ }
156
+ const record = network as Record<string, unknown>;
157
+ const labels = record.Labels;
158
+ const containers = record.Containers;
159
+ if (
160
+ typeof record.Id !== "string" ||
161
+ !/^[a-f0-9]{64}$/.test(record.Id) ||
162
+ !record.Id.startsWith(candidate.id) ||
163
+ record.Name !== candidate.name ||
164
+ record.Driver !== "bridge" ||
165
+ record.Scope !== "local" ||
166
+ !labels ||
167
+ typeof labels !== "object" ||
168
+ Array.isArray(labels) ||
169
+ (labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
170
+ candidate.composeProject ||
171
+ !containers ||
172
+ typeof containers !== "object" ||
173
+ Array.isArray(containers) ||
174
+ Object.keys(containers).length !== 0
175
+ ) {
176
+ return null;
177
+ }
178
+ return { fullId: record.Id };
179
+ }
180
+
181
+ async function reclaimCandidate(
182
+ candidate: ComposeNetworkCandidate,
183
+ deps: ComposeNetworkRecoveryDeps,
184
+ ): Promise<boolean> {
185
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
186
+ return false;
187
+ }
188
+ const inspected = await deps.docker(
189
+ ["network", "inspect", candidate.id],
190
+ {
191
+ timeoutMs: DOCKER_TIMEOUT_MS,
192
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
193
+ },
194
+ );
195
+ if (inspected.status !== 0 || inspected.outputTruncated) return false;
196
+ const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
197
+ if (empty === null) return false;
198
+
199
+ // `.Containers` is the network's own endpoint inventory. Independently ask
200
+ // Docker's all-container inventory as a second positive emptiness proof.
201
+ const attached = await deps.docker(
202
+ ["ps", "-aq", "--filter", `network=${empty.fullId}`],
203
+ {
204
+ timeoutMs: DOCKER_TIMEOUT_MS,
205
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
206
+ },
207
+ );
208
+ if (
209
+ attached.status !== 0 ||
210
+ attached.outputTruncated ||
211
+ attached.stdout.trim().length !== 0
212
+ ) {
213
+ return false;
214
+ }
215
+
216
+ const removed = await deps.docker(["network", "rm", empty.fullId], {
217
+ timeoutMs: DOCKER_TIMEOUT_MS,
218
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
219
+ });
220
+ return removed.status === 0 && !removed.outputTruncated;
221
+ }
222
+
223
+ /**
224
+ * Remove only positively-owned, empty task networks. This is best-effort host
225
+ * hygiene: an unavailable inventory never blocks a task that may still fit in
226
+ * Docker's pool, and it never authorizes deletion.
227
+ */
228
+ export async function reclaimUnusedUaiComposeNetworks(
229
+ lifecycle: ComposeNetworkLifecycle,
230
+ deps: ComposeNetworkRecoveryDeps = defaultDeps,
231
+ ): Promise<ComposeNetworkRecoveryResult> {
232
+ const listed = await deps.docker(
233
+ [
234
+ "network",
235
+ "ls",
236
+ "--filter",
237
+ `label=${COMPOSE_PROJECT_LABEL}`,
238
+ "--format",
239
+ NETWORK_LIST_FORMAT,
240
+ ],
241
+ {
242
+ timeoutMs: DOCKER_TIMEOUT_MS,
243
+ maxOutputBytes: MAX_NETWORK_LIST_BYTES,
244
+ },
245
+ );
246
+ if (listed.status !== 0 || listed.outputTruncated) {
247
+ return { inventory: "unavailable", candidates: 0, removed: 0, busy: 0 };
248
+ }
249
+ const candidates = parseCandidates(listed.stdout);
250
+ if (candidates === null) {
251
+ return { inventory: "invalid", candidates: 0, removed: 0, busy: 0 };
252
+ }
253
+
254
+ let removed = 0;
255
+ let busy = 0;
256
+ for (const candidate of candidates) {
257
+ // Avoid taking a live start's slot merely to rediscover in-slot that it
258
+ // is not reclaimable. This observation grants no authority; the complete
259
+ // ownership/status check is repeated after the lifecycle slot is held.
260
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
261
+ continue;
262
+ }
263
+ // Never wait for the candidate's slot. A Resume marks its task `starting`
264
+ // only partway into its own slot, so the row can still read `error` while
265
+ // that slot is held, and waiting for it meant waiting out the whole
266
+ // start; a stuck operation on any stopped task would hold every later
267
+ // start here. A busy task's network is left for a later start's sweep.
268
+ const attempt = await lifecycle.tryRunTaskLifecycle(
269
+ candidate.taskId,
270
+ // The trusted row and Docker evidence are deliberately read only after
271
+ // entering the candidate task's lifecycle slot.
272
+ () => reclaimCandidate(candidate, deps),
273
+ );
274
+ if (!attempt.ran) busy += 1;
275
+ else if (attempt.value) removed += 1;
276
+ }
277
+ return {
278
+ inventory: "complete",
279
+ candidates: candidates.length,
280
+ removed,
281
+ busy,
282
+ };
283
+ }
@@ -114,7 +114,7 @@ export type ActivationAnswer = { ok: true } | { ok: false; error: string };
114
114
  const RECORD_SCHEMA_VERSION = 1;
115
115
  const STEPS: readonly ActivationStep[] = ["local", "cloud", "active"];
116
116
 
117
- export interface ActivationAuthorityDeps {
117
+ export interface ActivationAuthorityDeps<Retirement = RetirementPlan> {
118
118
  /** The durable record (non-secret), beside the runner's database. */
119
119
  recordPath: string;
120
120
  /** The credentials floor's file exists: the switch began, even with no
@@ -122,13 +122,17 @@ export interface ActivationAuthorityDeps {
122
122
  floorClaimed: () => boolean;
123
123
  /** Whether this runner moves to cloud credentials in this release. */
124
124
  eligible: () => boolean;
125
+ /** A marked task machine is born behind the v1 fence: it may ask the
126
+ * cloud to activate, but must never expose a transient `legacy` mode or
127
+ * request a v0 lease while that answer is pending. */
128
+ initiallyPending?: () => boolean;
125
129
  /** One phase asked of the cloud. Rejects when it could not be asked or
126
130
  * answered (no bridge, a timeout): the next attempt asks again. */
127
131
  ask: (phase: CredentialsActivationPhase) => Promise<ActivationAnswer>;
128
132
  /** Admit no new start on the runner's own credentials (cloud-credential-host). */
129
133
  pauseLegacyStarts: () => { drained: Promise<void>; resume(): void };
130
134
  /** The retirement's plan: changes nothing, rejects with every blocker. */
131
- planRetirement: () => Promise<RetirementPlan>;
135
+ planRetirement: () => Promise<Retirement>;
132
136
  /** Under the managed-operation lock, prove the next boot reads the record
133
137
  * and fence standalone recovery, then call `record` to write it
134
138
  * (managed-runtime.ts commitCredentialsSwitchLocally). */
@@ -140,7 +144,7 @@ export interface ActivationAuthorityDeps {
140
144
  /** Carry the plan out (idempotent). `stopped` names each task whose
141
145
  * runtime is gone, at its exact run epoch; `problem` says what could not
142
146
  * be proven removed. */
143
- retire: (plan: RetirementPlan) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
147
+ retire: (plan: Retirement) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
144
148
  /** Send each owed report again; returns those still owed (the moot ones
145
149
  * dropped: a newer run of the task, or its row gone). */
146
150
  reportStopped: (owed: readonly StoppedReport[]) => StoppedReport[];
@@ -165,8 +169,8 @@ export interface ActivationAuthorityDeps {
165
169
  log: (line: string) => void;
166
170
  }
167
171
 
168
- export class CredentialsActivationAuthority {
169
- readonly #deps: ActivationAuthorityDeps;
172
+ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
173
+ readonly #deps: ActivationAuthorityDeps<Retirement>;
170
174
  #record: ActivationRecord | null;
171
175
  /** `#record` is known to be durable (see the header). */
172
176
  #durable = false;
@@ -179,13 +183,18 @@ export class CredentialsActivationAuthority {
179
183
  #sweepsItself = false;
180
184
  /** The record was `active` when this process started. */
181
185
  readonly #activeAtBoot: boolean;
186
+ /** A fresh task-machine incarnation starts behind the v1 fence before it
187
+ * has a local activation record. Unlike an owned runner's reversible
188
+ * prepare, a cloud-marked incarnation must never fall back to v0. */
189
+ readonly #initiallyPending: boolean;
182
190
  #advancing: Promise<void> | null = null;
183
191
  #lastNote: string | null = null;
184
192
  /** Leaving the runner's own credentials failed in this process. */
185
193
  #stuck = false;
186
194
 
187
- constructor(deps: ActivationAuthorityDeps) {
195
+ constructor(deps: ActivationAuthorityDeps<Retirement>) {
188
196
  this.#deps = deps;
197
+ this.#initiallyPending = deps.initiallyPending?.() === true;
189
198
  this.#record = readActivationRecord(deps.recordPath);
190
199
  if (this.#record === null && deps.floorClaimed()) {
191
200
  // Only the switch claims the floor, after its record: a floor with no
@@ -210,7 +219,9 @@ export class CredentialsActivationAuthority {
210
219
  }
211
220
 
212
221
  mode(): CredentialsMode {
213
- if (this.#record === null) return "legacy";
222
+ if (this.#record === null) {
223
+ return this.#initiallyPending ? "pending" : "legacy";
224
+ }
214
225
  return this.#record.step === "active" && (this.#announced || this.#announcing) ? "active" : "pending";
215
226
  }
216
227
 
@@ -253,7 +264,7 @@ export class CredentialsActivationAuthority {
253
264
  await this.#activate();
254
265
  return;
255
266
  }
256
- let plan: RetirementPlan | null = null;
267
+ let plan: Retirement | null = null;
257
268
  if (this.#record === null) {
258
269
  if (!this.#deps.eligible()) return;
259
270
  const prepared = await this.#deps.ask("prepare");
@@ -276,7 +287,11 @@ export class CredentialsActivationAuthority {
276
287
  this.#record ??= readActivationRecord(this.#deps.recordPath);
277
288
  if (this.#record === null) {
278
289
  paused.resume();
279
- this.#note(`the runner stays on its own credentials, nothing changed: ${message(error)}`);
290
+ this.#note(
291
+ this.#initiallyPending
292
+ ? `the v1-required task machine stays pending, with no v0 fallback: ${message(error)}`
293
+ : `the runner stays on its own credentials, nothing changed: ${message(error)}`,
294
+ );
280
295
  return;
281
296
  }
282
297
  }
@@ -395,7 +410,7 @@ export class CredentialsActivationAuthority {
395
410
 
396
411
  /** The retirement, its reports owed durably before they are sent, and
397
412
  * sent; rejects when anything could not be proven removed. */
398
- async #retire(plan: RetirementPlan): Promise<void> {
413
+ async #retire(plan: Retirement): Promise<void> {
399
414
  const { stopped, problem } = await this.#deps.retire(plan);
400
415
  const owed = new Map((this.#record?.stopped ?? []).map((report) => [report.taskId, report]));
401
416
  for (const report of stopped) owed.set(report.taskId, report);
@@ -847,6 +847,35 @@ export class Orchestrator {
847
847
  }
848
848
  }
849
849
 
850
+ /** Run one task lifecycle mutation only if the task's slot is free right
851
+ * now: nothing running or queued for it, and its boot recovery finished.
852
+ * Never waits, so best-effort work on another task's resources (the
853
+ * task-network reclaim) cannot be held up by a long start or a stuck
854
+ * operation. The check and the claim happen in one synchronous step, so
855
+ * nothing can enter between them, and a `runTaskLifecycle` that arrives
856
+ * meanwhile queues behind this one as it would behind any other. */
857
+ async tryRunTaskLifecycle<T>(
858
+ taskId: string,
859
+ operation: () => Promise<T>,
860
+ ): Promise<{ ran: true; value: T } | { ran: false }> {
861
+ if (this.taskLifecycleTails.has(taskId) || taskRecoveryPending(taskId)) {
862
+ return { ran: false };
863
+ }
864
+ let release!: () => void;
865
+ const tail = new Promise<void>((resolve) => {
866
+ release = resolve;
867
+ });
868
+ this.taskLifecycleTails.set(taskId, tail);
869
+ try {
870
+ return { ran: true, value: await operation() };
871
+ } finally {
872
+ release();
873
+ if (this.taskLifecycleTails.get(taskId) === tail) {
874
+ this.taskLifecycleTails.delete(taskId);
875
+ }
876
+ }
877
+ }
878
+
850
879
  /** Emit a host-originated system note into a task's channel (ADR-027). */
851
880
  emitSystemNote(taskId: string, text: string): void {
852
881
  this.emitHost({ kind: "system.note", taskId, text });
@@ -7481,6 +7510,12 @@ export function taskRecoveryComplete(taskId: string): Promise<void> {
7481
7510
  return taskRecoveryBarriers.get(taskId)?.promise ?? Promise.resolve();
7482
7511
  }
7483
7512
 
7513
+ /** Boot recovery has not yet finished with this task. Work that must not
7514
+ * wait (`tryRunTaskLifecycle`) skips the task rather than join its barrier. */
7515
+ export function taskRecoveryPending(taskId: string): boolean {
7516
+ return taskRecoveryBarriers.has(taskId);
7517
+ }
7518
+
7484
7519
  /**
7485
7520
  * Resolves once boot-time recovery has finished (starting the orchestrator —
7486
7521
  * and with it recovery — if that hasn't happened yet). Never rejects; false
@@ -714,6 +714,24 @@ export function standardImageVolumeMaintenanceCommandForTest(
714
714
  `ensure_real_dir "$node_root" node-root; ` +
715
715
  `ensure_real_dir "$shims_root" shims-root; ` +
716
716
  `ensure_real_dir "$corepack_root" corepack-root; ` +
717
+ // Legacy images could seed individual shims as root even though the
718
+ // directory itself was later repaired. asdf 0.14 then prints permission
719
+ // errors while reshim quietly leaves the old executable behind; task
720
+ // materialization can appear successful but the next CLI launch is stale
721
+ // or non-executable. The trusted root maintenance transaction repairs only
722
+ // the flat, regular-file shim set before dropping to node for every asdf
723
+ // mutation. Anything else is ambiguous volume content and fails closed.
724
+ `repair_shim_metadata() { ` +
725
+ `unsafe_shim_entry="$(/usr/bin/find "$shims_root" -xdev -mindepth 1 ` +
726
+ `-maxdepth 1 ! -type f -print -quit)"; ` +
727
+ `[ -z "$unsafe_shim_entry" ] || fail unsafe-shim-entry; ` +
728
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
729
+ `-exec /usr/bin/chown node:node -- {} + ` +
730
+ `|| fail repair-shim-owner; ` +
731
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
732
+ `-exec /usr/bin/chmod 0755 -- {} + ` +
733
+ `|| fail repair-shim-mode; }; ` +
734
+ `repair_shim_metadata; ` +
717
735
  `candidate_is_safe() { ` +
718
736
  `lexical_candidate="$(/usr/bin/realpath -m -s -- "$candidate")" ` +
719
737
  `|| return 1; [ "$lexical_candidate" = "$node_root/$default_node" ] ` +
@@ -61,9 +61,23 @@ import {
61
61
  } from "./machine-environment";
62
62
  import { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH } from "./self-identity";
63
63
  import { readBoundedFile, workspacePathParts } from "./workspace-files";
64
+ import { assertTaskFilesMachineProcessAllowed } from "../task-files-machine-retirement";
64
65
 
65
66
  export { ETC_ENVIRONMENT_PATH, parseEtcEnvironment, SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH };
66
67
 
68
+ /**
69
+ * A terminal Files failure latches before its synchronous handler runs. Check
70
+ * on both sides of a caller's own admission callback so that callback cannot
71
+ * trigger retirement and then let this launch repopulate the cgroup the
72
+ * handler just emptied. JavaScript runs the final check and node spawn in one
73
+ * turn, so no other callback can interleave between them.
74
+ */
75
+ function admitTaskMachineCall(admit?: () => void): void {
76
+ assertTaskFilesMachineProcessAllowed();
77
+ admit?.();
78
+ assertTaskFilesMachineProcessAllowed();
79
+ }
80
+
67
81
  /**
68
82
  * Binds the view, then runs the rest of its argv. With no view yet (nothing
69
83
  * pinned so far: a start whose CA failed, the proxy off), it first makes one,
@@ -281,7 +295,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
281
295
 
282
296
  async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
283
297
  assertWorkspaceScopedPath(this.descriptor, path);
284
- options?.admit?.();
298
+ admitTaskMachineCall(options?.admit);
285
299
  return readBoundedFile(this.descriptor.workspacePath, workspacePathParts(this.descriptor, path), options);
286
300
  }
287
301
 
@@ -355,7 +369,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
355
369
  refusePrivilegedExec(request);
356
370
  const scoped = this.#withMachineEnv(request);
357
371
  const { command, args, env } = this.#command(scoped, "interactive");
358
- request.admit?.();
372
+ admitTaskMachineCall(request.admit);
359
373
  return capturedCliExec(this.#spawn, command, args, scoped, env);
360
374
  }
361
375
 
@@ -363,7 +377,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
363
377
  assertTaskEnvironmentProcessRequest(request);
364
378
  const scoped = this.#withMachineEnv(request);
365
379
  const { command, args, env } = this.#command(scoped, "interactive");
366
- request.admit?.();
380
+ admitTaskMachineCall(request.admit);
367
381
  return streamingCliExec(this.#spawn, command, args, scoped, env);
368
382
  }
369
383
 
@@ -374,7 +388,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
374
388
  cwd: request.cwd ?? this.#value.workspacePath,
375
389
  });
376
390
  const { command, args, env } = this.#command(session, "interactive");
377
- request.admit?.();
391
+ admitTaskMachineCall(request.admit);
378
392
  return streamingCliExec(this.#spawn, command, args, session, env);
379
393
  }
380
394
 
@@ -387,7 +401,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
387
401
  cwd: request.cwd ?? this.#value.workspacePath,
388
402
  });
389
403
  const { command, args, env } = this.#command(session, "detached");
390
- request.admit?.();
404
+ admitTaskMachineCall(request.admit);
391
405
  return capturedCliExec(
392
406
  this.#spawn,
393
407
  command,