@runuai/host 0.9.149 → 0.9.151

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,51 @@
1
+ -- ADR-154 runner persistence foundation. A run epoch is non-secret
2
+ -- assignment identity; cleanup rows contain paths and ids only, never values.
3
+ ALTER TABLE `uai_host_tasks` ADD `run_epoch` integer NOT NULL DEFAULT 0
4
+ CONSTRAINT `uai_host_tasks_run_epoch` CHECK (`run_epoch` >= 0);
5
+ --> statement-breakpoint
6
+ CREATE TABLE `host_task_run_assignments` (
7
+ `task_id` text PRIMARY KEY NOT NULL,
8
+ `highest_run_epoch` integer NOT NULL,
9
+ `assignment_open` integer NOT NULL DEFAULT 1,
10
+ `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
11
+ `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
12
+ CONSTRAINT `host_task_run_assignments_epoch` CHECK (`highest_run_epoch` >= 1),
13
+ CONSTRAINT `host_task_run_assignments_open` CHECK (`assignment_open` IN (0, 1)),
14
+ CONSTRAINT `host_task_run_assignments_task_id` CHECK (length(`task_id`) > 0)
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE TABLE `host_credential_cleanup_paths` (
18
+ `task_id` text NOT NULL,
19
+ `run_epoch` integer NOT NULL,
20
+ `runtime_locator` text NOT NULL,
21
+ `lane` text NOT NULL,
22
+ `path` text NOT NULL,
23
+ `credential_id` text NOT NULL,
24
+ `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
25
+ `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
26
+ PRIMARY KEY(`task_id`, `run_epoch`, `runtime_locator`, `path`),
27
+ CONSTRAINT `host_credential_cleanup_paths_run_epoch` CHECK (`run_epoch` >= 1),
28
+ CONSTRAINT `host_credential_cleanup_paths_identity` CHECK (
29
+ length(`task_id`) > 0 AND length(`runtime_locator`) > 0 AND length(`credential_id`) > 0
30
+ ),
31
+ CONSTRAINT `host_credential_cleanup_paths_lane` CHECK (
32
+ `lane` IN ('engine-auth', 'github', 'ssh', 'files-session')
33
+ ),
34
+ CONSTRAINT `host_credential_cleanup_paths_absolute_path` CHECK (substr(`path`, 1, 1) = '/')
35
+ );
36
+ --> statement-breakpoint
37
+ CREATE INDEX `host_credential_cleanup_paths_task_credential_idx`
38
+ ON `host_credential_cleanup_paths` (`task_id`, `run_epoch`, `credential_id`);
39
+ --> statement-breakpoint
40
+ CREATE TABLE `host_credential_cleanup_pending` (
41
+ `task_id` text PRIMARY KEY NOT NULL,
42
+ `run_epoch` integer NOT NULL,
43
+ `reason` text NOT NULL,
44
+ `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
45
+ `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
46
+ CONSTRAINT `host_credential_cleanup_pending_run_epoch` CHECK (`run_epoch` >= 1),
47
+ CONSTRAINT `host_credential_cleanup_pending_reason` CHECK (
48
+ `reason` IN ('removed', 'denied', 'expired', 'assignment', 'restart', 'stop', 'park', 'teardown')
49
+ ),
50
+ CONSTRAINT `host_credential_cleanup_pending_task_id` CHECK (length(`task_id`) > 0)
51
+ );
@@ -0,0 +1,11 @@
1
+ CREATE TABLE `host_leased_engine_runtimes` (
2
+ `task_id` text NOT NULL,
3
+ `run_epoch` integer NOT NULL,
4
+ `runtime_locator` text NOT NULL,
5
+ `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
6
+ PRIMARY KEY(`task_id`, `run_epoch`, `runtime_locator`),
7
+ CONSTRAINT `host_leased_engine_runtimes_run_epoch` CHECK (`run_epoch` >= 1),
8
+ CONSTRAINT `host_leased_engine_runtimes_identity` CHECK (
9
+ length(`task_id`) > 0 AND length(`runtime_locator`) > 0
10
+ )
11
+ );
@@ -190,6 +190,20 @@
190
190
  "when": 1788900000008,
191
191
  "tag": "0026_project_env_replica",
192
192
  "breakpoints": true
193
+ },
194
+ {
195
+ "idx": 27,
196
+ "version": "6",
197
+ "when": 1791331200000,
198
+ "tag": "0027_cloud_credential_foundation",
199
+ "breakpoints": true
200
+ },
201
+ {
202
+ "idx": 28,
203
+ "version": "6",
204
+ "when": 1791331200001,
205
+ "tag": "0028_leased_engine_runtimes",
206
+ "breakpoints": true
193
207
  }
194
208
  ]
195
209
  }
package/db/schema.ts CHANGED
@@ -21,6 +21,11 @@ export const hostTasks = sqliteTable(
21
21
  "uai_host_tasks",
22
22
  {
23
23
  taskId: text("task_id").primaryKey(),
24
+ // ADR-154: the v1 epoch whose runtime was successfully established.
25
+ // Admission's monotonic floor lives separately so deleting this recovery
26
+ // row can never make an older dispatch current again. 0 means no v1
27
+ // runtime has been proven for this row (including every legacy row).
28
+ runEpoch: integer("run_epoch").notNull().default(0),
24
29
  codeServerPort: integer("code_server_port"),
25
30
  previewPorts: text("preview_ports").notNull().default("[]"),
26
31
  composeProject: text("compose_project"),
@@ -57,6 +62,10 @@ export const hostTasks = sqliteTable(
57
62
  .default(sql`(unixepoch() * 1000)`),
58
63
  },
59
64
  (table) => ({
65
+ runEpochCheck: check(
66
+ "uai_host_tasks_run_epoch",
67
+ sql`${table.runEpoch} >= 0`,
68
+ ),
60
69
  environmentPair: check(
61
70
  "uai_host_tasks_environment_pair",
62
71
  sql`(${table.environmentProvider} IS NULL) = (${table.environmentLocator} IS NULL)`,
@@ -67,6 +76,185 @@ export const hostTasks = sqliteTable(
67
76
  export type HostTask = typeof hostTasks.$inferSelect;
68
77
  export type NewHostTask = typeof hostTasks.$inferInsert;
69
78
 
79
+ // ADR-154's non-secret assignment authority. It intentionally has no foreign
80
+ // key to uai_host_tasks: terminal-history retention and orphan GC may remove a
81
+ // runtime row, but neither is allowed to erase the highest epoch this runner
82
+ // has admitted or reopen a closed assignment.
83
+ export const hostTaskRunAssignments = sqliteTable(
84
+ "host_task_run_assignments",
85
+ {
86
+ taskId: text("task_id").primaryKey(),
87
+ highestRunEpoch: integer("highest_run_epoch").notNull(),
88
+ assignmentOpen: integer("assignment_open", { mode: "boolean" })
89
+ .notNull()
90
+ .default(true),
91
+ createdAt: integer("created_at", { mode: "number" })
92
+ .notNull()
93
+ .default(sql`(unixepoch() * 1000)`),
94
+ updatedAt: integer("updated_at", { mode: "number" })
95
+ .notNull()
96
+ .default(sql`(unixepoch() * 1000)`),
97
+ },
98
+ (table) => ({
99
+ runEpochCheck: check(
100
+ "host_task_run_assignments_epoch",
101
+ sql`${table.highestRunEpoch} >= 1`,
102
+ ),
103
+ assignmentOpenCheck: check(
104
+ "host_task_run_assignments_open",
105
+ sql`${table.assignmentOpen} IN (0, 1)`,
106
+ ),
107
+ taskIdCheck: check(
108
+ "host_task_run_assignments_task_id",
109
+ sql`length(${table.taskId}) > 0`,
110
+ ),
111
+ }),
112
+ );
113
+
114
+ export type HostTaskRunAssignment =
115
+ typeof hostTaskRunAssignments.$inferSelect;
116
+ export type NewHostTaskRunAssignment =
117
+ typeof hostTaskRunAssignments.$inferInsert;
118
+
119
+ // ADR-154: values never land here. This is only the durable witness needed
120
+ // to remove a task-local credential file after a runner crash. The exact
121
+ // runtime locator is part of the key because a later run may recreate the
122
+ // same task id on a different container or VM.
123
+ export const hostCredentialCleanupPaths = sqliteTable(
124
+ "host_credential_cleanup_paths",
125
+ {
126
+ taskId: text("task_id").notNull(),
127
+ runEpoch: integer("run_epoch").notNull(),
128
+ runtimeLocator: text("runtime_locator").notNull(),
129
+ lane: text("lane", {
130
+ enum: ["engine-auth", "github", "ssh", "files-session"],
131
+ }).notNull(),
132
+ path: text("path").notNull(),
133
+ credentialId: text("credential_id").notNull(),
134
+ createdAt: integer("created_at", { mode: "number" })
135
+ .notNull()
136
+ .default(sql`(unixepoch() * 1000)`),
137
+ updatedAt: integer("updated_at", { mode: "number" })
138
+ .notNull()
139
+ .default(sql`(unixepoch() * 1000)`),
140
+ },
141
+ (table) => ({
142
+ pk: primaryKey({
143
+ columns: [
144
+ table.taskId,
145
+ table.runEpoch,
146
+ table.runtimeLocator,
147
+ table.path,
148
+ ],
149
+ }),
150
+ taskCredentialIdx: index(
151
+ "host_credential_cleanup_paths_task_credential_idx",
152
+ ).on(table.taskId, table.runEpoch, table.credentialId),
153
+ runEpochCheck: check(
154
+ "host_credential_cleanup_paths_run_epoch",
155
+ sql`${table.runEpoch} >= 1`,
156
+ ),
157
+ identityCheck: check(
158
+ "host_credential_cleanup_paths_identity",
159
+ sql`length(${table.taskId}) > 0 AND length(${table.runtimeLocator}) > 0 AND length(${table.credentialId}) > 0`,
160
+ ),
161
+ laneCheck: check(
162
+ "host_credential_cleanup_paths_lane",
163
+ sql`${table.lane} IN ('engine-auth', 'github', 'ssh', 'files-session')`,
164
+ ),
165
+ absolutePathCheck: check(
166
+ "host_credential_cleanup_paths_absolute_path",
167
+ sql`substr(${table.path}, 1, 1) = '/'`,
168
+ ),
169
+ }),
170
+ );
171
+
172
+ export type HostCredentialCleanupPath =
173
+ typeof hostCredentialCleanupPaths.$inferSelect;
174
+ export type NewHostCredentialCleanupPath =
175
+ typeof hostCredentialCleanupPaths.$inferInsert;
176
+
177
+ // A task remains fail-closed across a host crash even when cleanup has not yet
178
+ // reached (or has already deleted) an inventoried path. This row contains no
179
+ // credential value; it is cleared only after the entire cleanup procedure has
180
+ // completed successfully through the recorded epoch.
181
+ export const hostCredentialCleanupPending = sqliteTable(
182
+ "host_credential_cleanup_pending",
183
+ {
184
+ taskId: text("task_id").primaryKey(),
185
+ runEpoch: integer("run_epoch").notNull(),
186
+ reason: text("reason", {
187
+ enum: [
188
+ "removed",
189
+ "denied",
190
+ "expired",
191
+ "assignment",
192
+ "restart",
193
+ "stop",
194
+ "park",
195
+ "teardown",
196
+ ],
197
+ }).notNull(),
198
+ createdAt: integer("created_at", { mode: "number" })
199
+ .notNull()
200
+ .default(sql`(unixepoch() * 1000)`),
201
+ updatedAt: integer("updated_at", { mode: "number" })
202
+ .notNull()
203
+ .default(sql`(unixepoch() * 1000)`),
204
+ },
205
+ (table) => ({
206
+ runEpochCheck: check(
207
+ "host_credential_cleanup_pending_run_epoch",
208
+ sql`${table.runEpoch} >= 1`,
209
+ ),
210
+ reasonCheck: check(
211
+ "host_credential_cleanup_pending_reason",
212
+ sql`${table.reason} IN ('removed', 'denied', 'expired', 'assignment', 'restart', 'stop', 'park', 'teardown')`,
213
+ ),
214
+ taskIdCheck: check(
215
+ "host_credential_cleanup_pending_task_id",
216
+ sql`length(${table.taskId}) > 0`,
217
+ ),
218
+ }),
219
+ );
220
+
221
+ export type HostCredentialCleanupPending =
222
+ typeof hostCredentialCleanupPending.$inferSelect;
223
+ export type NewHostCredentialCleanupPending =
224
+ typeof hostCredentialCleanupPending.$inferInsert;
225
+
226
+ // ADR-154 §6: every runtime a leased engine of a task's run was started in,
227
+ // recorded before it starts. An env-token engine leaves no credential file
228
+ // for the cleanup inventory to name, yet the cleanup must prove it stopped
229
+ // wherever it ran, an older runtime included. Value-free.
230
+ export const hostLeasedEngineRuntimes = sqliteTable(
231
+ "host_leased_engine_runtimes",
232
+ {
233
+ taskId: text("task_id").notNull(),
234
+ runEpoch: integer("run_epoch").notNull(),
235
+ runtimeLocator: text("runtime_locator").notNull(),
236
+ createdAt: integer("created_at", { mode: "number" })
237
+ .notNull()
238
+ .default(sql`(unixepoch() * 1000)`),
239
+ },
240
+ (table) => ({
241
+ pk: primaryKey({
242
+ columns: [table.taskId, table.runEpoch, table.runtimeLocator],
243
+ }),
244
+ runEpochCheck: check(
245
+ "host_leased_engine_runtimes_run_epoch",
246
+ sql`${table.runEpoch} >= 1`,
247
+ ),
248
+ identityCheck: check(
249
+ "host_leased_engine_runtimes_identity",
250
+ sql`length(${table.taskId}) > 0 AND length(${table.runtimeLocator}) > 0`,
251
+ ),
252
+ }),
253
+ );
254
+
255
+ export type HostLeasedEngineRuntime =
256
+ typeof hostLeasedEngineRuntimes.$inferSelect;
257
+
70
258
  // Singleton host-local settings row (ADR-100). Runtime switches that must be
71
259
  // visible before SQLite opens remain in `.env.local`; retention policy belongs
72
260
  // here because it governs owner-facing local history only. Correctness
package/lib/agent-cli.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  * (surfaced in the system preamble).
13
13
  */
14
14
  import {
15
- chmodSync,
16
15
  lstatSync,
17
16
  mkdirSync,
18
17
  readFileSync,
@@ -30,6 +29,7 @@ import { signTaskToken } from "./task-token";
30
29
  import { assertSafeHostTaskId } from "./task-identity";
31
30
  import type { RosterAgent } from "./agents/types";
32
31
  import type { TaskEnvironmentAgentSessionSurface } from "./task-environment/types";
32
+ import { makeContainedDirectoriesSync, writeContainedFile } from "./task-environment/workspace-files";
33
33
 
34
34
  /** Container path of the CLI — what the preamble tells agents to run. */
35
35
  export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
@@ -118,12 +118,12 @@ export function apiUrlFromCloudUrl(cloudUrl: string | undefined): string | null
118
118
  * dispatch). The latter is explicit rather than globally minting empty-power
119
119
  * tokens for every task.
120
120
  */
121
- export function writeAgentCli(
121
+ export async function writeAgentCli(
122
122
  taskId: string,
123
123
  roster: RosterAgent[],
124
124
  apiUrl: string | null,
125
125
  allowPermissionless = false,
126
- ): boolean {
126
+ ): Promise<boolean> {
127
127
  if (
128
128
  !apiUrl ||
129
129
  (rosterPermissions(roster).length === 0 && !allowPermissionless)
@@ -131,16 +131,14 @@ export function writeAgentCli(
131
131
  return false;
132
132
  }
133
133
  try {
134
- const dir = resolve(taskWorkspaceDir(taskId), ".uai");
135
- mkdirSync(dir, { recursive: true });
136
- writeFileSync(resolve(dir, "uai.json"), `${JSON.stringify({ apiUrl }, null, 2)}\n`);
137
- const cliPath = resolve(dir, "cli.mjs");
138
- writeFileSync(cliPath, CLI_SOURCE);
139
- try {
140
- chmodSync(cliPath, 0o755);
141
- } catch {
142
- // best-effort
143
- }
134
+ // Into the task's own .uai, never through a link the task put there
135
+ // (#cb3j0z). The workspace root is the runner's own path; .uai, directly
136
+ // below it, is made if gone without following a link.
137
+ const workspace = taskWorkspaceDir(taskId);
138
+ mkdirSync(workspace, { recursive: true });
139
+ makeContainedDirectoriesSync(workspace, [".uai"]);
140
+ await writeContainedFile(workspace, [".uai", "uai.json"], Buffer.from(`${JSON.stringify({ apiUrl }, null, 2)}\n`));
141
+ await writeContainedFile(workspace, [".uai", "cli.mjs"], Buffer.from(CLI_SOURCE), { mode: 0o755 });
144
142
  return true;
145
143
  } catch {
146
144
  return false;
package/lib/agent.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawn } from "node:child_process";
19
+ import { chmodSync, existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
20
  import { dirname, join, resolve } from "node:path";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { z } from "zod";
@@ -32,18 +33,63 @@ import { containerRuntimeStandardAssetsRoot } from "./container-runtime";
32
33
  import { getDecryptedForProject } from "./host-env";
33
34
  import { PreviewPortRuntimesSchema } from "./preview-ports";
34
35
  import { getHostTask } from "./runtime-state";
36
+ import { ENGINE_CREDENTIAL_ENV_NAMES } from "./engine-credential-env";
35
37
  import { activeSecretsProxy } from "./secrets-proxy/host";
36
38
  import { closeTaskUpTrustSteps, finishTaskUpTrustSteps, writeTaskUpTrustSteps } from "./secrets-proxy/task-trust";
37
39
  import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
40
+ import type { TaskGitCredentialHold } from "./credential-consumers/github";
41
+ import type { TaskWriteEnvironment } from "./credential-consumers/task-files";
42
+ import { serveCloneCredentials, type CloneCredentialService } from "./git-clone-credentials";
38
43
  import { taskGatewayHost } from "./mcp-gateway";
39
44
  import type { ProjectEnvMode } from "./task-environment/types";
40
45
  import { isProjectEnvSchemaObservation, type ProjectEnvSchemaObservation, type TaskDownInput, type TaskLaunchInput } from "../src/protocol";
41
46
 
42
47
  export type { TaskLaunchInput } from "../src/protocol";
43
48
 
49
+ /**
50
+ * ADR-154 §12: a start on cloud credentials. Only ever whole: the hold and
51
+ * the runtime-ready writes come together, so no such start reaches its
52
+ * runtime without what the runner writes into it.
53
+ */
54
+ export interface LeasedTaskUp {
55
+ /**
56
+ * The owner's GitHub sign-in and SSH key, held for this start
57
+ * (`holdTaskGitCredentials`). The host-side clone reaches them only
58
+ * through this process's own sockets, for as long as task-up runs; a hold
59
+ * taken back aborts task-up. No key file is written and the runner's own
60
+ * store is never read.
61
+ */
62
+ clone: TaskGitCredentialHold;
63
+ /**
64
+ * The task's runtime exists and nothing in it has run yet (task-up pauses
65
+ * before uai-init, a machine before its clones): write what the runner
66
+ * writes into it, its owner's GitHub configuration and SSH key recorded
67
+ * for cleanup, before anything that may need them runs. A rejection stops
68
+ * the start.
69
+ */
70
+ runtimeReady: (environment: TaskWriteEnvironment, signal: AbortSignal) => Promise<void>;
71
+ }
72
+
73
+ /** Refuses a start on cloud credentials that is not whole, or not this
74
+ * start's: before anything is provisioned, cloned or written. */
75
+ export function assertLeasedTaskUp(
76
+ leased: LeasedTaskUp | undefined,
77
+ task: { id: string; runEpoch?: number },
78
+ ): void {
79
+ if (leased === undefined) return;
80
+ if (!leased.clone || typeof leased.runtimeReady !== "function") {
81
+ throw new Error("a start on cloud credentials needs its hold and its runtime-ready writes together");
82
+ }
83
+ if (leased.clone.assignment.taskId !== task.id || leased.clone.assignment.runEpoch !== task.runEpoch) {
84
+ throw new Error("the credentials held for this start are another task's or another run's");
85
+ }
86
+ }
87
+
44
88
  export interface TaskUpCredentials {
45
89
  /** Path to the task owner's private, ephemeral Git credential cache. */
46
90
  githubCredentialSocket?: string;
91
+ /** ADR-154 §12: a task on cloud credentials. */
92
+ leased?: LeasedTaskUp;
47
93
  /** Fresh ADR-153 generation credential. The raw bytes are forwarded only
48
94
  * over stdin and are never serialized into a task row, argv, or env. */
49
95
  taskGateway?: {
@@ -156,6 +202,15 @@ export class AgentError extends Error {
156
202
  // Internal: run an agent script and parse the JSON envelope.
157
203
  // ---------------------------------------------------------------------------
158
204
 
205
+ /** How long a withdrawn script has to end on SIGTERM before SIGKILL. */
206
+ const WITHDRAWN_KILL_GRACE_MS = 5_000;
207
+
208
+ /** What a start on cloud credentials says when they were withdrawn under it. */
209
+ export const CREDENTIALS_WITHDRAWN_MESSAGE =
210
+ "The task's credentials were withdrawn while it was starting: removed, refused or replaced in the cloud. Start the task again.";
211
+ /** How often task-up's handshake directory is looked at for its events. */
212
+ const HANDSHAKE_POLL_MS = 100;
213
+
159
214
  async function runAgent<T extends z.ZodTypeAny>(
160
215
  scriptName: string,
161
216
  args: string[],
@@ -163,13 +218,25 @@ async function runAgent<T extends z.ZodTypeAny>(
163
218
  commandDbPath: string,
164
219
  extraEnv: Record<string, string> = {},
165
220
  stdinPayload?: string,
221
+ /** Aborted when what the script runs on is withdrawn (ADR-154): the
222
+ * script and everything it started, its process group, end. */
223
+ withdrawn?: AbortSignal,
224
+ /** Names the script never inherits from the runner's own environment
225
+ * (ADR-154: a start on cloud credentials gets none of the runner's). */
226
+ withoutRunnerEnv: ReadonlySet<string> = new Set(),
166
227
  ): Promise<z.infer<T>> {
167
228
  const scriptPath = resolve(agentDir(), scriptName);
229
+ const runnerEnv = Object.fromEntries(
230
+ Object.entries(process.env).filter(([name]) => !withoutRunnerEnv.has(name)),
231
+ );
168
232
 
169
233
  const child = spawn(scriptPath, args, {
234
+ // Its own process group when it may be withdrawn, so ending it ends
235
+ // every git and container CLI it started.
236
+ detached: withdrawn !== undefined,
170
237
  stdio: [stdinPayload === undefined ? "ignore" : "pipe", "pipe", "pipe"],
171
238
  env: {
172
- ...process.env,
239
+ ...runnerEnv,
173
240
  UAI_DB_PATH: commandDbPath,
174
241
  UAI_DATA_DIR: env.dataDir,
175
242
  UAI_STANDARD_CONTAINER_ASSETS_DIR:
@@ -195,10 +262,35 @@ async function runAgent<T extends z.ZodTypeAny>(
195
262
  child.stdin.end(stdinPayload);
196
263
  }
197
264
 
198
- const exitCode: number = await new Promise((res, rej) => {
199
- child.on("error", rej);
200
- child.on("close", (code) => res(code ?? 0));
201
- });
265
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
266
+ const endGroup = (signal: NodeJS.Signals): void => {
267
+ try {
268
+ if (child.pid !== undefined) process.kill(-child.pid, signal);
269
+ } catch {
270
+ // Already gone.
271
+ }
272
+ };
273
+ const onWithdrawn = (): void => {
274
+ endGroup("SIGTERM");
275
+ killTimer = setTimeout(() => endGroup("SIGKILL"), WITHDRAWN_KILL_GRACE_MS);
276
+ };
277
+ if (withdrawn?.aborted) onWithdrawn();
278
+ else withdrawn?.addEventListener("abort", onWithdrawn, { once: true });
279
+
280
+ let exitCode: number;
281
+ try {
282
+ exitCode = await new Promise((res, rej) => {
283
+ child.on("error", rej);
284
+ child.on("close", (code) => res(code ?? 0));
285
+ });
286
+ } finally {
287
+ withdrawn?.removeEventListener("abort", onWithdrawn);
288
+ clearTimeout(killTimer);
289
+ }
290
+ if (withdrawn?.aborted) {
291
+ endGroup("SIGKILL");
292
+ throw new AgentError("CREDENTIALS_WITHDRAWN", CREDENTIALS_WITHDRAWN_MESSAGE, { exitCode });
293
+ }
202
294
 
203
295
  // Try to parse whichever stream has the JSON. On failure the agent puts
204
296
  // its envelope on stderr; on success, on stdout.
@@ -321,11 +413,74 @@ export const agent = {
321
413
  input: TaskLaunchInput,
322
414
  credentials: TaskUpCredentials = {},
323
415
  envMode: ProjectEnvMode = "baked",
416
+ /** The task's runtime as the provider reaches it once task-up has
417
+ * created it: where `credentials.leased.runtimeReady` writes. */
418
+ runtimeEnvironment?: () => TaskWriteEnvironment,
324
419
  ): Promise<TaskUpResult> {
420
+ const leased = credentials.leased;
421
+ if (leased && credentials.githubCredentialSocket) {
422
+ throw new Error("a task on cloud credentials never uses the runner's own GitHub credential");
423
+ }
424
+ assertLeasedTaskUp(leased, input.task);
425
+ if (leased && !runtimeEnvironment) {
426
+ throw new Error("this runtime cannot pause task-up for its runtime-ready writes");
427
+ }
325
428
  const commandDbPath = createTaskUpCommandDb(input);
326
429
  // Materialize the creator's per-user SSH key for commit signing and the
327
- // explicit no-GitHub-connection transport fallback (ADR-029).
328
- const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
430
+ // explicit no-GitHub-connection transport fallback (ADR-029). A task on
431
+ // cloud credentials gets no key file: the runner writes the task's key
432
+ // itself, and serves the clone's from memory.
433
+ const identityDir = leased ? null : writeTaskIdentity(input.task.id, input.task.ownerUserId);
434
+ let cloneService: CloneCredentialService | null = null;
435
+ // ADR-154 §12: task-up says where it is with event files in a private
436
+ // directory (event-clone-done, then event-runtime-ready), never on the
437
+ // stderr its subprocesses share, and finds the runtime-ready answer
438
+ // there. Each event is taken once, in order. The runtime-ready writes
439
+ // are one operation, abortable by the hold, joined before task-up's
440
+ // outcome is returned whatever it is.
441
+ const handshakeDir = leased ? mkdtempSync(join("/tmp", "uai-task-handshake-")) : null;
442
+ if (handshakeDir) chmodSync(handshakeDir, 0o700);
443
+ let cloneDone = false;
444
+ let runtimeReadyWrites: Promise<void> | null = null;
445
+ const answer = (value: "continue" | "abort"): void => {
446
+ try {
447
+ const staged = join(handshakeDir!, ".runtime-ready");
448
+ writeFileSync(staged, `${value}\n`, { mode: 0o600 });
449
+ renameSync(staged, join(handshakeDir!, "runtime-ready"));
450
+ } catch {
451
+ // task-up already ended: nothing waits for the answer.
452
+ }
453
+ };
454
+ const takeEvents = (): void => {
455
+ if (!handshakeDir) return;
456
+ if (!cloneDone && existsSync(join(handshakeDir, "event-clone-done"))) {
457
+ cloneDone = true;
458
+ // The host's clones and fetches are over: stop serving them.
459
+ void cloneService?.close();
460
+ }
461
+ if (runtimeReadyWrites || !existsSync(join(handshakeDir, "event-runtime-ready"))) return;
462
+ if (!cloneDone) {
463
+ // Out of order: not task-up's.
464
+ runtimeReadyWrites = Promise.resolve();
465
+ answer("abort");
466
+ return;
467
+ }
468
+ runtimeReadyWrites = (async () => {
469
+ let value: "continue" | "abort" = "abort";
470
+ try {
471
+ const signal = leased!.clone.signal;
472
+ if (signal.aborted) throw new Error("the task's credentials were withdrawn");
473
+ await leased!.runtimeReady(runtimeEnvironment!(), signal);
474
+ value = "continue";
475
+ } catch (error) {
476
+ console.warn(
477
+ `[uai-agent] task ${input.task.id}: runtime-ready writes failed: ${error instanceof Error ? error.message : String(error)}`,
478
+ );
479
+ }
480
+ answer(value);
481
+ })();
482
+ };
483
+ const poller = handshakeDir ? setInterval(takeEvents, HANDSHAKE_POLL_MS) : null;
329
484
  try {
330
485
  // Per-(project, key) env values must NEVER enter task-up's host process
331
486
  // environment. Besides ordinary app names, projects can declare names such
@@ -348,6 +503,7 @@ export const agent = {
348
503
  const extraEnv: Record<string, string> = {
349
504
  UAI_PROJECT_ENV_MODE: envMode,
350
505
  };
506
+ if (handshakeDir) extraEnv.UAI_TASK_HANDSHAKE_DIR = handshakeDir;
351
507
  if (identityDir) extraEnv.UAI_TASK_IDENTITY_DIR = identityDir;
352
508
  // Only a non-secret socket path crosses into Bash. The token itself was
353
509
  // seeded over stdin and never enters argv, env, project data, Compose, or
@@ -356,6 +512,10 @@ export const agent = {
356
512
  extraEnv.UAI_GITHUB_CREDENTIAL_SOCKET =
357
513
  credentials.githubCredentialSocket;
358
514
  }
515
+ if (leased) {
516
+ cloneService = await serveCloneCredentials(leased.clone);
517
+ Object.assign(extraEnv, cloneService.env);
518
+ }
359
519
  let stdinPayload: string;
360
520
  if (envMode === "loader") {
361
521
  const gateway = credentials.taskGateway;
@@ -396,6 +556,10 @@ export const agent = {
396
556
  commandDbPath,
397
557
  extraEnv,
398
558
  stdinPayload,
559
+ leased?.clone.signal,
560
+ // ADR-154 §6: no default slot. A start on cloud credentials gets
561
+ // the runner's own engine sign-ins in neither its env nor files.
562
+ leased ? ENGINE_CREDENTIAL_ENV_NAMES : undefined,
399
563
  );
400
564
  } catch (error) {
401
565
  finishTaskUpTrustSteps(env.dataDir, input.task.id, null);
@@ -407,6 +571,12 @@ export const agent = {
407
571
  if (result.secretsProxyTrust === "failed") activeSecretsProxy()?.runtime.requestTrustDelivery();
408
572
  return result;
409
573
  } finally {
574
+ if (poller) clearInterval(poller);
575
+ // Joined, whatever task-up's outcome: nothing it began still writes
576
+ // into the task once this returns.
577
+ await runtimeReadyWrites;
578
+ await cloneService?.close();
579
+ if (handshakeDir) rmSync(handshakeDir, { recursive: true, force: true });
410
580
  closeTaskUpTrustSteps(env.dataDir, input.task.id);
411
581
  removeCommandDb(commandDbPath);
412
582
  removeTaskIdentity(input.task.id);