@runuai/host 0.9.148 → 0.9.150

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.
Files changed (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
@@ -8,6 +8,12 @@
8
8
 
9
9
  export const TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION = 1 as const;
10
10
 
11
+ /** Durable rollout generation for project environment delivery (ADR-153). */
12
+ export type ProjectEnvMode = "baked" | "loader";
13
+
14
+ /** Every process launch is explicitly classified at the type boundary. */
15
+ export type ProjectEnvProfile = "maintenance" | "task";
16
+
11
17
  export interface TaskEnvironmentLocator {
12
18
  schemaVersion: typeof TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION;
13
19
  /** Stable provider registry key, for example `docker-compose`. */
@@ -19,6 +25,8 @@ export interface TaskEnvironmentLocator {
19
25
  export interface TaskEnvironmentDescriptor {
20
26
  taskId: string;
21
27
  locator: TaskEnvironmentLocator;
28
+ /** Persisted independently from the provider locator for rollout safety. */
29
+ envMode?: ProjectEnvMode;
22
30
  /** Absolute path as observed inside the environment. */
23
31
  workspacePath: string;
24
32
  codeServerPort?: number;
@@ -34,9 +42,25 @@ export interface TaskEnvironmentExecRequest {
34
42
  /** Host environment names inherited by name, without copying values into argv. */
35
43
  inheritEnv?: readonly string[];
36
44
  env?: Readonly<Record<string, string>>;
45
+ projectEnvProfile: ProjectEnvProfile;
37
46
  stdin?: Uint8Array;
38
47
  timeoutMs: number;
39
48
  maxOutputBytes: number;
49
+ /**
50
+ * Run with every capability, as root only: the secrets proxy's NAT step
51
+ * needs NET_ADMIN, which a task container does not have (ADR-153). Docker
52
+ * only (`docker exec --privileged`, one process, the container keeps its
53
+ * default capabilities); every other provider refuses it.
54
+ */
55
+ privileged?: true;
56
+ /**
57
+ * Called by the provider at the last moment before the process starts,
58
+ * after anything it awaits first (a machine looks up its address);
59
+ * throwing refuses the exec unstarted. The secrets proxy's root-step
60
+ * fence rides on it, so a step's exec cannot start once its operation is
61
+ * no longer live, however long that lookup takes (ADR-153).
62
+ */
63
+ admit?: () => void;
40
64
  }
41
65
 
42
66
  export interface TaskEnvironmentExecResult {
@@ -55,6 +79,7 @@ export interface TaskEnvironmentSpawnRequest {
55
79
  /** Host environment names inherited by name, without copying values into argv. */
56
80
  inheritEnv?: readonly string[];
57
81
  env?: Readonly<Record<string, string>>;
82
+ projectEnvProfile: ProjectEnvProfile;
58
83
  timeoutMs: number;
59
84
  maxOutputBytes: number;
60
85
  }
@@ -70,6 +95,7 @@ export interface TaskEnvironmentSessionRequest {
70
95
  user?: string;
71
96
  inheritEnv?: readonly string[];
72
97
  env?: Readonly<Record<string, string>>;
98
+ projectEnvProfile: ProjectEnvProfile;
73
99
  maxOutputBytes: number;
74
100
  }
75
101
 
@@ -161,6 +187,8 @@ export interface TaskEnvironmentRecoveryContext {
161
187
  isCurrent(): boolean;
162
188
  /** Drain/tombstone consumers before stopping or repairing a live writer. */
163
189
  quarantineConsumers?: () => Promise<void>;
190
+ /** Reassert task-local security/runtime prerequisites before admission. */
191
+ prepareConsumers?: () => Promise<void>;
164
192
  /** Re-open admission only after the environment is proven operational. */
165
193
  allowConsumers?: () => void;
166
194
  /** Recreate durable consumers after admission has reopened. */
@@ -232,6 +260,8 @@ export interface TaskEnvironmentHandle<TTeardownResult = void>
232
260
 
233
261
  export interface TaskEnvironmentProvisionRequest<TInput, TCredentials> {
234
262
  taskId: string;
263
+ /** Omitted only by direct legacy/provider callers; new orchestration passes loader. */
264
+ envMode?: ProjectEnvMode;
235
265
  input: TInput;
236
266
  credentials: TCredentials;
237
267
  /**
@@ -260,6 +290,7 @@ export interface TaskEnvironmentProvider<
260
290
  ): Promise<TaskEnvironmentProvisioned<TResult, TTeardownResult>>;
261
291
  reconstruct(
262
292
  locator: TaskEnvironmentLocator,
293
+ envMode?: ProjectEnvMode,
263
294
  ): Promise<TaskEnvironmentHandle<TTeardownResult>>;
264
295
  }
265
296
 
@@ -288,6 +319,17 @@ export function assertWorkspaceScopedPath(
288
319
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
289
320
 
290
321
  /** Shared admission guard for provider implementations and fake providers. */
322
+ /** A one-shot exec: the only request that may be privileged, and then only
323
+ * as root. Providers pass the privilege on explicitly, never by reading the
324
+ * request, so no other surface can carry it. */
325
+ export function assertTaskEnvironmentExecRequest(request: TaskEnvironmentExecRequest): void {
326
+ const { privileged, ...unprivileged } = request;
327
+ if (privileged !== undefined && (privileged !== true || request.user !== "root")) {
328
+ throw new Error("a privileged task environment exec must run as root");
329
+ }
330
+ assertTaskEnvironmentProcessRequest(unprivileged);
331
+ }
332
+
291
333
  export function assertTaskEnvironmentProcessRequest(
292
334
  request: TaskEnvironmentExecRequest | TaskEnvironmentSpawnRequest,
293
335
  ): void {
@@ -297,6 +339,14 @@ export function assertTaskEnvironmentProcessRequest(
297
339
  }
298
340
  }
299
341
 
342
+ /** For providers with no privileged exec: refuse it rather than run the
343
+ * step without the capability it asked for. */
344
+ export function refusePrivilegedExec(request: TaskEnvironmentExecRequest): void {
345
+ if (request.privileged === true) {
346
+ throw new Error("this task environment provider has no privileged exec");
347
+ }
348
+ }
349
+
300
350
  export function assertTaskEnvironmentSessionRequest(
301
351
  request:
302
352
  | TaskEnvironmentSessionRequest
@@ -304,6 +354,19 @@ export function assertTaskEnvironmentSessionRequest(
304
354
  | TaskEnvironmentExecRequest
305
355
  | TaskEnvironmentSpawnRequest,
306
356
  ): void {
357
+ // Only a one-shot exec may be privileged (assertTaskEnvironmentExecRequest
358
+ // strips it after checking); anywhere else it is refused outright.
359
+ if ("privileged" in request) {
360
+ throw new Error("only a one-shot task environment exec may be privileged");
361
+ }
362
+ if (
363
+ request.projectEnvProfile !== "maintenance" &&
364
+ request.projectEnvProfile !== "task"
365
+ ) {
366
+ throw new Error(
367
+ "task environment project env profile must be maintenance or task",
368
+ );
369
+ }
307
370
  if (
308
371
  !Array.isArray(request.argv) ||
309
372
  request.argv.length === 0 ||
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The port the runner's task gateway listens on (`UAI_MCP_GATEWAY_PORT`,
3
+ * 5877 by default): where task forwarders relay, and where a machine's
4
+ * sidecar serves its own task directly on loopback. A module of its own so
5
+ * the secrets proxy can name it without loading the gateway.
6
+ */
7
+
8
+ const DEFAULT_MCP_GATEWAY_PORT = 5877;
9
+
10
+ function gatewayPort(value: string | undefined): number {
11
+ if (value === undefined || !/^\d+$/.test(value)) {
12
+ return DEFAULT_MCP_GATEWAY_PORT;
13
+ }
14
+ const port = Number(value);
15
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65_535
16
+ ? port
17
+ : DEFAULT_MCP_GATEWAY_PORT;
18
+ }
19
+
20
+ export const MCP_GATEWAY_PORT = gatewayPort(
21
+ process.env.UAI_MCP_GATEWAY_PORT,
22
+ );
@@ -0,0 +1,190 @@
1
+ import {
2
+ createHmac,
3
+ timingSafeEqual,
4
+ type KeyObject,
5
+ } from "node:crypto";
6
+
7
+ /**
8
+ * Binary, versioned task-forwarder handshake (ADR-153).
9
+ *
10
+ * The leading NUL cannot be the first byte of either HTTP/1.1 or TLS. That
11
+ * lets the shared gateway reject direct TLS while keeping the existing MCP
12
+ * HTTP listener on the same port. Length-prefixed fields keep unauthenticated
13
+ * input bounded before a task identity has been proved.
14
+ */
15
+ export const TASK_FORWARDER_PREAMBLE_MAGIC = Buffer.from([
16
+ 0x00, 0x55, 0x41, 0x49, 0x46, 0x57, 0x44, 0x01,
17
+ ]);
18
+ export const TASK_FORWARDER_CHALLENGE_MAGIC = Buffer.from("UAICHL01", "ascii");
19
+ export const TASK_FORWARDER_RESPONSE_MAGIC = Buffer.from("UAIRES01", "ascii");
20
+ export const TASK_FORWARDER_ACCEPT_MAGIC = Buffer.from("UAIOK001", "ascii");
21
+
22
+ export const TASK_FORWARDER_SECRET_BYTES = 32;
23
+ /** Numerical container uid reserved for the forwarder (neither root nor node). */
24
+ export const TASK_FORWARDER_UID = 65_532;
25
+ export const TASK_FORWARDER_NONCE_BYTES = 32;
26
+ export const TASK_FORWARDER_MAC_BYTES = 32;
27
+ export const TASK_FORWARDER_MAX_TASK_ID_BYTES = 128;
28
+ export const TASK_FORWARDER_MAX_GENERATION_BYTES = 64;
29
+ export const TASK_FORWARDER_PREAMBLE_HEADER_BYTES =
30
+ TASK_FORWARDER_PREAMBLE_MAGIC.length + 2;
31
+ export const TASK_FORWARDER_MAX_PREAMBLE_BYTES =
32
+ TASK_FORWARDER_PREAMBLE_HEADER_BYTES +
33
+ TASK_FORWARDER_MAX_TASK_ID_BYTES +
34
+ TASK_FORWARDER_MAX_GENERATION_BYTES;
35
+ export const TASK_FORWARDER_CHALLENGE_BYTES =
36
+ TASK_FORWARDER_CHALLENGE_MAGIC.length + TASK_FORWARDER_NONCE_BYTES;
37
+ export const TASK_FORWARDER_RESPONSE_BYTES =
38
+ TASK_FORWARDER_RESPONSE_MAGIC.length + TASK_FORWARDER_MAC_BYTES;
39
+
40
+ const FORWARDER_HMAC_DOMAIN = Buffer.from(
41
+ "uai-task-forwarder-v1\0",
42
+ "ascii",
43
+ );
44
+ const FIELD_SEPARATOR = Buffer.from([0]);
45
+ const FORWARDER_GENERATION = /^[A-Za-z0-9_-]{16,64}$/;
46
+
47
+ export interface TaskForwarderIdentity {
48
+ taskId: string;
49
+ generation: string;
50
+ }
51
+
52
+ export function encodeTaskForwarderPreamble(
53
+ identity: TaskForwarderIdentity,
54
+ ): Buffer {
55
+ const taskId = Buffer.from(identity.taskId, "utf8");
56
+ const generation = Buffer.from(identity.generation, "ascii");
57
+ if (
58
+ taskId.length < 1 ||
59
+ taskId.length > TASK_FORWARDER_MAX_TASK_ID_BYTES ||
60
+ generation.length < 1 ||
61
+ generation.length > TASK_FORWARDER_MAX_GENERATION_BYTES ||
62
+ !FORWARDER_GENERATION.test(identity.generation)
63
+ ) {
64
+ throw new Error("invalid task forwarder identity");
65
+ }
66
+ return Buffer.concat([
67
+ TASK_FORWARDER_PREAMBLE_MAGIC,
68
+ Buffer.from([taskId.length, generation.length]),
69
+ taskId,
70
+ generation,
71
+ ]);
72
+ }
73
+
74
+ export function taskForwarderPreambleLength(header: Buffer): number | null {
75
+ if (
76
+ header.length !== TASK_FORWARDER_PREAMBLE_HEADER_BYTES ||
77
+ !header.subarray(0, TASK_FORWARDER_PREAMBLE_MAGIC.length).equals(
78
+ TASK_FORWARDER_PREAMBLE_MAGIC,
79
+ )
80
+ ) {
81
+ return null;
82
+ }
83
+ const taskIdBytes = header[TASK_FORWARDER_PREAMBLE_MAGIC.length] ?? 0;
84
+ const generationBytes =
85
+ header[TASK_FORWARDER_PREAMBLE_MAGIC.length + 1] ?? 0;
86
+ if (
87
+ taskIdBytes < 1 ||
88
+ taskIdBytes > TASK_FORWARDER_MAX_TASK_ID_BYTES ||
89
+ generationBytes < 1 ||
90
+ generationBytes > TASK_FORWARDER_MAX_GENERATION_BYTES
91
+ ) {
92
+ return null;
93
+ }
94
+ return TASK_FORWARDER_PREAMBLE_HEADER_BYTES + taskIdBytes + generationBytes;
95
+ }
96
+
97
+ export function decodeTaskForwarderPreamble(
98
+ preamble: Buffer,
99
+ ): TaskForwarderIdentity | null {
100
+ const header = preamble.subarray(0, TASK_FORWARDER_PREAMBLE_HEADER_BYTES);
101
+ const expectedLength = taskForwarderPreambleLength(header);
102
+ if (expectedLength === null || preamble.length !== expectedLength) return null;
103
+
104
+ const taskIdBytes = header[TASK_FORWARDER_PREAMBLE_MAGIC.length] ?? 0;
105
+ const taskStart = TASK_FORWARDER_PREAMBLE_HEADER_BYTES;
106
+ const generationStart = taskStart + taskIdBytes;
107
+ const rawTaskId = preamble.subarray(taskStart, generationStart);
108
+ const rawGeneration = preamble.subarray(generationStart);
109
+ const taskId = rawTaskId.toString("utf8");
110
+ const generation = rawGeneration.toString("ascii");
111
+ // Both Node decoders replace or mask malformed bytes. Require exact
112
+ // round-trips so two wire identities can never alias the same HMAC input.
113
+ if (
114
+ !Buffer.from(taskId, "utf8").equals(rawTaskId) ||
115
+ !Buffer.from(generation, "ascii").equals(rawGeneration) ||
116
+ !FORWARDER_GENERATION.test(generation)
117
+ ) {
118
+ return null;
119
+ }
120
+ return { taskId, generation };
121
+ }
122
+
123
+ export function encodeTaskForwarderChallenge(nonce: Buffer): Buffer {
124
+ if (nonce.length !== TASK_FORWARDER_NONCE_BYTES) {
125
+ throw new Error("invalid task forwarder nonce");
126
+ }
127
+ return Buffer.concat([TASK_FORWARDER_CHALLENGE_MAGIC, nonce]);
128
+ }
129
+
130
+ export function decodeTaskForwarderChallenge(
131
+ challenge: Buffer,
132
+ ): Buffer | null {
133
+ if (
134
+ challenge.length !== TASK_FORWARDER_CHALLENGE_BYTES ||
135
+ !challenge.subarray(0, TASK_FORWARDER_CHALLENGE_MAGIC.length).equals(
136
+ TASK_FORWARDER_CHALLENGE_MAGIC,
137
+ )
138
+ ) {
139
+ return null;
140
+ }
141
+ return Buffer.from(challenge.subarray(TASK_FORWARDER_CHALLENGE_MAGIC.length));
142
+ }
143
+
144
+ export function taskForwarderMac(
145
+ secret: Buffer | KeyObject,
146
+ identity: TaskForwarderIdentity,
147
+ nonce: Buffer,
148
+ ): Buffer {
149
+ if (nonce.length !== TASK_FORWARDER_NONCE_BYTES) {
150
+ throw new Error("invalid task forwarder nonce");
151
+ }
152
+ return createHmac("sha256", secret)
153
+ .update(FORWARDER_HMAC_DOMAIN)
154
+ .update(identity.taskId, "utf8")
155
+ .update(FIELD_SEPARATOR)
156
+ .update(identity.generation, "ascii")
157
+ .update(FIELD_SEPARATOR)
158
+ .update(nonce)
159
+ .digest();
160
+ }
161
+
162
+ export function encodeTaskForwarderResponse(mac: Buffer): Buffer {
163
+ if (mac.length !== TASK_FORWARDER_MAC_BYTES) {
164
+ throw new Error("invalid task forwarder response");
165
+ }
166
+ return Buffer.concat([TASK_FORWARDER_RESPONSE_MAGIC, mac]);
167
+ }
168
+
169
+ export function decodeTaskForwarderResponse(response: Buffer): Buffer | null {
170
+ if (
171
+ response.length !== TASK_FORWARDER_RESPONSE_BYTES ||
172
+ !response.subarray(0, TASK_FORWARDER_RESPONSE_MAGIC.length).equals(
173
+ TASK_FORWARDER_RESPONSE_MAGIC,
174
+ )
175
+ ) {
176
+ return null;
177
+ }
178
+ return Buffer.from(response.subarray(TASK_FORWARDER_RESPONSE_MAGIC.length));
179
+ }
180
+
181
+ export function verifyTaskForwarderMac(
182
+ expected: Buffer,
183
+ provided: Buffer,
184
+ ): boolean {
185
+ return (
186
+ expected.length === TASK_FORWARDER_MAC_BYTES &&
187
+ provided.length === expected.length &&
188
+ timingSafeEqual(expected, provided)
189
+ );
190
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ activeLoaderHostTasks,
3
+ hostTaskProjectEnvSpec,
4
+ } from "./runtime-state";
5
+ import { taskEnvResolver } from "./task-env-source";
6
+ import { recoverTaskGatewayTask } from "./task-gateway-state";
7
+
8
+ export interface TaskGatewayRecoverySummary {
9
+ readonly recovered: number;
10
+ readonly failed: number;
11
+ }
12
+
13
+ /**
14
+ * Reattach value resolvers before the gateway starts listening. Persisted
15
+ * credentials load inactive, so a malformed row or missing identity stays
16
+ * fail-closed and its surviving forwarder cannot authenticate.
17
+ */
18
+ export function recoverActiveTaskGateways(): TaskGatewayRecoverySummary {
19
+ let recovered = 0;
20
+ let failed = 0;
21
+ for (const task of activeLoaderHostTasks()) {
22
+ try {
23
+ const spec = hostTaskProjectEnvSpec(task);
24
+ if (spec === null) {
25
+ throw new Error("loader task has no persisted project env spec");
26
+ }
27
+ const identity = recoverTaskGatewayTask(task.taskId, {
28
+ resolveEnv: taskEnvResolver(task.taskId, spec),
29
+ });
30
+ if (identity === null) {
31
+ throw new Error("loader task has no valid persisted gateway identity");
32
+ }
33
+ recovered += 1;
34
+ } catch (error) {
35
+ failed += 1;
36
+ console.warn(
37
+ `[task-gateway] task ${task.taskId}: boot recovery stayed closed: ${
38
+ error instanceof Error ? error.message : String(error)
39
+ }`,
40
+ );
41
+ }
42
+ }
43
+ return { recovered, failed };
44
+ }