@runuai/host 0.9.127 → 0.9.129
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/lib/credential-store-pickup.ts +3 -2
- package/lib/credential-store-scope.ts +85 -0
- package/lib/engine-accounts.ts +3 -2
- package/lib/git-identity.ts +70 -13
- package/lib/github-tokens.ts +14 -3
- package/lib/lease.ts +29 -14
- package/lib/orchestrator.ts +33 -4
- package/lib/shared-files-s3.ts +48 -7
- package/lib/task-environment/self.ts +70 -7
- package/lib/user-credentials-store.ts +5 -2
- package/package.json +1 -1
- package/src/cli.ts +7 -0
- package/src/main.ts +3 -1
- package/src/protocol.ts +7 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
13
|
+
import { engineAccountListPaths } from "./credential-store-scope";
|
|
13
14
|
import {
|
|
14
15
|
applyStoredEngineAccount,
|
|
15
16
|
engineAccountHostId,
|
|
@@ -45,8 +46,8 @@ export async function pickupChangedCredentials(): Promise<PickupSummary> {
|
|
|
45
46
|
const summary: PickupSummary = { skipped: false, listed: 0, changed: 0, applied: 0, errors: 0 };
|
|
46
47
|
if (!store) return { ...summary, skipped: true };
|
|
47
48
|
const prefix = credentialStorePrefix();
|
|
48
|
-
const
|
|
49
|
-
|
|
49
|
+
const paths = engineAccountListPaths(prefix, engineAccountHostId());
|
|
50
|
+
if (paths === null) return { ...summary, skipped: true };
|
|
50
51
|
const current = new Map<string, number>();
|
|
51
52
|
for (const path of paths) {
|
|
52
53
|
try {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which credential store paths this runner may list.
|
|
3
|
+
*
|
|
4
|
+
* An owned or shared host reads whole trees (`/uai/orgs`, `/uai/users`,
|
|
5
|
+
* `/uai/projects`, its own `/uai/hosts/<id>`). A sidecar (ADR-133) holds a
|
|
6
|
+
* task lease whose IAM policy is scoped by session tag — it may list exactly
|
|
7
|
+
* `/uai/orgs/<org>` and `/uai/users/<user>` and nothing above them, so the
|
|
8
|
+
* tree listings answer AccessDenied on every path (2026-09-10, smoke 6: zero
|
|
9
|
+
* engine accounts and no GitHub token on the first live task machine). The
|
|
10
|
+
* lease carries the two ids; a lease without them (an older cloud) means the
|
|
11
|
+
* store is not read at all rather than listed where it cannot be.
|
|
12
|
+
*
|
|
13
|
+
* Project env stays unread on a task machine: the policy grants project
|
|
14
|
+
* parameters by an `org` tag the mirror never writes (put-parameter's
|
|
15
|
+
* Overwrite excludes tags), so no listing shape can reach them until the
|
|
16
|
+
* store layout or the mirror changes (hostless-implementation.md).
|
|
17
|
+
*/
|
|
18
|
+
import { isParameterPathSegment } from "./credential-store";
|
|
19
|
+
import { leaseScope } from "./lease";
|
|
20
|
+
import { sidecarMode } from "./sidecar";
|
|
21
|
+
|
|
22
|
+
export type StoreListScope =
|
|
23
|
+
| { kind: "host" }
|
|
24
|
+
| { kind: "lease"; orgId: string | null; userId: string }
|
|
25
|
+
| { kind: "none"; reason: string };
|
|
26
|
+
|
|
27
|
+
export function storeListScope(): StoreListScope {
|
|
28
|
+
if (!sidecarMode()) return { kind: "host" };
|
|
29
|
+
const scope = leaseScope();
|
|
30
|
+
if (!scope) return { kind: "none", reason: "the lease carries no org/user scope" };
|
|
31
|
+
return { kind: "lease", orgId: scope.orgId, userId: scope.userId };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const warned = new Set<string>();
|
|
35
|
+
function warnOnce(tag: string, line: string): void {
|
|
36
|
+
if (warned.has(tag)) return;
|
|
37
|
+
warned.add(tag);
|
|
38
|
+
console.warn(`[${tag}] credential store: ${line}`);
|
|
39
|
+
}
|
|
40
|
+
/** Test seam. */
|
|
41
|
+
export function resetStoreScopeWarningsForTesting(): void {
|
|
42
|
+
warned.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The paths an engine-account listing walks, or null when this runner
|
|
46
|
+
* must not list at all (a sidecar without a scoped lease). */
|
|
47
|
+
export function engineAccountListPaths(prefix: string, hostId: string | null): string[] | null {
|
|
48
|
+
const scope = storeListScope();
|
|
49
|
+
if (scope.kind === "host") {
|
|
50
|
+
const paths = [`${prefix}/orgs`];
|
|
51
|
+
if (hostId && isParameterPathSegment(hostId)) paths.push(`${prefix}/hosts/${hostId}`);
|
|
52
|
+
return paths;
|
|
53
|
+
}
|
|
54
|
+
if (scope.kind === "none") {
|
|
55
|
+
warnOnce("engine-accounts", `${scope.reason} — not listed on this task machine`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// No host path: a task machine's host row owns no accounts, and the policy
|
|
59
|
+
// denies the path anyway.
|
|
60
|
+
if (scope.orgId === null || !isParameterPathSegment(scope.orgId)) return [];
|
|
61
|
+
return [`${prefix}/orgs/${scope.orgId}`];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The path a user-credential listing walks, or null to skip. */
|
|
65
|
+
export function userCredentialListPath(prefix: string): string | null {
|
|
66
|
+
const scope = storeListScope();
|
|
67
|
+
if (scope.kind === "host") return `${prefix}/users`;
|
|
68
|
+
if (scope.kind === "none") {
|
|
69
|
+
warnOnce("user-credentials", `${scope.reason} — not listed on this task machine`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!isParameterPathSegment(scope.userId)) return null;
|
|
73
|
+
return `${prefix}/users/${scope.userId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The path a project-env listing walks, or null to skip. */
|
|
77
|
+
export function projectEnvListPath(prefix: string): string | null {
|
|
78
|
+
const scope = storeListScope();
|
|
79
|
+
if (scope.kind === "host") return `${prefix}/projects`;
|
|
80
|
+
warnOnce(
|
|
81
|
+
"project-env",
|
|
82
|
+
"not read on a task machine — the lease policy grants no project parameters (they carry no org tag)",
|
|
83
|
+
);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
settleStoreWrites,
|
|
54
54
|
type CredentialStore,
|
|
55
55
|
} from "./credential-store";
|
|
56
|
+
import { engineAccountListPaths } from "./credential-store-scope";
|
|
56
57
|
import { getDb, schema } from "./db";
|
|
57
58
|
import { brokeredFilesFor } from "./token-broker";
|
|
58
59
|
import {
|
|
@@ -1452,8 +1453,8 @@ export async function reconcileEngineAccountsFromStore(
|
|
|
1452
1453
|
const prefix = credentialStorePrefix();
|
|
1453
1454
|
const warn = (line: string): void => console.warn(`[engine-accounts] credential store: ${line}`);
|
|
1454
1455
|
|
|
1455
|
-
const paths =
|
|
1456
|
-
if (
|
|
1456
|
+
const paths = engineAccountListPaths(prefix, hostId);
|
|
1457
|
+
if (paths === null) return { ...result, skipped: true };
|
|
1457
1458
|
const listed: Array<{ name: string; value: string }> = [];
|
|
1458
1459
|
const listedPrefixes: string[] = [];
|
|
1459
1460
|
for (const path of paths) {
|
package/lib/git-identity.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
14
15
|
import { resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
import { eq } from "drizzle-orm";
|
|
@@ -62,6 +63,30 @@ function managedTaskExecArgs(
|
|
|
62
63
|
];
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/** A task environment we can exec in (machine or sidecar): the ssh steps run
|
|
67
|
+
* there as `node` under the runtime authority, key material over stdin. */
|
|
68
|
+
type EnvironmentExec = Pick<TaskEnvironmentHandle, "exec">;
|
|
69
|
+
|
|
70
|
+
async function environmentStep(
|
|
71
|
+
environment: EnvironmentExec,
|
|
72
|
+
argv: [string, ...string[]],
|
|
73
|
+
stdin?: Uint8Array,
|
|
74
|
+
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
|
75
|
+
const result = await environment.exec({
|
|
76
|
+
argv,
|
|
77
|
+
user: "node",
|
|
78
|
+
env: RUNTIME_AUTHORITY_ENV,
|
|
79
|
+
...(stdin ? { stdin } : {}),
|
|
80
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
81
|
+
maxOutputBytes: 64 * 1024,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
status: result.exitCode,
|
|
85
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
86
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
export async function setupTaskGitIdentity(
|
|
66
91
|
taskId: string,
|
|
67
92
|
name: string | null,
|
|
@@ -164,7 +189,35 @@ export async function injectSshIdentity(
|
|
|
164
189
|
taskId: string,
|
|
165
190
|
keyPath: string,
|
|
166
191
|
exec: DockerExec = defaultExec,
|
|
192
|
+
environment?: EnvironmentExec,
|
|
167
193
|
): Promise<boolean> {
|
|
194
|
+
if (environment) {
|
|
195
|
+
// ADR-121/133: no container and no `docker cp` on a machine — the key
|
|
196
|
+
// pair rides stdin into files node owns from the start, so there is no
|
|
197
|
+
// chown race and nothing for the machine's other users to read. Before
|
|
198
|
+
// this branch every task machine logged `mkdir ~/.ssh failed: spawn
|
|
199
|
+
// docker ENOENT` on session start and signed nothing (2026-09-10).
|
|
200
|
+
const privateKey = readFileSync(keyPath);
|
|
201
|
+
const publicKey = readFileSync(`${keyPath}.pub`);
|
|
202
|
+
const steps: ReadonlyArray<readonly [string, [string, ...string[]], Uint8Array?]> = [
|
|
203
|
+
["mkdir ~/.ssh", ["/bin/sh", "-c", "/bin/mkdir -p /home/node/.ssh && /bin/chmod 700 /home/node/.ssh"]],
|
|
204
|
+
["copy key", ["/bin/sh", "-c", "umask 077 && /bin/cat > /home/node/.ssh/id_ed25519"], privateKey],
|
|
205
|
+
["copy pubkey", ["/bin/sh", "-c", "umask 022 && /bin/cat > /home/node/.ssh/id_ed25519.pub"], publicKey],
|
|
206
|
+
["own + chmod", ["/bin/sh", "-c", "/bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519"]],
|
|
207
|
+
["SSH signing config", ["/bin/sh", "-c", SSH_SIGNING_CONFIG]],
|
|
208
|
+
];
|
|
209
|
+
for (const [label, argv, stdin] of steps) {
|
|
210
|
+
const res = await environmentStep(environment, argv, stdin);
|
|
211
|
+
if (res.status !== 0) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
|
|
214
|
+
);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
168
221
|
const container = taskAppContainerName(taskId);
|
|
169
222
|
const steps: ReadonlyArray<readonly [string, string[]]> = [
|
|
170
223
|
[
|
|
@@ -269,12 +322,16 @@ async function sshKeyHealthy(
|
|
|
269
322
|
taskId: string,
|
|
270
323
|
expectedPublicKey: string,
|
|
271
324
|
exec: DockerExec,
|
|
325
|
+
environment?: EnvironmentExec,
|
|
272
326
|
): Promise<boolean> {
|
|
273
|
-
const
|
|
327
|
+
const probe: [string, ...string[]] = [
|
|
274
328
|
"/bin/sh",
|
|
275
329
|
"-c",
|
|
276
330
|
"test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
|
|
277
|
-
]
|
|
331
|
+
];
|
|
332
|
+
const res = environment
|
|
333
|
+
? await environmentStep(environment, probe)
|
|
334
|
+
: await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), probe));
|
|
278
335
|
const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
|
|
279
336
|
const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
|
|
280
337
|
return (
|
|
@@ -287,6 +344,9 @@ async function sshKeyHealthy(
|
|
|
287
344
|
interface EnsureTaskSshIdentityDeps {
|
|
288
345
|
publicKeyForUser?: (userId: string) => string | null;
|
|
289
346
|
writeIdentity?: typeof writeTaskIdentity;
|
|
347
|
+
/** The task's environment when it is a machine or sidecar (no container to
|
|
348
|
+
* exec into or `docker cp` to). Null/undefined = the container path. */
|
|
349
|
+
environment?: () => EnvironmentExec | null | undefined;
|
|
290
350
|
}
|
|
291
351
|
|
|
292
352
|
/**
|
|
@@ -342,6 +402,7 @@ export async function ensureTaskSshIdentity(
|
|
|
342
402
|
expectedPublicKey,
|
|
343
403
|
exec,
|
|
344
404
|
deps.writeIdentity ?? writeTaskIdentity,
|
|
405
|
+
deps.environment?.() ?? undefined,
|
|
345
406
|
);
|
|
346
407
|
const entry = { fingerprint: expectedFingerprint, promise: pending };
|
|
347
408
|
sshEnsureInFlight.set(taskId, entry);
|
|
@@ -365,19 +426,15 @@ async function ensureTaskSshIdentityOnce(
|
|
|
365
426
|
expectedPublicKey: string,
|
|
366
427
|
exec: DockerExec,
|
|
367
428
|
writeIdentity: typeof writeTaskIdentity,
|
|
429
|
+
environment?: EnvironmentExec,
|
|
368
430
|
): Promise<boolean> {
|
|
369
431
|
// Fast path: the DB-authoritative key is already good → re-assert only the
|
|
370
432
|
// cheap signing config and skip docker cp churn.
|
|
371
|
-
if (await sshKeyHealthy(taskId, expectedPublicKey, exec)) {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
"/bin/sh",
|
|
377
|
-
"-c",
|
|
378
|
-
SSH_SIGNING_CONFIG,
|
|
379
|
-
],
|
|
380
|
-
));
|
|
433
|
+
if (await sshKeyHealthy(taskId, expectedPublicKey, exec, environment)) {
|
|
434
|
+
const config: [string, ...string[]] = ["/bin/sh", "-c", SSH_SIGNING_CONFIG];
|
|
435
|
+
const configured = environment
|
|
436
|
+
? await environmentStep(environment, config)
|
|
437
|
+
: await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), config));
|
|
381
438
|
return configured.status === 0;
|
|
382
439
|
}
|
|
383
440
|
|
|
@@ -388,7 +445,7 @@ async function ensureTaskSshIdentityOnce(
|
|
|
388
445
|
}
|
|
389
446
|
const keyPath = resolve(dir, "id_ed25519");
|
|
390
447
|
try {
|
|
391
|
-
return await injectSshIdentity(taskId, keyPath, exec);
|
|
448
|
+
return await injectSshIdentity(taskId, keyPath, exec, environment);
|
|
392
449
|
} finally {
|
|
393
450
|
// Host hygiene (same as task-up): the materialized private key never
|
|
394
451
|
// outlives the injection.
|
package/lib/github-tokens.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
ensureTaskSshIdentity,
|
|
34
34
|
} from "./git-identity";
|
|
35
35
|
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
36
|
+
import { sidecarMode } from "./sidecar";
|
|
36
37
|
import {
|
|
37
38
|
MAX_GITHUB_INSTALLATIONS,
|
|
38
39
|
MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
|
|
@@ -1940,6 +1941,13 @@ export async function removeGithubFromContainer(
|
|
|
1940
1941
|
run: DockerCommand = taskContainerCli,
|
|
1941
1942
|
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
1942
1943
|
): Promise<boolean> {
|
|
1944
|
+
// ADR-133: a task machine has no container runtime and no app container —
|
|
1945
|
+
// the credential lives in the machine's own gh config, installed over the
|
|
1946
|
+
// environment at task-up (`machineTaskGithubAuth`). Inspecting Docker here
|
|
1947
|
+
// threw `spawn docker ENOENT` on every session start, so no agent ever
|
|
1948
|
+
// came up on the first live hostless run (2026-09-10, smoke 6). Same answer
|
|
1949
|
+
// a Docker host gives for a machine task: no container, nothing to scrub.
|
|
1950
|
+
if (sidecarMode()) return false;
|
|
1943
1951
|
const container = taskAppContainerName(taskId);
|
|
1944
1952
|
admitContainerWork();
|
|
1945
1953
|
const observed = await taskAppContainerState(container, run as never);
|
|
@@ -2086,9 +2094,12 @@ function enqueueTaskGitAuth(
|
|
|
2086
2094
|
// ensuring the identity also keeps commit/tag signing available. Never
|
|
2087
2095
|
// select SSH transport unless a usable identity was actually asserted.
|
|
2088
2096
|
admitContainerWork();
|
|
2089
|
-
const hasSshIdentity =
|
|
2090
|
-
deps.ensureSsh
|
|
2091
|
-
|
|
2097
|
+
const hasSshIdentity = deps.ensureSsh
|
|
2098
|
+
? await deps.ensureSsh(taskId, userId)
|
|
2099
|
+
: await ensureTaskSshIdentity(taskId, userId, undefined, {
|
|
2100
|
+
// Machine/sidecar tasks install the key over the environment.
|
|
2101
|
+
environment: () => invocation.setupDeps?.environment?.() ?? null,
|
|
2102
|
+
});
|
|
2092
2103
|
if (!isCurrent()) return false;
|
|
2093
2104
|
admitContainerWork();
|
|
2094
2105
|
if (hasSshIdentity) {
|
package/lib/lease.ts
CHANGED
|
@@ -30,13 +30,21 @@ export const LEASE_RETRY_MS = 30_000;
|
|
|
30
30
|
/** The cloud must answer within this window or the request is retried. */
|
|
31
31
|
export const LEASE_REQUEST_TIMEOUT_MS = 20_000;
|
|
32
32
|
|
|
33
|
-
/** Where the `credential_process`
|
|
33
|
+
/** Where the `credential_process` command reads the live session. Root-only:
|
|
34
34
|
* the agents run as `node` and never see it. */
|
|
35
35
|
export const LEASE_CREDENTIALS_FILE = "/run/uai/lease-credentials.json";
|
|
36
36
|
/** The AWS config Mountpoint is pointed at (`AWS_CONFIG_FILE`). */
|
|
37
37
|
export const LEASE_AWS_CONFIG_FILE = "/run/uai/aws-config";
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The `credential_process` line: `cat` of the JSON, run by the AWS CLI and
|
|
40
|
+
* by Mountpoint's CRT profile provider through `/bin/sh -c`. NOT a script
|
|
41
|
+
* under /run — Debian mounts /run `noexec`, so a helper there fails with
|
|
42
|
+
* EACCES even for root and both clients fall through to the instance
|
|
43
|
+
* profile, which has no S3 rights (live 2026-09-10, hostless smoke 6: every
|
|
44
|
+
* mount answered "assumed-role/uai-task-machine-instance … is not
|
|
45
|
+
* authorized to perform: s3:ListBucket").
|
|
46
|
+
*/
|
|
47
|
+
export const LEASE_CREDENTIAL_PROCESS = `/bin/cat ${LEASE_CREDENTIALS_FILE}`;
|
|
40
48
|
|
|
41
49
|
export interface LeaseSender {
|
|
42
50
|
(frame: { kind: "lease.request"; opId: string; taskId: string }): boolean;
|
|
@@ -95,21 +103,28 @@ export function credentialProcessOutput(lease: TaskLease): string {
|
|
|
95
103
|
});
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
/** The AWS config file content for a lease: default profile, region, and
|
|
107
|
+
* the `credential_process` that serves the live session. Exported for the
|
|
108
|
+
* test that pins it. */
|
|
109
|
+
export function leaseAwsConfig(lease: Pick<TaskLease, "region">): string {
|
|
110
|
+
return `[default]\nregion = ${lease.region}\ncredential_process = ${LEASE_CREDENTIAL_PROCESS}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The org and user a lease was issued for — the credential store paths a
|
|
114
|
+
* sidecar may list (`/uai/orgs/<org>`, `/uai/users/<user>`; the task-machine
|
|
115
|
+
* policy grants nothing above them). Null on an owned host or before the
|
|
116
|
+
* first grant; `orgId` is null for a lease with no org scope. Absent from
|
|
117
|
+
* the lease when the cloud predates the field. */
|
|
118
|
+
export function leaseScope(): { orgId: string | null; userId: string } | null {
|
|
119
|
+
if (!current || typeof current.userId !== "string") return null;
|
|
120
|
+
return { orgId: current.orgId ?? null, userId: current.userId };
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
function writeLeaseFiles(lease: TaskLease): void {
|
|
99
124
|
mkdirSync(dirname(LEASE_CREDENTIALS_FILE), { recursive: true, mode: 0o700 });
|
|
100
125
|
writeFileSync(LEASE_CREDENTIALS_FILE, credentialProcessOutput(lease), { mode: 0o600 });
|
|
101
126
|
chmodSync(LEASE_CREDENTIALS_FILE, 0o600);
|
|
102
|
-
writeFileSync(
|
|
103
|
-
LEASE_CREDENTIAL_PROCESS,
|
|
104
|
-
`#!/bin/sh\nexec cat ${LEASE_CREDENTIALS_FILE}\n`,
|
|
105
|
-
{ mode: 0o700 },
|
|
106
|
-
);
|
|
107
|
-
chmodSync(LEASE_CREDENTIAL_PROCESS, 0o700);
|
|
108
|
-
writeFileSync(
|
|
109
|
-
LEASE_AWS_CONFIG_FILE,
|
|
110
|
-
`[default]\nregion = ${lease.region}\ncredential_process = ${LEASE_CREDENTIAL_PROCESS}\n`,
|
|
111
|
-
{ mode: 0o600 },
|
|
112
|
-
);
|
|
127
|
+
writeFileSync(LEASE_AWS_CONFIG_FILE, leaseAwsConfig(lease), { mode: 0o600 });
|
|
113
128
|
chmodSync(LEASE_AWS_CONFIG_FILE, 0o600);
|
|
114
129
|
}
|
|
115
130
|
|
package/lib/orchestrator.ts
CHANGED
|
@@ -400,6 +400,25 @@ export function isClaudeAuthRevoked(message: string): boolean {
|
|
|
400
400
|
return CLAUDE_AUTH_REVOKED_PATTERNS.some((re) => re.test(message));
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/**
|
|
404
|
+
* ADR-121/133: on a machine or sidecar task the SSH signing identity is
|
|
405
|
+
* installed over the task environment (no container to exec into or
|
|
406
|
+
* `docker cp` to); a container task gets no environment and keeps the docker
|
|
407
|
+
* path. Recovery and resume have only the persisted task, so the environment
|
|
408
|
+
* is reconstructed here.
|
|
409
|
+
*/
|
|
410
|
+
async function sshIdentityDepsFor(task: {
|
|
411
|
+
taskId: string;
|
|
412
|
+
environmentProvider: string | null;
|
|
413
|
+
}): Promise<{ environment: () => Pick<TaskEnvironmentHandle<unknown>, "exec"> | null }> {
|
|
414
|
+
if (!isEnvironmentTransportProvider(task.environmentProvider)) {
|
|
415
|
+
return { environment: () => null };
|
|
416
|
+
}
|
|
417
|
+
const persisted = getHostTask(task.taskId);
|
|
418
|
+
const environment = persisted ? await reconstructPersistedTaskEnvironment(persisted) : null;
|
|
419
|
+
return { environment: () => environment };
|
|
420
|
+
}
|
|
421
|
+
|
|
403
422
|
export class Orchestrator {
|
|
404
423
|
private readonly channels = new Map<string, Channel>();
|
|
405
424
|
private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
|
|
@@ -1605,10 +1624,12 @@ export class Orchestrator {
|
|
|
1605
1624
|
environmentProvider: string | null;
|
|
1606
1625
|
projectIds?: string | null;
|
|
1607
1626
|
}): Record<string, string> {
|
|
1608
|
-
|
|
1627
|
+
// Machine OR sidecar (ADR-133): neither has a compose env to ride.
|
|
1628
|
+
if (!isEnvironmentTransportProvider(task.environmentProvider)) return {};
|
|
1609
1629
|
return projectEnvForTask(hostTaskProjectIds(task));
|
|
1610
1630
|
}
|
|
1611
1631
|
|
|
1632
|
+
|
|
1612
1633
|
/** Install a session with an event handler scoped to this exact generation. */
|
|
1613
1634
|
private bindSession(
|
|
1614
1635
|
channel: Channel,
|
|
@@ -1898,7 +1919,11 @@ export class Orchestrator {
|
|
|
1898
1919
|
// SSH signing identity, re-asserted independently of transport. Connected
|
|
1899
1920
|
// users still push through gh over HTTPS; disconnected users select the
|
|
1900
1921
|
// SSH transport fallback inside the reconcile above.
|
|
1901
|
-
void ensureTaskSshIdentity(channel.taskId, task.ownerUserId
|
|
1922
|
+
void ensureTaskSshIdentity(channel.taskId, task.ownerUserId, undefined, {
|
|
1923
|
+
// A machine or sidecar task installs the key over its environment; a
|
|
1924
|
+
// container task keeps the docker path (a null handle).
|
|
1925
|
+
environment: () => channel.agentHandle,
|
|
1926
|
+
}).catch((err) =>
|
|
1902
1927
|
console.warn(
|
|
1903
1928
|
`[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
|
|
1904
1929
|
err instanceof Error ? err.message : String(err)
|
|
@@ -6820,7 +6845,9 @@ async function recoverOneDockerTask(
|
|
|
6820
6845
|
),
|
|
6821
6846
|
);
|
|
6822
6847
|
}
|
|
6823
|
-
void
|
|
6848
|
+
void sshIdentityDepsFor(task)
|
|
6849
|
+
.then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
|
|
6850
|
+
.catch((err) =>
|
|
6824
6851
|
console.warn(
|
|
6825
6852
|
`[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
|
|
6826
6853
|
err instanceof Error ? err.message : String(err)
|
|
@@ -7087,7 +7114,9 @@ async function recoverOneDockerTask(
|
|
|
7087
7114
|
db_setRuntime(task.taskId, {
|
|
7088
7115
|
codeServerPort: port,
|
|
7089
7116
|
});
|
|
7090
|
-
void
|
|
7117
|
+
void sshIdentityDepsFor(task)
|
|
7118
|
+
.then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
|
|
7119
|
+
.catch((err) =>
|
|
7091
7120
|
console.warn(
|
|
7092
7121
|
`[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
|
|
7093
7122
|
err instanceof Error ? err.message : String(err)
|
package/lib/shared-files-s3.ts
CHANGED
|
@@ -17,8 +17,46 @@ import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
|
17
17
|
import { LEASE_AWS_CONFIG_FILE, currentLease } from "./lease";
|
|
18
18
|
|
|
19
19
|
export const MOUNT_S3_BIN = "/usr/bin/mount-s3";
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** The task user the mounts are owned by. Its ids are looked up on the
|
|
21
|
+
* machine, never assumed: the Debian cloud image the payload AMI is built
|
|
22
|
+
* from ships an `admin` user at uid 1000, so `node` lands on 1001 there
|
|
23
|
+
* and a hard-coded 1000 handed every mount to the wrong user (2026-09-10,
|
|
24
|
+
* smoke 6). */
|
|
25
|
+
export const SHARED_FILES_USER = "node";
|
|
26
|
+
/** What the lookup falls back to (the standard container image's ids). */
|
|
27
|
+
export const SHARED_FILES_FALLBACK_UID = 1000;
|
|
28
|
+
export const SHARED_FILES_FALLBACK_GID = 1000;
|
|
29
|
+
|
|
30
|
+
export interface TaskUserIds {
|
|
31
|
+
uid: number;
|
|
32
|
+
gid: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `id -u`/`id -g` of the task user on this machine, through the root exec. */
|
|
36
|
+
export async function resolveTaskUserIds(
|
|
37
|
+
handle: Pick<TaskEnvironmentHandle, "exec">,
|
|
38
|
+
user: string = SHARED_FILES_USER,
|
|
39
|
+
): Promise<TaskUserIds> {
|
|
40
|
+
const fallback = { uid: SHARED_FILES_FALLBACK_UID, gid: SHARED_FILES_FALLBACK_GID };
|
|
41
|
+
try {
|
|
42
|
+
const result = await handle.exec({
|
|
43
|
+
argv: ["/bin/sh", "-c", 'id -u "$1" && id -g "$1"', "ids", user],
|
|
44
|
+
user: "root",
|
|
45
|
+
timeoutMs: 10_000,
|
|
46
|
+
maxOutputBytes: 1024,
|
|
47
|
+
});
|
|
48
|
+
if (result.exitCode !== 0) return fallback;
|
|
49
|
+
const [uid, gid] = Buffer.from(result.stdout)
|
|
50
|
+
.toString("utf8")
|
|
51
|
+
.trim()
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.map((part) => Number.parseInt(part, 10));
|
|
54
|
+
if (!Number.isInteger(uid) || !Number.isInteger(gid) || uid! < 0 || gid! < 0) return fallback;
|
|
55
|
+
return { uid: uid!, gid: gid! };
|
|
56
|
+
} catch {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
22
60
|
|
|
23
61
|
export type SharedFilesMode = "off" | "ro" | "rw";
|
|
24
62
|
|
|
@@ -31,7 +69,7 @@ export interface SharedFilesMountPlan {
|
|
|
31
69
|
}
|
|
32
70
|
|
|
33
71
|
/** The mount-s3 argv for one prefix. Exported for the test that pins it. */
|
|
34
|
-
export function mountS3Argv(plan: SharedFilesMountPlan): [string, ...string[]] {
|
|
72
|
+
export function mountS3Argv(plan: SharedFilesMountPlan, owner: TaskUserIds): [string, ...string[]] {
|
|
35
73
|
return [
|
|
36
74
|
MOUNT_S3_BIN,
|
|
37
75
|
"--prefix",
|
|
@@ -42,9 +80,9 @@ export function mountS3Argv(plan: SharedFilesMountPlan): [string, ...string[]] {
|
|
|
42
80
|
// is performed by the root sidecar.
|
|
43
81
|
"--allow-other",
|
|
44
82
|
"--uid",
|
|
45
|
-
String(
|
|
83
|
+
String(owner.uid),
|
|
46
84
|
"--gid",
|
|
47
|
-
String(
|
|
85
|
+
String(owner.gid),
|
|
48
86
|
"--dir-mode",
|
|
49
87
|
"0755",
|
|
50
88
|
"--file-mode",
|
|
@@ -101,16 +139,19 @@ export async function mountSharedFilesFromLease(
|
|
|
101
139
|
}
|
|
102
140
|
const warnings: string[] = [];
|
|
103
141
|
let mountedAny = false;
|
|
142
|
+
const owner = await resolveTaskUserIds(handle);
|
|
104
143
|
for (const plan of sharedFilesMountPlans(workspacePath, mode, lease)) {
|
|
105
144
|
const result = await handle.exec({
|
|
106
145
|
argv: [
|
|
107
146
|
"/bin/sh",
|
|
108
147
|
"-c",
|
|
109
148
|
// Idempotent: an already-mounted directory is left alone.
|
|
110
|
-
'mkdir -p "$1" && chown
|
|
149
|
+
'mkdir -p "$1" && chown "$2:$3" "$1"; if mountpoint -q "$1"; then exit 0; fi; shift 3; exec "$@"',
|
|
111
150
|
"mount",
|
|
112
151
|
plan.directory,
|
|
113
|
-
|
|
152
|
+
String(owner.uid),
|
|
153
|
+
String(owner.gid),
|
|
154
|
+
...mountS3Argv(plan, owner),
|
|
114
155
|
],
|
|
115
156
|
user: "root",
|
|
116
157
|
env: { AWS_CONFIG_FILE: LEASE_AWS_CONFIG_FILE },
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* terminating the instance this process lives on.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
19
|
import { readFile } from "node:fs/promises";
|
|
20
20
|
|
|
21
21
|
import { buildRemoteCommand } from "../machine-exec";
|
|
@@ -63,6 +63,51 @@ export interface SelfTaskEnvironmentDeps {
|
|
|
63
63
|
/** Whether this process may drop privileges with setpriv (root). Tests and
|
|
64
64
|
* a non-root dev run keep everything as the current user. */
|
|
65
65
|
privileged?: () => boolean;
|
|
66
|
+
/** The machine-wide environment every request runs under (beneath its own
|
|
67
|
+
* `env`). Defaults to /etc/environment — see `readEtcEnvironment`. */
|
|
68
|
+
etcEnvironment?: () => Readonly<Record<string, string>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* /etc/environment, as pam_env reads it: `KEY=value` lines, optional
|
|
73
|
+
* `export`, optional single or double quotes, `#` comments. The machine
|
|
74
|
+
* provider gets these for free — sshd applies pam_env to every session — but
|
|
75
|
+
* a local `setpriv --reset-env … sh -lc` sees neither pam nor the file, so
|
|
76
|
+
* `PLAYWRIGHT_BROWSERS_PATH` from the payload image never reached an agent
|
|
77
|
+
* on the sidecar and every task re-downloaded Chromium (2026-09-10, smoke 6).
|
|
78
|
+
*/
|
|
79
|
+
export function parseEtcEnvironment(text: string): Record<string, string> {
|
|
80
|
+
const out: Record<string, string> = {};
|
|
81
|
+
for (const raw of text.split("\n")) {
|
|
82
|
+
const line = raw.trim();
|
|
83
|
+
if (!line || line.startsWith("#")) continue;
|
|
84
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
85
|
+
if (!match) continue;
|
|
86
|
+
let value = match[2]!.trim();
|
|
87
|
+
if (
|
|
88
|
+
value.length >= 2 &&
|
|
89
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
90
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
91
|
+
) {
|
|
92
|
+
value = value.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
out[match[1]!] = value;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ETC_ENVIRONMENT_PATH = "/etc/environment";
|
|
100
|
+
let etcEnvironmentCache: Record<string, string> | null = null;
|
|
101
|
+
/** The parsed /etc/environment, read once per process (the image writes it
|
|
102
|
+
* at bake time; nothing edits it on a task machine). Empty when absent. */
|
|
103
|
+
export function readEtcEnvironment(): Record<string, string> {
|
|
104
|
+
if (etcEnvironmentCache) return etcEnvironmentCache;
|
|
105
|
+
try {
|
|
106
|
+
etcEnvironmentCache = parseEtcEnvironment(readFileSync(ETC_ENVIRONMENT_PATH, "utf8"));
|
|
107
|
+
} catch {
|
|
108
|
+
etcEnvironmentCache = {};
|
|
109
|
+
}
|
|
110
|
+
return etcEnvironmentCache;
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
export function selfTaskEnvironmentLocator(
|
|
@@ -143,12 +188,14 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
143
188
|
readonly #value: SelfTaskEnvironmentLocatorValue;
|
|
144
189
|
readonly #spawn: typeof nodeSpawn;
|
|
145
190
|
readonly #privileged: () => boolean;
|
|
191
|
+
readonly #etcEnvironment: () => Readonly<Record<string, string>>;
|
|
146
192
|
|
|
147
193
|
constructor(locator: TaskEnvironmentLocator, deps: SelfTaskEnvironmentDeps) {
|
|
148
194
|
this.#value = parseSelfTaskEnvironmentLocator(locator);
|
|
149
195
|
this.#spawn = deps.spawn ?? nodeSpawn;
|
|
150
196
|
this.#privileged =
|
|
151
197
|
deps.privileged ?? (() => typeof process.getuid === "function" && process.getuid() === 0);
|
|
198
|
+
this.#etcEnvironment = deps.etcEnvironment ?? readEtcEnvironment;
|
|
152
199
|
this.durableIdentity = `self:${this.#value.taskId}`;
|
|
153
200
|
this.descriptor = {
|
|
154
201
|
taskId: this.#value.taskId,
|
|
@@ -184,21 +231,34 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
184
231
|
}
|
|
185
232
|
}
|
|
186
233
|
|
|
234
|
+
/** The request with the machine-wide environment beneath its own: what an
|
|
235
|
+
* ssh session on a machine task gets from pam_env, applied here by hand. */
|
|
236
|
+
#withMachineEnv<T extends { env?: Readonly<Record<string, string>> }>(request: T): T {
|
|
237
|
+
const base = this.#etcEnvironment();
|
|
238
|
+
if (Object.keys(base).length === 0) return request;
|
|
239
|
+
return { ...request, env: { ...base, ...request.env } };
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
async exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
188
243
|
assertTaskEnvironmentProcessRequest(request);
|
|
189
|
-
const
|
|
190
|
-
|
|
244
|
+
const scoped = this.#withMachineEnv(request);
|
|
245
|
+
const { command, args } = selfCommand(scoped, "interactive", this.#privileged());
|
|
246
|
+
return capturedCliExec(this.#spawn, command, args, scoped);
|
|
191
247
|
}
|
|
192
248
|
|
|
193
249
|
async spawn(request: TaskEnvironmentSpawnRequest): Promise<TaskEnvironmentProcess> {
|
|
194
250
|
assertTaskEnvironmentProcessRequest(request);
|
|
195
|
-
const
|
|
196
|
-
|
|
251
|
+
const scoped = this.#withMachineEnv(request);
|
|
252
|
+
const { command, args } = selfCommand(scoped, "interactive", this.#privileged());
|
|
253
|
+
return streamingCliExec(this.#spawn, command, args, scoped);
|
|
197
254
|
}
|
|
198
255
|
|
|
199
256
|
async spawnSession(request: TaskEnvironmentSessionRequest): Promise<TaskEnvironmentProcess> {
|
|
200
257
|
assertTaskEnvironmentSessionRequest(request);
|
|
201
|
-
const session =
|
|
258
|
+
const session = this.#withMachineEnv({
|
|
259
|
+
...request,
|
|
260
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
261
|
+
});
|
|
202
262
|
const { command, args } = selfCommand(session, "interactive", this.#privileged());
|
|
203
263
|
return streamingCliExec(this.#spawn, command, args, session);
|
|
204
264
|
}
|
|
@@ -207,7 +267,10 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
207
267
|
request: TaskEnvironmentDetachedSessionRequest,
|
|
208
268
|
): Promise<TaskEnvironmentExecResult> {
|
|
209
269
|
assertTaskEnvironmentSessionRequest(request);
|
|
210
|
-
const session =
|
|
270
|
+
const session = this.#withMachineEnv({
|
|
271
|
+
...request,
|
|
272
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
273
|
+
});
|
|
211
274
|
const { command, args } = selfCommand(session, "detached", this.#privileged());
|
|
212
275
|
return capturedCliExec(this.#spawn, command, args, {
|
|
213
276
|
...session,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
isParameterPathSegment,
|
|
24
24
|
type CredentialStore,
|
|
25
25
|
} from "./credential-store";
|
|
26
|
+
import { projectEnvListPath, userCredentialListPath } from "./credential-store-scope";
|
|
26
27
|
import {
|
|
27
28
|
deleteToken as deleteGithubTokenLocal,
|
|
28
29
|
githubTokenParameterName,
|
|
@@ -196,7 +197,8 @@ export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUser
|
|
|
196
197
|
const store = configuredCredentialStore();
|
|
197
198
|
if (!store) return { ...result, skipped: true };
|
|
198
199
|
const prefix = credentialStorePrefix();
|
|
199
|
-
const path =
|
|
200
|
+
const path = userCredentialListPath(prefix);
|
|
201
|
+
if (path === null) return { ...result, skipped: true };
|
|
200
202
|
const warn = (line: string): void =>
|
|
201
203
|
console.warn(`[user-credentials] credential store: ${line}`);
|
|
202
204
|
|
|
@@ -359,7 +361,8 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
|
|
|
359
361
|
const store = configuredCredentialStore();
|
|
360
362
|
if (!store) return { ...result, skipped: true };
|
|
361
363
|
const prefix = credentialStorePrefix();
|
|
362
|
-
const path =
|
|
364
|
+
const path = projectEnvListPath(prefix);
|
|
365
|
+
if (path === null) return { ...result, skipped: true };
|
|
363
366
|
const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
|
|
364
367
|
let listed: Array<{ name: string; value: string }>;
|
|
365
368
|
try {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -169,6 +169,13 @@ async function main(): Promise<void> {
|
|
|
169
169
|
return cmdInstaller(cmd, dryRun);
|
|
170
170
|
case "uninstall":
|
|
171
171
|
return cmdUninstall(rest);
|
|
172
|
+
case "version":
|
|
173
|
+
case "-v":
|
|
174
|
+
case "--version":
|
|
175
|
+
// A bare version line: the sidecar's prepare step compares it with the
|
|
176
|
+
// pinned runner version, and any other flag prints the usage instead.
|
|
177
|
+
console.log(packageVersion());
|
|
178
|
+
return;
|
|
172
179
|
case undefined:
|
|
173
180
|
case "help":
|
|
174
181
|
case "-h":
|
package/src/main.ts
CHANGED
|
@@ -2724,7 +2724,9 @@ function isTaskLease(value: unknown): value is TaskLease {
|
|
|
2724
2724
|
typeof files === "object" &&
|
|
2725
2725
|
typeof files.bucket === "string" &&
|
|
2726
2726
|
(files.orgPrefix === null || typeof files.orgPrefix === "string") &&
|
|
2727
|
-
typeof files.userPrefix === "string"
|
|
2727
|
+
typeof files.userPrefix === "string" &&
|
|
2728
|
+
(lease.orgId === undefined || lease.orgId === null || typeof lease.orgId === "string") &&
|
|
2729
|
+
(lease.userId === undefined || typeof lease.userId === "string")
|
|
2728
2730
|
);
|
|
2729
2731
|
}
|
|
2730
2732
|
|
package/src/protocol.ts
CHANGED
|
@@ -1722,6 +1722,13 @@ export interface TaskLease {
|
|
|
1722
1722
|
orgPrefix: string | null;
|
|
1723
1723
|
userPrefix: string;
|
|
1724
1724
|
};
|
|
1725
|
+
/** The org and user the session is tagged with — the only credential
|
|
1726
|
+
* store paths the sidecar may list (`/uai/orgs/<org>`, `/uai/users/<user>`).
|
|
1727
|
+
* Optional on the wire: a cloud that predates the field sends neither, and
|
|
1728
|
+
* the sidecar then skips the store instead of listing paths the policy
|
|
1729
|
+
* denies. */
|
|
1730
|
+
orgId?: string | null;
|
|
1731
|
+
userId?: string;
|
|
1725
1732
|
}
|
|
1726
1733
|
|
|
1727
1734
|
export type McpOp =
|