@runuai/host 0.9.150 → 0.9.152
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 +1880 -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 +1837 -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 +650 -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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A one-off container on the Apple runtime (ADR-106), and nothing of it left
|
|
3
|
+
* behind (#p2jdax).
|
|
4
|
+
*
|
|
5
|
+
* `container run --rm` removes only a container that started. A run the
|
|
6
|
+
* runtime refuses leaves a created-never-started container, and those wedge
|
|
7
|
+
* the runtime's apiserver: `run` and `delete` hang while `list` and
|
|
8
|
+
* `inspect` still answer, until the runtime's services are recycled, which
|
|
9
|
+
* restarts every Apple task (apple-runtime-recycle.ts). A client killed at
|
|
10
|
+
* its bound (no answer in time, output past its bound) can leave one too, or
|
|
11
|
+
* a container that goes on running what it was abandoned for. So every
|
|
12
|
+
* one-off container is named in the closed grammar the recycle's sweep
|
|
13
|
+
* deletes, with a random part no other run's name has, labelled as Uai's,
|
|
14
|
+
* and labelled with a token only its own run carries; and its run is
|
|
15
|
+
* settled after it ends:
|
|
16
|
+
*
|
|
17
|
+
* - absent (the runtime says it is not found): done;
|
|
18
|
+
* - its own (Uai's label and its run's token, on exactly that name):
|
|
19
|
+
* deleted, then proven gone; deleted again if it is not; still there, it
|
|
20
|
+
* is what wedges the runtime, so the runtime's remedy is asked for, and it
|
|
21
|
+
* is proven gone after. Only the runtime's own activation restarts its
|
|
22
|
+
* services (it recovers every task after); all other work, and every
|
|
23
|
+
* watch, asks the runtime's owner for that activation instead
|
|
24
|
+
* (askRuntimeOwnerOnce);
|
|
25
|
+
* - of that name but not its own: never touched (a name alone is no proof);
|
|
26
|
+
* - unobservable (the runtime does not answer): nothing is deleted or
|
|
27
|
+
* recycled on a guess.
|
|
28
|
+
*
|
|
29
|
+
* Whatever is left unproven is watched a while longer, and so is the name of
|
|
30
|
+
* a run whose client never reported its end (its container can still be
|
|
31
|
+
* created after): each look that finds it its own settles it again, and the
|
|
32
|
+
* watch ends only once it is proven gone or removed, or its time is up.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
import { MAINTENANCE_CONTAINER_LABEL, type AppleRecycleOutcome } from "./apple-runtime-recycle";
|
|
38
|
+
|
|
39
|
+
/** The label only one run's container carries: its token. */
|
|
40
|
+
export const MAINTENANCE_TOKEN_LABEL = "com.uai.maintenance.token";
|
|
41
|
+
|
|
42
|
+
/** One run of the Apple `container` CLI. */
|
|
43
|
+
export interface AppleCliResult {
|
|
44
|
+
code: number | null;
|
|
45
|
+
stdout: string;
|
|
46
|
+
stderr: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type AppleCli = (args: string[], timeoutMs: number) => Promise<AppleCliResult>;
|
|
50
|
+
|
|
51
|
+
export interface AppleOneOffDeps {
|
|
52
|
+
cli: AppleCli;
|
|
53
|
+
/** The runtime's remedy for a container of a run's own that will not go
|
|
54
|
+
* (see the module comment). */
|
|
55
|
+
recycle: () => Promise<AppleRecycleOutcome>;
|
|
56
|
+
/** Tests only: how the watch waits. */
|
|
57
|
+
delay?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One one-off container: its name, and the token only its run carries. */
|
|
61
|
+
export interface AppleOneOff {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly token: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What a one-off container's name may name, within the recycle sweep's
|
|
67
|
+
* grammar (MAINTENANCE_CONTAINER_NAME_RE). */
|
|
68
|
+
const PURPOSE = /^[a-z]+(?:-[a-z]+)*$/;
|
|
69
|
+
const INSPECT_TIMEOUT_MS = 15_000;
|
|
70
|
+
const DELETE_TIMEOUT_MS = 30_000;
|
|
71
|
+
const DELETE_ATTEMPTS = 2;
|
|
72
|
+
/** A watched name is looked for again after each of these waits. */
|
|
73
|
+
const WATCH_MS: readonly number[] = [5_000, 15_000, 60_000, 180_000, 600_000];
|
|
74
|
+
|
|
75
|
+
let sequence = 0;
|
|
76
|
+
|
|
77
|
+
/** A new one-off container: a name no other run has had, and its token. */
|
|
78
|
+
export function newAppleOneOff(purpose: string): AppleOneOff {
|
|
79
|
+
if (!PURPOSE.test(purpose)) throw new Error(`a malformed one-off container purpose: ${purpose}`);
|
|
80
|
+
sequence += 1;
|
|
81
|
+
return {
|
|
82
|
+
name: `uai-maint-${purpose}-${process.pid}-${sequence}-${randomBytes(8).toString("hex")}`,
|
|
83
|
+
token: randomBytes(16).toString("hex"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The `container run` arguments that name a one-off container, mark it
|
|
88
|
+
* Uai's (what the recycle's sweep goes by) and give it its run's token
|
|
89
|
+
* (what alone authorizes deleting it here). */
|
|
90
|
+
export function appleOneOffRunArgs(oneOff: AppleOneOff): string[] {
|
|
91
|
+
return [
|
|
92
|
+
"--name",
|
|
93
|
+
oneOff.name,
|
|
94
|
+
"-l",
|
|
95
|
+
`${MAINTENANCE_CONTAINER_LABEL}=1`,
|
|
96
|
+
"-l",
|
|
97
|
+
`${MAINTENANCE_TOKEN_LABEL}=${oneOff.token}`,
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What one inspection of a one-off container's name finds. */
|
|
102
|
+
export type AppleOneOffPresence = "ours" | "absent" | "foreign" | "unknown";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One observation of `oneOff`'s name: absent only on the runtime's own "not
|
|
106
|
+
* found"; its own only with Uai's label and its run's token on exactly that
|
|
107
|
+
* container; of that name without them, foreign; anything else (no answer,
|
|
108
|
+
* an answer that does not parse), unknown.
|
|
109
|
+
*/
|
|
110
|
+
export async function appleOneOffPresence(cli: AppleCli, oneOff: AppleOneOff): Promise<AppleOneOffPresence> {
|
|
111
|
+
const { name } = oneOff;
|
|
112
|
+
const inspected = await cli(["inspect", name], INSPECT_TIMEOUT_MS);
|
|
113
|
+
if (inspected.code === 0) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(inspected.stdout) as Array<{
|
|
116
|
+
id?: unknown;
|
|
117
|
+
configuration?: { labels?: Record<string, unknown> };
|
|
118
|
+
}>;
|
|
119
|
+
if (!Array.isArray(parsed) || parsed.length !== 1 || parsed[0]?.id !== name) return "unknown";
|
|
120
|
+
const labels = parsed[0]?.configuration?.labels;
|
|
121
|
+
return labels?.[MAINTENANCE_CONTAINER_LABEL] === "1" && labels?.[MAINTENANCE_TOKEN_LABEL] === oneOff.token
|
|
122
|
+
? "ours"
|
|
123
|
+
: "foreign";
|
|
124
|
+
} catch {
|
|
125
|
+
return "unknown";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
inspected.code !== null &&
|
|
130
|
+
inspected.stdout.trim() === "" &&
|
|
131
|
+
inspected.stderr.split("\n").some((line) => line.trim() === `Error: container not found: ${name}`)
|
|
132
|
+
) {
|
|
133
|
+
return "absent";
|
|
134
|
+
}
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** How a one-off container's run was settled. */
|
|
139
|
+
export type AppleOneOffSettlement =
|
|
140
|
+
/** Nothing of that name is there. */
|
|
141
|
+
| "absent"
|
|
142
|
+
/** Its own was there: deleted, and proven gone. */
|
|
143
|
+
| "removed"
|
|
144
|
+
/** Its own would not go: the runtime's remedy took it, and it is proven
|
|
145
|
+
* gone. */
|
|
146
|
+
| "recycled"
|
|
147
|
+
/** Of that name but not its own: left untouched. */
|
|
148
|
+
| "foreign"
|
|
149
|
+
/** Neither proven gone nor removed. */
|
|
150
|
+
| "unproven";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Settle `oneOff`'s container once (see the module comment). Never
|
|
154
|
+
* rejects.
|
|
155
|
+
*/
|
|
156
|
+
export async function settleAppleOneOff(deps: AppleOneOffDeps, oneOff: AppleOneOff): Promise<AppleOneOffSettlement> {
|
|
157
|
+
const { name } = oneOff;
|
|
158
|
+
try {
|
|
159
|
+
let presence = await appleOneOffPresence(deps.cli, oneOff);
|
|
160
|
+
if (presence === "absent") return "absent";
|
|
161
|
+
// Only its own is ever deleted.
|
|
162
|
+
for (let attempt = 0; attempt < DELETE_ATTEMPTS && presence === "ours"; attempt += 1) {
|
|
163
|
+
await deps.cli(["delete", "--force", name], DELETE_TIMEOUT_MS);
|
|
164
|
+
presence = await appleOneOffPresence(deps.cli, oneOff);
|
|
165
|
+
}
|
|
166
|
+
if (presence === "absent") return "removed";
|
|
167
|
+
if (presence !== "ours") return leftAlone(name, presence);
|
|
168
|
+
// Its own, and it will not go: what wedges the runtime.
|
|
169
|
+
console.warn(`[host-agent] one-off container ${name} could not be deleted; asking for the Apple runtime's remedy`);
|
|
170
|
+
const outcome = await deps.recycle();
|
|
171
|
+
presence = await appleOneOffPresence(deps.cli, oneOff);
|
|
172
|
+
if (presence === "absent") return "recycled";
|
|
173
|
+
console.warn(
|
|
174
|
+
`[host-agent] one-off container ${name} is still there (${
|
|
175
|
+
outcome.recycled ? "the runtime's services were recycled" : outcome.reason
|
|
176
|
+
}); it is watched, and removed once it can be`,
|
|
177
|
+
);
|
|
178
|
+
return "unproven";
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.warn(
|
|
181
|
+
`[host-agent] one-off container ${name} could not be settled: ${error instanceof Error ? error.message : String(error)}`,
|
|
182
|
+
);
|
|
183
|
+
return "unproven";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function leftAlone(name: string, presence: "foreign" | "unknown"): AppleOneOffSettlement {
|
|
188
|
+
if (presence === "foreign") {
|
|
189
|
+
console.warn(`[host-agent] a container named ${name} is not this run's own; leaving it untouched`);
|
|
190
|
+
return "foreign";
|
|
191
|
+
}
|
|
192
|
+
console.warn(`[host-agent] could not observe one-off container ${name}; nothing is deleted or recycled on a guess`);
|
|
193
|
+
return "unproven";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The runtime's remedy for work outside its activation: ask the runtime's
|
|
198
|
+
* owner, at most once for one run, to recheck the runtime
|
|
199
|
+
* (container-runtime.ts requestContainerRuntimeRecheck). Its activation
|
|
200
|
+
* restarts the services of a runtime it finds wedged, then recovers every
|
|
201
|
+
* task; this never restarts them itself.
|
|
202
|
+
*/
|
|
203
|
+
export function askRuntimeOwnerOnce(
|
|
204
|
+
recheck: (reason: string) => boolean,
|
|
205
|
+
reason: string,
|
|
206
|
+
): () => Promise<Extract<AppleRecycleOutcome, { recycled: false }>> {
|
|
207
|
+
let asked: boolean | null = null;
|
|
208
|
+
return async () => {
|
|
209
|
+
asked ??= recheck(reason);
|
|
210
|
+
return {
|
|
211
|
+
recycled: false,
|
|
212
|
+
reason: asked ? "its owner was asked to recheck the runtime" : "no owner of the runtime was there to ask",
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a run's settlement leaves its name to watch: anything unproven,
|
|
219
|
+
* and, when its client never reported its end (`lateCreation`: its
|
|
220
|
+
* container can still be created after), anything short of removed.
|
|
221
|
+
*/
|
|
222
|
+
export function appleOneOffNeedsWatch(settled: AppleOneOffSettlement, lateCreation: boolean): boolean {
|
|
223
|
+
if (settled === "removed" || settled === "recycled") return false;
|
|
224
|
+
return settled === "unproven" || lateCreation;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Watch `oneOff`'s name: look for it again after each wait, and settle it
|
|
229
|
+
* whenever it is found its own; one that cannot be observed, or is not its
|
|
230
|
+
* own (when `lateCreation`, the name may be its own's later), is looked for
|
|
231
|
+
* again. Resolves once it is removed or proven gone (absent ends the watch
|
|
232
|
+
* only where nothing of the run can be created after), or the watch's time
|
|
233
|
+
* is up; never rejects.
|
|
234
|
+
*/
|
|
235
|
+
export async function watchAppleOneOff(
|
|
236
|
+
deps: AppleOneOffDeps,
|
|
237
|
+
oneOff: AppleOneOff,
|
|
238
|
+
options: { lateCreation: boolean },
|
|
239
|
+
): Promise<AppleOneOffSettlement> {
|
|
240
|
+
const delay = deps.delay ?? defaultDelay;
|
|
241
|
+
let last: AppleOneOffSettlement = "unproven";
|
|
242
|
+
for (const wait of WATCH_MS) {
|
|
243
|
+
await delay(wait);
|
|
244
|
+
let presence: AppleOneOffPresence;
|
|
245
|
+
try {
|
|
246
|
+
presence = await appleOneOffPresence(deps.cli, oneOff);
|
|
247
|
+
} catch {
|
|
248
|
+
presence = "unknown";
|
|
249
|
+
}
|
|
250
|
+
if (presence === "absent" || presence === "foreign") {
|
|
251
|
+
if (!options.lateCreation) return presence;
|
|
252
|
+
last = presence;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (presence === "unknown") {
|
|
256
|
+
last = "unproven";
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const settled = await settleAppleOneOff(deps, oneOff);
|
|
260
|
+
if (settled === "removed" || settled === "recycled") return settled;
|
|
261
|
+
if (settled !== "unproven" && !options.lateCreation) return settled;
|
|
262
|
+
last = settled;
|
|
263
|
+
}
|
|
264
|
+
return last;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function defaultDelay(ms: number): Promise<void> {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
const timer = setTimeout(resolve, ms);
|
|
270
|
+
timer.unref?.();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Tests only: the name sequence, from the start. */
|
|
275
|
+
export function resetAppleOneOffNamesForTests(): void {
|
|
276
|
+
sequence = 0;
|
|
277
|
+
}
|
|
@@ -103,9 +103,10 @@ export function parseAppleContainerServiceLabels(listing: string): string[] {
|
|
|
103
103
|
* requires the label AND the closed generated grammar together. */
|
|
104
104
|
export const MAINTENANCE_CONTAINER_LABEL = "com.uai.maintenance";
|
|
105
105
|
|
|
106
|
-
/** The closed grammar
|
|
107
|
-
* uai-maint-<purpose>-<pid>-<seq
|
|
108
|
-
|
|
106
|
+
/** The closed grammar one-off containers are named in (apple-one-off.ts):
|
|
107
|
+
* uai-maint-<purpose>-<pid>-<seq>-<16 random hex>; the random part is
|
|
108
|
+
* absent from the names of a runner that predates it. */
|
|
109
|
+
export const MAINTENANCE_CONTAINER_NAME_RE = /^uai-maint-[a-z-]+-\d+-\d+(?:-[0-9a-f]{16})?$/;
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
112
|
* The sweep deletes ONLY containers Uai provably owns: the exact generated
|
package/lib/attachments.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* never get committed. The cloud relays bytes in/out; it never persists them.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { taskWorkspaceDir } from "./env";
|
|
13
|
+
import { makeContainedDirectories, readBoundedFile, writeContainedFile } from "./task-environment/workspace-files";
|
|
13
14
|
|
|
14
15
|
/** Host directory holding a task's attachments. */
|
|
15
16
|
export function attachmentsDir(taskId: string): string {
|
|
@@ -21,26 +22,27 @@ export function containerAttachmentPath(filename: string): string {
|
|
|
21
22
|
return `/workspace/.uai/attachments/${basename(filename)}`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
function
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (full !== dir && !full.startsWith(dir + "/")) {
|
|
25
|
+
/** An attachment's file name, as it lands in the attachments directory. */
|
|
26
|
+
function attachmentName(filename: string): string {
|
|
27
|
+
const name = basename(filename);
|
|
28
|
+
if (name === "" || name === "." || name === ".." || name.includes("\0")) {
|
|
29
29
|
throw new Error("invalid attachment filename");
|
|
30
30
|
}
|
|
31
|
-
return
|
|
31
|
+
return name;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
/** Written inside the task's workspace, never through a link a task put
|
|
35
|
+
* there (#cb3j0z). */
|
|
36
|
+
export async function writeAttachment(taskId: string, filename: string, bytes: Buffer): Promise<void> {
|
|
37
|
+
const name = attachmentName(filename);
|
|
38
|
+
// The workspace root is the runner's own path; below it, the task's.
|
|
39
|
+
await mkdir(taskWorkspaceDir(taskId), { recursive: true });
|
|
40
|
+
await makeContainedDirectories(taskWorkspaceDir(taskId), [".uai", "attachments"]);
|
|
41
|
+
await writeContainedFile(taskWorkspaceDir(taskId), [".uai", "attachments", name], bytes);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/** Read the same way: a regular file inside the workspace, or null. */
|
|
45
|
+
export async function readAttachment(taskId: string, filename: string): Promise<Buffer | null> {
|
|
46
|
+
const bytes = await readBoundedFile(taskWorkspaceDir(taskId), [".uai", "attachments", attachmentName(filename)]);
|
|
47
|
+
return bytes === null ? null : Buffer.from(bytes);
|
|
46
48
|
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §3 on the sign-in box: write one capture as one immutable revision,
|
|
3
|
+
* exactly where the cloud's flow says, and never read it back.
|
|
4
|
+
*
|
|
5
|
+
* The cloud begins every capture (lib/credential-metadata.ts) and hands the
|
|
6
|
+
* box the revision's parameter names. The box checks them, builds the two
|
|
7
|
+
* parts from what the sign-in produced, and creates them, broker-only part
|
|
8
|
+
* first:
|
|
9
|
+
*
|
|
10
|
+
* - lease-readable: what a task may receive: a static key as entered, or an
|
|
11
|
+
* OAuth login's brokered copy (access token in, refresh token replaced);
|
|
12
|
+
* - broker-only (OAuth logins only): the full material, which only the
|
|
13
|
+
* broker reads and only the broker ever overwrites.
|
|
14
|
+
*
|
|
15
|
+
* Both are created, never overwritten (`putImmutable`), with tags naming the
|
|
16
|
+
* workspace and person the role policy checks. The answer carries names and
|
|
17
|
+
* versions, never material.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
ENGINE_CREDENTIAL_FILES,
|
|
21
|
+
ENGINE_ENV_CREDENTIAL_KEYS,
|
|
22
|
+
MAX_CREDENTIAL_VALUE_BYTES,
|
|
23
|
+
parseCaptureFlowWire,
|
|
24
|
+
serializeLeasedValue,
|
|
25
|
+
type CaptureFlowWire,
|
|
26
|
+
type CaptureResultWire,
|
|
27
|
+
type CredentialLocation,
|
|
28
|
+
} from "../src/cloud-credentials";
|
|
29
|
+
import { ImmutableParameterAbortedError, storeFailureKind, type CredentialStore } from "./credential-store";
|
|
30
|
+
import { laneFor } from "./token-lanes/registry";
|
|
31
|
+
import {
|
|
32
|
+
emptyRefreshMeta,
|
|
33
|
+
refreshMetaFrom,
|
|
34
|
+
type CredentialFiles,
|
|
35
|
+
type RefreshMeta,
|
|
36
|
+
} from "./token-lanes/types";
|
|
37
|
+
|
|
38
|
+
/** What only the broker reads: the full material of an OAuth login. */
|
|
39
|
+
export interface BrokerEngineValue {
|
|
40
|
+
v: 1;
|
|
41
|
+
part: "broker";
|
|
42
|
+
location: CredentialLocation;
|
|
43
|
+
class: "engine";
|
|
44
|
+
lane: string;
|
|
45
|
+
files: CredentialFiles;
|
|
46
|
+
refresh: RefreshMeta;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type EngineCaptureMaterial =
|
|
50
|
+
| { authKind: "env"; env: Record<string, string> }
|
|
51
|
+
| { authKind: "config-dir"; files: CredentialFiles };
|
|
52
|
+
|
|
53
|
+
export interface BuiltCapture {
|
|
54
|
+
lease: string;
|
|
55
|
+
broker: string | null;
|
|
56
|
+
expiresAt: number | null;
|
|
57
|
+
/** Metadata the lease part also carries (never material): an SSH key's
|
|
58
|
+
* public half, so a retry can tell which key the revision holds. The
|
|
59
|
+
* identity tags always win over these. */
|
|
60
|
+
leaseTags?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ParsedCaptureFlow = CaptureFlowWire & { location: CredentialLocation };
|
|
64
|
+
|
|
65
|
+
export function parseCaptureFlow(value: unknown): ParsedCaptureFlow | null {
|
|
66
|
+
return parseCaptureFlowWire(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onlyKeys(record: Record<string, string>, allowed: readonly string[]): boolean {
|
|
70
|
+
const keys = Object.keys(record);
|
|
71
|
+
return keys.length > 0 && keys.every((key) => allowed.includes(key));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The two parts of an AI account's capture, or an error the sign-in shows.
|
|
76
|
+
* The flow's broker part must exist exactly when the material rotates: a
|
|
77
|
+
* rotating login without one would leave its refresh token nowhere the
|
|
78
|
+
* broker can reach (and a task holding it would refresh it itself), and a
|
|
79
|
+
* static key needs none.
|
|
80
|
+
*/
|
|
81
|
+
export function buildEngineCapture(
|
|
82
|
+
flow: ParsedCaptureFlow,
|
|
83
|
+
material: EngineCaptureMaterial,
|
|
84
|
+
): BuiltCapture | { error: string } {
|
|
85
|
+
const { location } = flow;
|
|
86
|
+
if (location.class !== "engine") {
|
|
87
|
+
return { error: "This capture is not an AI account." };
|
|
88
|
+
}
|
|
89
|
+
const kind = location.engineKind!;
|
|
90
|
+
if (material.authKind === "env") {
|
|
91
|
+
if (!onlyKeys(material.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [])) {
|
|
92
|
+
return { error: "This key is not one the engine reads." };
|
|
93
|
+
}
|
|
94
|
+
if (flow.brokerParameter !== null) {
|
|
95
|
+
return { error: "A pasted key does not rotate; it has no broker part." };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
lease: serializeLeasedValue({
|
|
99
|
+
v: 1,
|
|
100
|
+
part: "lease",
|
|
101
|
+
location,
|
|
102
|
+
class: "engine",
|
|
103
|
+
authKind: "env",
|
|
104
|
+
env: { ...material.env },
|
|
105
|
+
}),
|
|
106
|
+
broker: null,
|
|
107
|
+
expiresAt: null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!onlyKeys(material.files, ENGINE_CREDENTIAL_FILES[kind] ?? [])) {
|
|
111
|
+
return { error: "The sign-in produced files the engine does not use." };
|
|
112
|
+
}
|
|
113
|
+
const lane = laneFor(
|
|
114
|
+
{ kind, authKind: "config-dir", id: location.credentialId!, scope: location.scope },
|
|
115
|
+
material.files,
|
|
116
|
+
);
|
|
117
|
+
if (lane === null) {
|
|
118
|
+
if (flow.brokerParameter !== null) {
|
|
119
|
+
return { error: "This credential does not rotate; it has no broker part." };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
lease: serializeLeasedValue({
|
|
123
|
+
v: 1,
|
|
124
|
+
part: "lease",
|
|
125
|
+
location,
|
|
126
|
+
class: "engine",
|
|
127
|
+
authKind: "config-dir",
|
|
128
|
+
files: { ...material.files },
|
|
129
|
+
expiresAt: null,
|
|
130
|
+
}),
|
|
131
|
+
broker: null,
|
|
132
|
+
expiresAt: null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (flow.brokerParameter === null) {
|
|
136
|
+
return { error: "This sign-in rotates, and the cloud gave it nowhere to keep its refresh token." };
|
|
137
|
+
}
|
|
138
|
+
const expiresAt = lane.expiresAt(material.files);
|
|
139
|
+
const broker: BrokerEngineValue = {
|
|
140
|
+
v: 1,
|
|
141
|
+
part: "broker",
|
|
142
|
+
location,
|
|
143
|
+
class: "engine",
|
|
144
|
+
lane: lane.id,
|
|
145
|
+
files: { ...material.files },
|
|
146
|
+
refresh: { ...emptyRefreshMeta(lane.id), expiresAt },
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
lease: serializeLeasedValue({
|
|
150
|
+
v: 1,
|
|
151
|
+
part: "lease",
|
|
152
|
+
location,
|
|
153
|
+
class: "engine",
|
|
154
|
+
authKind: "config-dir",
|
|
155
|
+
files: lane.brokeredCopy(material.files),
|
|
156
|
+
expiresAt,
|
|
157
|
+
}),
|
|
158
|
+
broker: JSON.stringify(broker),
|
|
159
|
+
expiresAt,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The tags every part carries: the identity a retry is checked against,
|
|
164
|
+
* and the workspace and person the role policy compares. */
|
|
165
|
+
export function captureTags(
|
|
166
|
+
flow: ParsedCaptureFlow,
|
|
167
|
+
part: "lease" | "broker",
|
|
168
|
+
): Record<string, string> {
|
|
169
|
+
return {
|
|
170
|
+
...(flow.orgId !== null ? { org: flow.orgId } : {}),
|
|
171
|
+
...(flow.userId !== null ? { user: flow.userId } : {}),
|
|
172
|
+
uai_credential: flow.credentialId,
|
|
173
|
+
uai_revision: flow.revision,
|
|
174
|
+
uai_part: part,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create both parts, broker-only first: a crash after it leaves a revision
|
|
180
|
+
* the cloud never promotes, which the sweep removes; a lease-readable part
|
|
181
|
+
* never exists without the refresh token it was brokered from. A retry after
|
|
182
|
+
* a lost answer finds its own create by its tags and carries on.
|
|
183
|
+
*/
|
|
184
|
+
export async function writeCapture(
|
|
185
|
+
store: CredentialStore,
|
|
186
|
+
flow: ParsedCaptureFlow,
|
|
187
|
+
built: BuiltCapture,
|
|
188
|
+
options: { signal?: AbortSignal } = {},
|
|
189
|
+
): Promise<CaptureResultWire> {
|
|
190
|
+
if (!store.putImmutable) {
|
|
191
|
+
throw new Error("This credential store cannot create immutable parameters.");
|
|
192
|
+
}
|
|
193
|
+
// A caller's deadline covers the writes too: past it nothing more is sent,
|
|
194
|
+
// and a part already sent is the cloud's to sweep with the revision.
|
|
195
|
+
const { signal } = options;
|
|
196
|
+
if (built.broker !== null) {
|
|
197
|
+
if (flow.brokerParameter === null) throw new Error("no broker part to write");
|
|
198
|
+
if (signal?.aborted) throw new ImmutableParameterAbortedError();
|
|
199
|
+
await store.putImmutable(flow.brokerParameter, built.broker, captureTags(flow, "broker"), { signal });
|
|
200
|
+
}
|
|
201
|
+
if (signal?.aborted) throw new ImmutableParameterAbortedError();
|
|
202
|
+
await store.putImmutable(
|
|
203
|
+
flow.leaseParameter,
|
|
204
|
+
built.lease,
|
|
205
|
+
{ ...built.leaseTags, ...captureTags(flow, "lease") },
|
|
206
|
+
{ signal },
|
|
207
|
+
);
|
|
208
|
+
return {
|
|
209
|
+
credentialId: flow.credentialId,
|
|
210
|
+
revision: flow.revision,
|
|
211
|
+
// Created, never overwritten by the box: version 1. Only the broker
|
|
212
|
+
// writes later versions.
|
|
213
|
+
leaseVersion: 1,
|
|
214
|
+
expiresAt: built.expiresAt,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface CaptureOutcome {
|
|
219
|
+
ok: boolean;
|
|
220
|
+
message: string;
|
|
221
|
+
/** Present exactly when `ok`. */
|
|
222
|
+
capture?: CaptureResultWire;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The sign-in box's sink for an AI account's capture: check the flow, build
|
|
227
|
+
* both parts, create them. Every failure is a message the sign-in shows, and
|
|
228
|
+
* none of them carries material.
|
|
229
|
+
*/
|
|
230
|
+
export async function captureEngineAccount(
|
|
231
|
+
store: CredentialStore | null,
|
|
232
|
+
wire: CaptureFlowWire,
|
|
233
|
+
engine: string,
|
|
234
|
+
material: EngineCaptureMaterial,
|
|
235
|
+
engineLabel: string,
|
|
236
|
+
): Promise<CaptureOutcome> {
|
|
237
|
+
const flow = parseCaptureFlow(wire);
|
|
238
|
+
if (!flow || flow.location.class !== "engine" || flow.location.engineKind !== engine) {
|
|
239
|
+
return { ok: false, message: "The sign-in's destination is invalid; nothing was kept." };
|
|
240
|
+
}
|
|
241
|
+
if (!store) {
|
|
242
|
+
return { ok: false, message: "This runner has no credential store to hold the account." };
|
|
243
|
+
}
|
|
244
|
+
const built = buildEngineCapture(flow, material);
|
|
245
|
+
if ("error" in built) return { ok: false, message: built.error };
|
|
246
|
+
try {
|
|
247
|
+
const capture = await writeCapture(store, flow, built);
|
|
248
|
+
return { ok: true, message: `${engineLabel} signed in.`, capture };
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// A category, never the error's words: a store failure can echo input.
|
|
251
|
+
console.warn(`[capture-flow] ${engine} ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
message: "The credential store refused the write; nothing was kept on this runner.",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A stored broker-only part, exactly, for the revision at `expected`; null
|
|
262
|
+
* for anything else. Only the broker reads it (token-broker-revision.ts).
|
|
263
|
+
*/
|
|
264
|
+
export function parseBrokerEngineValue(
|
|
265
|
+
raw: string,
|
|
266
|
+
expected: CredentialLocation,
|
|
267
|
+
): BrokerEngineValue | null {
|
|
268
|
+
if (typeof raw !== "string" || new TextEncoder().encode(raw).length > MAX_CREDENTIAL_VALUE_BYTES) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
let parsed: unknown;
|
|
272
|
+
try {
|
|
273
|
+
parsed = JSON.parse(raw);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
278
|
+
const o = parsed as Record<string, unknown>;
|
|
279
|
+
const location = o.location as Record<string, unknown> | null;
|
|
280
|
+
if (
|
|
281
|
+
o.v !== 1 ||
|
|
282
|
+
o.part !== "broker" ||
|
|
283
|
+
o.class !== "engine" ||
|
|
284
|
+
expected.class !== "engine" ||
|
|
285
|
+
typeof o.lane !== "string" ||
|
|
286
|
+
!location ||
|
|
287
|
+
typeof location !== "object" ||
|
|
288
|
+
location.class !== expected.class ||
|
|
289
|
+
location.scope !== expected.scope ||
|
|
290
|
+
location.ownerId !== expected.ownerId ||
|
|
291
|
+
location.engineKind !== expected.engineKind ||
|
|
292
|
+
location.credentialId !== expected.credentialId ||
|
|
293
|
+
location.revision !== expected.revision
|
|
294
|
+
) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const allowed = ENGINE_CREDENTIAL_FILES[expected.engineKind ?? ""] ?? [];
|
|
298
|
+
const files = o.files as Record<string, unknown> | null;
|
|
299
|
+
if (
|
|
300
|
+
!files ||
|
|
301
|
+
typeof files !== "object" ||
|
|
302
|
+
Array.isArray(files) ||
|
|
303
|
+
Object.keys(files).length === 0 ||
|
|
304
|
+
!Object.entries(files).every(([name, text]) => allowed.includes(name) && typeof text === "string")
|
|
305
|
+
) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const refresh = refreshMetaFrom(o.refresh);
|
|
309
|
+
if (!refresh || refresh.lane !== o.lane) return null;
|
|
310
|
+
return {
|
|
311
|
+
v: 1,
|
|
312
|
+
part: "broker",
|
|
313
|
+
location: expected,
|
|
314
|
+
class: "engine",
|
|
315
|
+
lane: o.lane,
|
|
316
|
+
files: files as CredentialFiles,
|
|
317
|
+
refresh,
|
|
318
|
+
};
|
|
319
|
+
}
|