@runuai/host 0.9.127 → 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 +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/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
|
@@ -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 =
|