@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.
@@ -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 hostId = engineAccountHostId();
49
- const paths = [`${prefix}/orgs`, ...(hostId ? [`${prefix}/hosts/${hostId}`] : [])];
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
+ }
@@ -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 = [`${prefix}/orgs`];
1456
- if (hostId && isParameterPathSegment(hostId)) paths.push(`${prefix}/hosts/${hostId}`);
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) {
@@ -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` helper reads the live session. Root-only:
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
- /** The helper itself — a shell script so it needs nothing but `cat`. */
39
- export const LEASE_CREDENTIAL_PROCESS = "/run/uai/lease-credential-process";
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
 
@@ -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
- export const SHARED_FILES_UID = 1000;
21
- export const SHARED_FILES_GID = 1000;
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(SHARED_FILES_UID),
83
+ String(owner.uid),
46
84
  "--gid",
47
- String(SHARED_FILES_GID),
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 1000:1000 "$1"; if mountpoint -q "$1"; then exit 0; fi; shift; exec "$@"',
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
- ...mountS3Argv(plan),
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 { command, args } = selfCommand(request, "interactive", this.#privileged());
190
- return capturedCliExec(this.#spawn, command, args, request);
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 { command, args } = selfCommand(request, "interactive", this.#privileged());
196
- return streamingCliExec(this.#spawn, command, args, request);
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 = { ...request, cwd: request.cwd ?? this.#value.workspacePath };
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 = { ...request, cwd: request.cwd ?? this.#value.workspacePath };
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 = `${prefix}/users`;
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 = `${prefix}/projects`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.127",
3
+ "version": "0.9.128",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
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 =