@runuai/host 0.9.150 → 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.
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- 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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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);
|