@runuai/host 0.9.125 → 0.9.128
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/github-tokens.ts +8 -0
- package/lib/lease.ts +29 -14
- 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 +44 -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/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);
|
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/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
|
@@ -243,6 +243,7 @@ import {
|
|
|
243
243
|
type TaskDiffInput,
|
|
244
244
|
type TaskDownInput,
|
|
245
245
|
type TaskLaunchInput,
|
|
246
|
+
type TaskLease,
|
|
246
247
|
type FilesOpInput,
|
|
247
248
|
engineAccountScopeFromWire,
|
|
248
249
|
isEngineAccountLabel,
|
|
@@ -1272,7 +1273,6 @@ async function connect(): Promise<void> {
|
|
|
1272
1273
|
reconnectAttempt = 0;
|
|
1273
1274
|
markConnected();
|
|
1274
1275
|
console.log(`[host-agent] connected as ${hostId}`);
|
|
1275
|
-
leaseManager?.reconnected();
|
|
1276
1276
|
setHostObsTag(hostId);
|
|
1277
1277
|
addHostBreadcrumb("bridge", "connected");
|
|
1278
1278
|
|
|
@@ -1322,6 +1322,12 @@ async function connect(): Promise<void> {
|
|
|
1322
1322
|
case "pong":
|
|
1323
1323
|
break;
|
|
1324
1324
|
case "event.resume":
|
|
1325
|
+
// ADR-133: the cloud's first frame after a verified auth. A lease
|
|
1326
|
+
// request sent any earlier lands in the bridge's pre-verdict window,
|
|
1327
|
+
// where only auth/ping/result frames survive (live 2026-09-10,
|
|
1328
|
+
// hostless smoke 5: the first request vanished, the machine sat
|
|
1329
|
+
// three minutes without credentials).
|
|
1330
|
+
leaseManager?.reconnected();
|
|
1325
1331
|
// ADR-103: the cloud named its watermark — rewind the transmit
|
|
1326
1332
|
// cursor to it (null = no state, replay nothing) and start draining.
|
|
1327
1333
|
if (resumeFallbackTimer) {
|
|
@@ -2704,6 +2710,26 @@ function expectNumberArg(args: unknown[], index: number): number {
|
|
|
2704
2710
|
return value;
|
|
2705
2711
|
}
|
|
2706
2712
|
|
|
2713
|
+
function isTaskLease(value: unknown): value is TaskLease {
|
|
2714
|
+
if (!value || typeof value !== "object") return false;
|
|
2715
|
+
const lease = value as Record<string, unknown>;
|
|
2716
|
+
const files = lease.files as Record<string, unknown> | undefined;
|
|
2717
|
+
return (
|
|
2718
|
+
typeof lease.accessKeyId === "string" &&
|
|
2719
|
+
typeof lease.secretAccessKey === "string" &&
|
|
2720
|
+
typeof lease.sessionToken === "string" &&
|
|
2721
|
+
typeof lease.expiresAt === "number" &&
|
|
2722
|
+
typeof lease.region === "string" &&
|
|
2723
|
+
!!files &&
|
|
2724
|
+
typeof files === "object" &&
|
|
2725
|
+
typeof files.bucket === "string" &&
|
|
2726
|
+
(files.orgPrefix === null || typeof files.orgPrefix === "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")
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2707
2733
|
function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
2708
2734
|
let parsed: unknown;
|
|
2709
2735
|
try {
|
|
@@ -2931,6 +2957,23 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
2931
2957
|
) {
|
|
2932
2958
|
return { kind: "mcp.op", opId: frame.opId, op: frame.op };
|
|
2933
2959
|
}
|
|
2960
|
+
// ADR-133: the cloud's answers to a sidecar's lease request. Without these
|
|
2961
|
+
// two clauses the sidecar logs "dropping unknown frame kind=lease.grant"
|
|
2962
|
+
// and the task dies after the 3-minute credentials gate.
|
|
2963
|
+
if (
|
|
2964
|
+
frame.kind === "lease.grant" &&
|
|
2965
|
+
typeof frame.opId === "string" &&
|
|
2966
|
+
isTaskLease(frame.lease)
|
|
2967
|
+
) {
|
|
2968
|
+
return { kind: "lease.grant", opId: frame.opId, lease: frame.lease };
|
|
2969
|
+
}
|
|
2970
|
+
if (
|
|
2971
|
+
frame.kind === "lease.deny" &&
|
|
2972
|
+
typeof frame.opId === "string" &&
|
|
2973
|
+
typeof frame.error === "string"
|
|
2974
|
+
) {
|
|
2975
|
+
return { kind: "lease.deny", opId: frame.opId, error: frame.error };
|
|
2976
|
+
}
|
|
2934
2977
|
// Names only — never payloads (frames can carry tokens).
|
|
2935
2978
|
console.warn(
|
|
2936
2979
|
`[host-agent] dropping unknown frame kind=${String(frame.kind)}` +
|
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 =
|