@runuai/host 0.9.131 → 0.9.133

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.
@@ -0,0 +1,4 @@
1
+ -- ADR-133: the project's org, so the mirrored parameter can carry the `org`
2
+ -- tag the task-machine lease policy conditions on. Null until the cloud sends
3
+ -- it (env.var.set / env.var.list with orgId) or a stored value carries it.
4
+ ALTER TABLE `host_project_env` ADD `org_id` text;
@@ -162,6 +162,13 @@
162
162
  "when": 1788900000004,
163
163
  "tag": "0022_task_project_ids",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "6",
169
+ "when": 1788900000005,
170
+ "tag": "0023_project_env_org",
171
+ "breakpoints": true
165
172
  }
166
173
  ]
167
174
  }
package/db/schema.ts CHANGED
@@ -188,6 +188,11 @@ export const hostProjectEnv = sqliteTable(
188
188
  .default(sql`(unixepoch() * 1000)`),
189
189
  // ADR-134 slice 3: the credential-store parameter this row mirrors to.
190
190
  storeRef: text("store_ref"),
191
+ // The project's org, learned from an env.var.set/list frame that carries
192
+ // it (cloud from 2026-09-10) or from a stored value that does. It tags the
193
+ // mirrored parameter (`org`, `project`) so a task machine's tag-scoped
194
+ // lease can read it (ADR-133). Null until the cloud has said.
195
+ orgId: text("org_id"),
191
196
  },
192
197
  (t) => ({
193
198
  pk: primaryKey({ columns: [t.projectId, t.key] }),
@@ -1035,9 +1035,13 @@ console.log(
1035
1035
  */
1036
1036
  const VIEWER_STEPS: string[] = [
1037
1037
  // Xvfb — one-shot; the X lockfile is the truth (also set when an agent
1038
- // started the display itself). If it dies the lock clears and the next
1039
- // session ensure relaunches it.
1040
- `[ -x /usr/bin/Xvfb ] || exit 0; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
1038
+ // started the display itself). If it dies cleanly the lock clears and the
1039
+ // next session ensure relaunches it. A lock whose pid is DEAD is the
1040
+ // leftover of a SIGKILLed server — a sidecar restart kills its whole
1041
+ // cgroup (live 2026-09-10, smoke 10: Xvfb gone, lock present, x11vnc
1042
+ // looping on a display that never came back) — so clear it and the
1043
+ // socket first, then launch as usual.
1044
+ `[ -x /usr/bin/Xvfb ] || exit 0; if [ -e ${X_LOCK} ] && ! /usr/bin/kill -0 "$(/usr/bin/tr -d ' \\n' < ${X_LOCK})" 2>/dev/null; then /bin/rm -f ${X_LOCK} /tmp/.X11-unix/X${SERVER_DISPLAY.slice(1)}; fi; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
1041
1045
  // x11vnc under a restart loop (it exits whenever the X server isn't up
1042
1046
  // yet). Pidfile-guarded.
1043
1047
  `[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
@@ -73,13 +73,40 @@ export function userCredentialListPath(prefix: string): string | null {
73
73
  return `${prefix}/users/${scope.userId}`;
74
74
  }
75
75
 
76
- /** The path a project-env listing walks, or null to skip. */
77
- export function projectEnvListPath(prefix: string): string | null {
76
+ /**
77
+ * The paths a project-env listing walks, or null to skip. An owned or shared
78
+ * host walks the whole tree. A task machine's lease reads
79
+ * `/uai/projects/<id>/…` only for parameters tagged with its org (the policy
80
+ * conditions on `ssm:ResourceTag/org`), and only the task's own projects are
81
+ * worth asking for — their ids arrive with task-up, not with the first grant,
82
+ * so a listing without ids on a task machine is simply "nothing yet".
83
+ */
84
+ export function projectEnvListPaths(
85
+ prefix: string,
86
+ projectIds?: readonly string[],
87
+ ): string[] | null {
78
88
  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;
89
+ if (scope.kind === "host") return [`${prefix}/projects`];
90
+ if (scope.kind === "none") {
91
+ warnOnce("project-env", `not read: ${scope.reason}`);
92
+ return null;
93
+ }
94
+ if (!scope.orgId) {
95
+ warnOnce("project-env", "not read on a task machine whose lease carries no org");
96
+ return null;
97
+ }
98
+ if (!projectIds) return null;
99
+ return projectIds
100
+ .filter((id) => isParameterPathSegment(id))
101
+ .map((id) => `${prefix}/projects/${id}`);
102
+ }
103
+
104
+ /**
105
+ * Whether project env is read by name (describe + get-parameter) instead of
106
+ * by path: true under a lease, whose policy conditions on each parameter's
107
+ * `org` tag and therefore denies the path listing itself (see
108
+ * `CredentialStore.listByName`).
109
+ */
110
+ export function projectEnvListsByName(): boolean {
111
+ return storeListScope().kind === "lease";
85
112
  }
@@ -46,6 +46,21 @@ export interface CredentialStore {
46
46
  delete(name: string): Promise<void>;
47
47
  /** Every credential under `path` (recursive), decrypted. Throws on failure. */
48
48
  list(path: string): Promise<StoredParameter[]>;
49
+ /**
50
+ * Every credential under `path` read by NAME: names from
51
+ * `describe-parameters` (allowed on `*`, never returns values), then one
52
+ * `get-parameter` each, decrypted; a name the identity may not read is
53
+ * skipped. This is how a task machine's lease reads `/uai/projects/<id>`:
54
+ * its policy conditions on the parameter's `org` tag, and
55
+ * `get-parameters-by-path` is authorised against the PATH resource, which
56
+ * carries no tags, so it is denied even when every parameter under it is
57
+ * tagged (live 2026-09-10, smoke 12).
58
+ */
59
+ listByName(path: string): Promise<StoredParameter[]>;
60
+ /** Set tags on an existing parameter (a second call, because put-parameter
61
+ * with Overwrite cannot carry tags). Throws on failure — including when
62
+ * the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
63
+ tag(name: string, tags: Record<string, string>): Promise<void>;
49
64
  /** Names and versions under `path` (recursive), NO decryption — the cheap
50
65
  * poll a Uai-operated host runs to notice a broker refresh. */
51
66
  listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
@@ -136,6 +151,10 @@ function isNotFound(result: CliResult): boolean {
136
151
  return /ParameterNotFound/.test(result.stderr);
137
152
  }
138
153
 
154
+ function isAccessDenied(result: CliResult): boolean {
155
+ return /AccessDenied/.test(result.stderr);
156
+ }
157
+
139
158
  /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
140
159
  function failure(op: string, result: CliResult): Error {
141
160
  const line =
@@ -181,6 +200,28 @@ export class AwsSsmCredentialStore implements CredentialStore {
181
200
  if (result.status !== 0) throw failure("put-parameter", result);
182
201
  }
183
202
 
203
+ async tag(name: string, tags: Record<string, string>): Promise<void> {
204
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
205
+ const entries = Object.entries(tags);
206
+ if (entries.length === 0) return;
207
+ for (const [key, value] of entries) {
208
+ if (!/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) || !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)) {
209
+ throw new Error(`invalid tag ${key}`);
210
+ }
211
+ }
212
+ const result = await this.run([
213
+ "ssm",
214
+ "add-tags-to-resource",
215
+ "--resource-type",
216
+ "Parameter",
217
+ "--resource-id",
218
+ name,
219
+ "--tags",
220
+ ...entries.map(([key, value]) => `Key=${key},Value=${value}`),
221
+ ]);
222
+ if (result.status !== 0) throw failure("add-tags-to-resource", result);
223
+ }
224
+
184
225
  async get(name: string): Promise<string | null> {
185
226
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
186
227
  const result = await this.run([
@@ -243,6 +284,36 @@ export class AwsSsmCredentialStore implements CredentialStore {
243
284
  return out;
244
285
  }
245
286
 
287
+ async listByName(path: string): Promise<StoredParameter[]> {
288
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
289
+ const described = await this.run([
290
+ "ssm",
291
+ "describe-parameters",
292
+ "--parameter-filters",
293
+ `Key=Path,Option=Recursive,Values=${path}`,
294
+ "--query",
295
+ "Parameters[].Name",
296
+ ]);
297
+ if (described.status !== 0) throw failure("describe-parameters", described);
298
+ const parsed: unknown = JSON.parse(described.stdout || "[]");
299
+ const names = (Array.isArray(parsed) ? parsed : []).filter(
300
+ (name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
301
+ );
302
+ const out: StoredParameter[] = [];
303
+ for (const name of names) {
304
+ const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
305
+ if (result.status !== 0) {
306
+ // Not tagged for this identity (or gone between the two calls):
307
+ // invisible, not an error — the listing is what the lease may read.
308
+ if (isAccessDenied(result) || isNotFound(result)) continue;
309
+ throw failure("get-parameter", result);
310
+ }
311
+ const value = (JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } }).Parameter?.Value;
312
+ if (typeof value === "string") out.push({ name, value });
313
+ }
314
+ return out;
315
+ }
316
+
246
317
  async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
247
318
  if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
248
319
  const out: Array<{ name: string; version: number }> = [];
package/lib/host-env.ts CHANGED
@@ -14,13 +14,14 @@
14
14
  * sealed value as "<ct-base64>.<nonce-base64>".
15
15
  */
16
16
 
17
- import { and, eq } from "drizzle-orm";
17
+ import { and, eq, isNotNull } from "drizzle-orm";
18
18
 
19
19
  import {
20
20
  credentialStorePrefix,
21
21
  isParameterPathSegment,
22
22
  parameterName,
23
23
  queueStoreWrite,
24
+ type CredentialStore,
24
25
  } from "./credential-store";
25
26
  import { getDb, schema } from "./db";
26
27
  import { sealAesGcm, openAesGcm } from "./secrets";
@@ -40,23 +41,111 @@ function unpackValue(valueEnc: string): string {
40
41
  return openAesGcm(ct, nonce);
41
42
  }
42
43
 
43
- /** Set (insert or overwrite) a project env var, encrypting the value at rest. */
44
+ /** Set (insert or overwrite) a project env var, encrypting the value at rest.
45
+ * `orgId` is the project's org when the cloud sent it (see `noteProjectOrg`). */
44
46
  export function setProjectEnvVar(
45
47
  projectId: string,
46
48
  key: string,
47
49
  value: string,
50
+ orgId?: string,
48
51
  ): void {
52
+ const now = Date.now();
49
53
  getDb()
50
54
  .insert(schema.hostProjectEnv)
51
- .values({ projectId, key, valueEnc: packValue(value), updatedAt: Date.now() })
55
+ .values({
56
+ projectId,
57
+ key,
58
+ valueEnc: packValue(value),
59
+ updatedAt: now,
60
+ ...(orgId ? { orgId } : {}),
61
+ })
52
62
  .onConflictDoUpdate({
53
63
  target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
54
- set: { valueEnc: packValue(value), updatedAt: Date.now() },
64
+ set: { valueEnc: packValue(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
55
65
  })
56
66
  .run();
67
+ if (orgId) noteProjectOrg(projectId, orgId, { except: key });
57
68
  mirrorProjectEnvToStore(projectId, key);
58
69
  }
59
70
 
71
+ /**
72
+ * The cloud told us which org owns `projectId` (an env.var.set/list frame
73
+ * carrying `orgId`). Every row of that project mirrored before the org was
74
+ * known gets the org and is re-tagged in the store, so a task machine's
75
+ * tag-scoped lease can read it (ADR-133). Returns the rows updated.
76
+ */
77
+ export function noteProjectOrg(
78
+ projectId: string,
79
+ orgId: string,
80
+ opts: { except?: string } = {},
81
+ ): number {
82
+ const rows = getDb()
83
+ .select({ key: schema.hostProjectEnv.key, orgId: schema.hostProjectEnv.orgId, storeRef: schema.hostProjectEnv.storeRef })
84
+ .from(schema.hostProjectEnv)
85
+ .where(eq(schema.hostProjectEnv.projectId, projectId))
86
+ .all()
87
+ .filter((row) => row.key !== opts.except && row.orgId !== orgId);
88
+ for (const row of rows) {
89
+ getDb()
90
+ .update(schema.hostProjectEnv)
91
+ .set({ orgId })
92
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, row.key)))
93
+ .run();
94
+ if (row.storeRef) {
95
+ const name = row.storeRef;
96
+ queueStoreWrite(`tag project env ${projectId}/${row.key}`, async (store) => {
97
+ await tagProjectEnvParameter(store, name, projectId, orgId);
98
+ });
99
+ }
100
+ }
101
+ return rows.length;
102
+ }
103
+
104
+ /** The org recorded for a project, or null while the cloud has not said. */
105
+ export function projectEnvOrgId(projectId: string): string | null {
106
+ const row = getDb()
107
+ .select({ orgId: schema.hostProjectEnv.orgId })
108
+ .from(schema.hostProjectEnv)
109
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), isNotNull(schema.hostProjectEnv.orgId)))
110
+ .get();
111
+ return row?.orgId ?? null;
112
+ }
113
+
114
+ /** The tags a project env parameter carries: the lease policy's `org`, plus
115
+ * `project` for operators reading the store. */
116
+ export function projectEnvTags(projectId: string, orgId: string): Record<string, string> {
117
+ return { org: orgId, project: projectId };
118
+ }
119
+
120
+ const tagWarned = new Set<string>();
121
+ /**
122
+ * Tag a mirrored parameter for the task-machine lease. Best-effort: the
123
+ * value is already in the store, and a host whose policy predates
124
+ * `ssm:AddTagsToResource` (host-agent/scripts/credential-store/
125
+ * bootstrap-aws.sh) must keep mirroring; it warns once per parameter and a
126
+ * later set/list retries.
127
+ */
128
+ export async function tagProjectEnvParameter(
129
+ store: Pick<CredentialStore, "tag">,
130
+ name: string,
131
+ projectId: string,
132
+ orgId: string,
133
+ ): Promise<boolean> {
134
+ try {
135
+ await store.tag(name, projectEnvTags(projectId, orgId));
136
+ tagWarned.delete(name);
137
+ return true;
138
+ } catch (err) {
139
+ if (!tagWarned.has(name)) {
140
+ tagWarned.add(name);
141
+ console.warn(
142
+ `[project-env] credential store: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a task machine cannot read it until it carries org=${orgId}; re-run bootstrap-aws.sh for ssm:AddTagsToResource`,
143
+ );
144
+ }
145
+ return false;
146
+ }
147
+ }
148
+
60
149
  /** Remove a project env var (idempotent). */
61
150
  export function deleteProjectEnvVar(projectId: string, key: string): void {
62
151
  const result = getDb()
@@ -87,6 +176,9 @@ export interface StoredProjectEnv {
87
176
  key: string;
88
177
  value: string;
89
178
  updatedAt: number;
179
+ /** The project's org once the cloud has said (ADR-133 tag scope); a
180
+ * replacement host inherits it with the value. */
181
+ orgId?: string;
90
182
  }
91
183
 
92
184
  /** Null when the project id or key cannot be a parameter path segment (a
@@ -111,6 +203,7 @@ export function projectEnvStoreValue(projectId: string, key: string): StoredProj
111
203
  key,
112
204
  value: unpackValue(row.valueEnc),
113
205
  updatedAt: row.updatedAt,
206
+ ...(row.orgId ? { orgId: row.orgId } : {}),
114
207
  };
115
208
  }
116
209
 
@@ -141,6 +234,7 @@ export function mirrorProjectEnvToStore(projectId: string, key: string): void {
141
234
  if (!value) return;
142
235
  await store.put(name, JSON.stringify(value));
143
236
  setProjectEnvStoreRef(projectId, key, name);
237
+ if (value.orgId) await tagProjectEnvParameter(store, name, projectId, value.orgId);
144
238
  });
145
239
  }
146
240
 
@@ -154,6 +248,7 @@ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: str
154
248
  valueEnc: packValue(value.value),
155
249
  updatedAt: value.updatedAt,
156
250
  storeRef,
251
+ ...(value.orgId ? { orgId: value.orgId } : {}),
157
252
  })
158
253
  .onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
159
254
  .run();
@@ -145,8 +145,15 @@ export async function mountSharedFilesFromLease(
145
145
  argv: [
146
146
  "/bin/sh",
147
147
  "-c",
148
- // Idempotent: an already-mounted directory is left alone.
149
- 'mkdir -p "$1" && chown "$2:$3" "$1"; if mountpoint -q "$1"; then exit 0; fi; shift 3; exec "$@"',
148
+ // Idempotent: a healthy mount is left alone. A mount whose daemon is
149
+ // gone still shows as mounted but every access fails with "Transport
150
+ // endpoint is not connected" (a sidecar restart SIGKILLs mount-s3 —
151
+ // live 2026-09-10, smoke 10); it is lazily unmounted and mounted
152
+ // again. The mkdir/chown tolerate the broken mount too.
153
+ 'mkdir -p "$1" 2>/dev/null; chown "$2:$3" "$1" 2>/dev/null; ' +
154
+ 'if mountpoint -q "$1" 2>/dev/null; then if ls "$1" >/dev/null 2>&1; then exit 0; fi; ' +
155
+ 'umount -l "$1" 2>/dev/null || true; chown "$2:$3" "$1" 2>/dev/null; fi; ' +
156
+ 'shift 3; exec "$@"',
150
157
  "mount",
151
158
  plan.directory,
152
159
  String(owner.uid),
@@ -168,9 +175,11 @@ export async function mountSharedFilesFromLease(
168
175
  }
169
176
  if (mountedAny) {
170
177
  // The same marker the container and machine paths write: it gates the
171
- // "## Shared files" briefing in every agent's preamble.
178
+ // "## Shared files" briefing in every agent's preamble. The sidecar
179
+ // writes the MODE into it so a restart can mount again the same way
180
+ // (lib/sidecar-resume.ts); readers only test for the file.
172
181
  await handle
173
- .writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(""))
182
+ .writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(mode))
174
183
  .catch(() => {});
175
184
  }
176
185
  return warnings.length > 0 ? warnings.join("\n") : null;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ADR-133: what a sidecar re-asserts when it starts on a machine whose task
3
+ * is ALREADY up — a restart of the unit, not a fresh launch.
4
+ *
5
+ * Task-up mounts the shared files and launches the editor once; nothing ran
6
+ * them again when the runner came back, so an operator's restart (smoke 10,
7
+ * 2026-09-10) left /workspace/files as two dead endpoints and the Editor tab
8
+ * empty for the rest of the task. The unit now keeps those processes alive
9
+ * across a stop (KillMode=process), and this pass covers what that cannot:
10
+ * a crash, an older unit, a SIGKILL. Both steps are idempotent — a healthy
11
+ * mount and a live code-server are left alone — so running it on every
12
+ * lease-ready costs one stat each.
13
+ */
14
+
15
+ import { eq } from "drizzle-orm";
16
+
17
+ import * as schema from "../db/schema";
18
+ import { getDb } from "./db";
19
+ import { mountSharedFilesFromLease } from "./shared-files-s3";
20
+ import { sidecarTaskId } from "./sidecar";
21
+ import { reconstructPersistedTaskEnvironment } from "./task-environment";
22
+ import { ensureCodeServer } from "./task-environment/machine-task-up";
23
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
24
+
25
+ type PersistedTask = typeof schema.hostTasks.$inferSelect;
26
+
27
+ export interface SidecarResumeDeps {
28
+ taskId?: () => string | null;
29
+ persistedTask?: (taskId: string) => PersistedTask | undefined;
30
+ reconstruct?: (task: PersistedTask) => Promise<TaskEnvironmentHandle<unknown> | null>;
31
+ mount?: (
32
+ handle: TaskEnvironmentHandle<unknown>,
33
+ workspacePath: string,
34
+ task: { sharedFiles?: string },
35
+ ) => Promise<string | null>;
36
+ editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
37
+ }
38
+
39
+ export interface SidecarResumeResult {
40
+ /** `no-task`: not a sidecar. `not-up`: the task has not been provisioned on
41
+ * this machine yet (a fresh launch — task-up does the work). `resumed`: the
42
+ * task was up before this runner started; its mounts and editor were
43
+ * re-asserted, with any per-step warning listed. */
44
+ outcome: "no-task" | "not-up" | "resumed";
45
+ warnings: string[];
46
+ }
47
+
48
+ /** The mode task-up mounted with, recorded in the files-mounted marker; null
49
+ * when the files were never mounted (mode off, or the mount failed then). */
50
+ function mountedModeFromMarker(bytes: Uint8Array | null): "ro" | "rw" | null {
51
+ if (bytes === null) return null;
52
+ return Buffer.from(bytes).toString("utf8").trim() === "rw" ? "rw" : "ro";
53
+ }
54
+
55
+ export async function resumeSidecarTask(
56
+ deps: SidecarResumeDeps = {},
57
+ ): Promise<SidecarResumeResult> {
58
+ const taskId = (deps.taskId ?? sidecarTaskId)();
59
+ if (!taskId) return { outcome: "no-task", warnings: [] };
60
+ const task = (deps.persistedTask ?? defaultPersistedTask)(taskId);
61
+ if (!task?.environmentLocator) return { outcome: "not-up", warnings: [] };
62
+ const handle = await (deps.reconstruct ?? reconstructPersistedTaskEnvironment)(task);
63
+ if (!handle) return { outcome: "not-up", warnings: [] };
64
+ const workspacePath = handle.descriptor.workspacePath;
65
+ const warnings: string[] = [];
66
+
67
+ const marker = await handle
68
+ .readWorkspaceFile(`${workspacePath}/.uai/files-mounted`)
69
+ .catch(() => null);
70
+ const mode = mountedModeFromMarker(marker);
71
+ if (mode) {
72
+ try {
73
+ const warning = await (deps.mount ?? mountSharedFilesFromLease)(handle, workspacePath, {
74
+ sharedFiles: mode,
75
+ });
76
+ if (warning) warnings.push(warning);
77
+ } catch (error) {
78
+ warnings.push(
79
+ `shared files: ${error instanceof Error ? error.message : String(error)}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ try {
85
+ await (deps.editor ?? defaultEditor)(handle, workspacePath);
86
+ } catch (error) {
87
+ warnings.push(`editor: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
89
+ return { outcome: "resumed", warnings };
90
+ }
91
+
92
+ function defaultPersistedTask(taskId: string): PersistedTask | undefined {
93
+ return getDb()
94
+ .select()
95
+ .from(schema.hostTasks)
96
+ .where(eq(schema.hostTasks.taskId, taskId))
97
+ .get();
98
+ }
99
+
100
+ function defaultEditor(
101
+ handle: TaskEnvironmentHandle<unknown>,
102
+ workspacePath: string,
103
+ ): Promise<void> {
104
+ // The theme rode task-up's preview env and is not persisted; the settings
105
+ // seed is skipped when the file already exists, so an existing task keeps
106
+ // the theme it started with.
107
+ return ensureCodeServer(handle, workspacePath, undefined);
108
+ }
package/lib/skills.ts CHANGED
@@ -203,6 +203,34 @@ const dockerExec: SkillExec = async (container, cmd, cwd) => {
203
203
  return { status: res.status, stderr: res.stderr };
204
204
  };
205
205
 
206
+ /**
207
+ * A `skills add …` install command (github.com/vercel-labs/skills, the CLI
208
+ * behind the skills marketplace) asks which agents to install for and for a
209
+ * confirmation unless an agent is detected or `-y`/`--agent` are given. The
210
+ * install runs at task-up with no TTY and, on a fresh machine, before any
211
+ * agent has left its footprint, so the CLI refused: "Interactive prompt
212
+ * required but stdin is not a TTY … Use --agent <name> and -y" (every task
213
+ * start since the Marketing persona gained the skill; live 2026-09-10).
214
+ * Insert the two flags right after `skills add` when the author left them
215
+ * out — after the subcommand, so a trailing `&& …` or a leading `cd …`
216
+ * keeps working. Any other install command is returned as written.
217
+ */
218
+ export function nonInteractiveInstallCommand(command: string): string {
219
+ const add = /\bskills\s+add\b/;
220
+ const found = add.exec(command);
221
+ if (!found) return command;
222
+ // Only the flags AFTER the subcommand count: `npx -y skills add …` is npx's
223
+ // own -y, not the CLI's.
224
+ const rest = command.slice(found.index + found[0].length);
225
+ const flags: string[] = [];
226
+ if (!/(^|\s)(-y|--yes)(\s|$)/.test(rest)) flags.push("-y");
227
+ if (!/(^|\s)(-a|--agent)(\s|=)/.test(rest)) flags.push("--agent claude-code");
228
+ if (flags.length === 0) return command;
229
+ return (
230
+ command.slice(0, found.index + found[0].length) + ` ${flags.join(" ")}` + rest
231
+ );
232
+ }
233
+
206
234
  /**
207
235
  * Install a roster's package skills into the task container (ADR-047). Runs at
208
236
  * task-up (after the container is running), mirroring the git-identity/github
@@ -266,7 +294,11 @@ export async function installPackageSkills(
266
294
  continue;
267
295
  }
268
296
  // The author's CLI drops files into .claude/skills; run it in the repo.
269
- const c = await exec(container, ["bash", "-lc", s.value], "/workspace");
297
+ const c = await exec(
298
+ container,
299
+ ["bash", "-lc", nonInteractiveInstallCommand(s.value)],
300
+ "/workspace",
301
+ );
270
302
  if (c.status !== 0) {
271
303
  console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
272
304
  continue;
@@ -388,7 +388,7 @@ async function copySharedFiles(
388
388
  /** Launch code-server for the Editor pane, exactly once. The pgrep guard
389
389
  * makes re-provision and resume idempotent; the exec chain leaves only
390
390
  * code-server holding the detached job. */
391
- async function ensureCodeServer(
391
+ export async function ensureCodeServer(
392
392
  handle: TaskEnvironmentHandle<unknown>,
393
393
  workspacePath: string,
394
394
  editorTheme: string | undefined,
@@ -23,7 +23,8 @@ import {
23
23
  isParameterPathSegment,
24
24
  type CredentialStore,
25
25
  } from "./credential-store";
26
- import { projectEnvListPath, userCredentialListPath } from "./credential-store-scope";
26
+ import { projectEnvListPaths,
27
+ projectEnvListsByName, userCredentialListPath } from "./credential-store-scope";
27
28
  import {
28
29
  deleteToken as deleteGithubTokenLocal,
29
30
  githubTokenParameterName,
@@ -49,6 +50,8 @@ import {
49
50
  projectEnvParameterName,
50
51
  projectEnvStoreValue,
51
52
  setProjectEnvStoreRef,
53
+ noteProjectOrg,
54
+ tagProjectEnvParameter,
52
55
  type StoredProjectEnv,
53
56
  } from "./host-env";
54
57
  import {
@@ -347,10 +350,18 @@ export function parseStoredProjectEnv(value: string): StoredProjectEnv {
347
350
  key: str(o, "key"),
348
351
  value: str(o, "value"),
349
352
  updatedAt: num(o, "updatedAt"),
353
+ ...(typeof o.orgId === "string" && o.orgId.length > 0 ? { orgId: o.orgId } : {}),
350
354
  };
351
355
  }
352
356
 
353
- export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCredentialsResult> {
357
+ /**
358
+ * Host start (whole tree) or, on a task machine, task-up with the task's
359
+ * project ids (ADR-133: the lease reads `/uai/projects/<id>` only for
360
+ * parameters tagged with its org, and only the task's projects matter).
361
+ */
362
+ export async function reconcileProjectEnvFromStore(
363
+ opts: { projectIds?: readonly string[] } = {},
364
+ ): Promise<ReconcileUserCredentialsResult> {
354
365
  const result: ReconcileUserCredentialsResult = {
355
366
  skipped: false,
356
367
  imported: 0,
@@ -361,16 +372,20 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
361
372
  const store = configuredCredentialStore();
362
373
  if (!store) return { ...result, skipped: true };
363
374
  const prefix = credentialStorePrefix();
364
- const path = projectEnvListPath(prefix);
365
- if (path === null) return { ...result, skipped: true };
375
+ const paths = projectEnvListPaths(prefix, opts.projectIds);
376
+ if (paths === null) return { ...result, skipped: true };
366
377
  const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
367
- let listed: Array<{ name: string; value: string }>;
368
- try {
369
- listed = await store.list(path);
370
- } catch (err) {
371
- result.errors++;
372
- warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
373
- return result;
378
+ const listed: Array<{ name: string; value: string }> = [];
379
+ const listedPrefixes: string[] = [];
380
+ const byName = projectEnvListsByName();
381
+ for (const path of paths) {
382
+ try {
383
+ listed.push(...(byName ? await store.listByName(path) : await store.list(path)));
384
+ listedPrefixes.push(`${path}/`);
385
+ } catch (err) {
386
+ result.errors++;
387
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
388
+ }
374
389
  }
375
390
  const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
376
391
  const seen = new Set<string>();
@@ -386,6 +401,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
386
401
  const row = local.get(`${at.projectId}/${at.key}`);
387
402
  if (row) {
388
403
  if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
404
+ if (value.orgId) noteProjectOrg(at.projectId, value.orgId);
389
405
  } else if (importProjectEnvFromStore(value, param.name)) {
390
406
  result.imported++;
391
407
  }
@@ -397,7 +413,8 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
397
413
  for (const row of local.values()) {
398
414
  try {
399
415
  if (row.storeRef) {
400
- if (row.storeRef.startsWith(`${path}/`) && !seen.has(row.storeRef)) {
416
+ const ref = row.storeRef;
417
+ if (listedPrefixes.some((p) => ref.startsWith(p)) && !seen.has(ref)) {
401
418
  deleteProjectEnvLocal(row.projectId, row.key);
402
419
  result.removed++;
403
420
  }
@@ -408,6 +425,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
408
425
  if (!name || !value) continue;
409
426
  await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
410
427
  result.uploaded++;
428
+ if (value.orgId) await tagProjectEnvParameter(store, name, row.projectId, value.orgId);
411
429
  } catch (err) {
412
430
  result.errors++;
413
431
  warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.131",
3
+ "version": "0.9.133",
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/index.ts CHANGED
@@ -201,11 +201,32 @@ export function setManagedHostRestartHook(hook: () => void): void {
201
201
  managedHostRestartHook = hook;
202
202
  }
203
203
 
204
+ /**
205
+ * ADR-133: a task machine's runner is pinned by the cloud at launch and its
206
+ * unit's cgroup holds the task itself — the S3 mounts, the display, the
207
+ * editor, the agent sessions. An in-place update or restart replaces
208
+ * nothing (the prepare step reinstalls the pin) and killed all of those
209
+ * (live 2026-09-10, smoke 10). Both verbs are refused here; a new task gets
210
+ * a fresh machine with the current runner.
211
+ */
212
+ function sidecarRunnerIsPinned(): HostCommandResult<never> | null {
213
+ if (!sidecarMode()) return null;
214
+ return {
215
+ ok: false,
216
+ code: HostErrorCode.Internal,
217
+ message:
218
+ "a task machine's runner is pinned for the task's life — it neither updates nor restarts in place; close the task and start another for the current runner",
219
+ retryable: false,
220
+ };
221
+ }
222
+
204
223
  export const hostCommands: HostCommands = {
205
224
  // The hostId argument is cloud-side routing (host-scoped commands carry
206
225
  // their target in args, like filesOp); this process IS the target.
207
226
  async hostUpdate(ctx, _hostId) {
208
227
  logCommand(ctx, "hostUpdate");
228
+ const pinned = sidecarRunnerIsPinned();
229
+ if (pinned) return pinned;
209
230
  try {
210
231
  const { updateManagedRuntime } = await import("../lib/managed-runtime");
211
232
  const outcome = await updateManagedRuntime();
@@ -244,6 +265,8 @@ export const hostCommands: HostCommands = {
244
265
  },
245
266
  async hostRestart(ctx, _hostId) {
246
267
  logCommand(ctx, "hostRestart");
268
+ const pinned = sidecarRunnerIsPinned();
269
+ if (pinned) return pinned;
247
270
  if (!managedHostRestartHook) {
248
271
  return {
249
272
  ok: false,
@@ -828,6 +851,25 @@ export const hostCommands: HostCommands = {
828
851
  // ADR-133 sidecar: nothing to clone or spawn with until the lease
829
852
  // has landed and the store is reconciled under it.
830
853
  await awaitSidecarReady();
854
+ if (sidecarMode()) {
855
+ // ADR-133: project env lives under /uai/projects/<id>, readable by
856
+ // the lease only for parameters tagged with this org, and only the
857
+ // task's own projects are worth listing — their ids are known here,
858
+ // not at the first grant (docs/credential-store.md).
859
+ const { reconcileProjectEnvFromStore } = await import("../lib/user-credentials-store");
860
+ const projectIds = input.projects.map((project) => project.id);
861
+ const env = await reconcileProjectEnvFromStore({ projectIds }).catch((error: unknown) => {
862
+ console.warn(
863
+ `[project-env] task ${input.task.id}: credential store read failed: ${error instanceof Error ? error.message : String(error)}`,
864
+ );
865
+ return null;
866
+ });
867
+ if (env && !env.skipped) {
868
+ console.log(
869
+ `[project-env] task ${input.task.id}: credential store reconciled for ${projectIds.length} project(s): ${env.imported} imported, ${env.removed} removed, ${env.errors} errors`,
870
+ );
871
+ }
872
+ }
831
873
  // Launch screen: the first stage boundary. A machine-backed task
832
874
  // spends the next minute launching an EC2 instance; a container
833
875
  // task goes straight to preparing its workspace.
package/src/main.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  deleteProjectEnvVar,
73
73
  listProjectEnvKeys,
74
74
  setProjectEnvVar,
75
+ noteProjectOrg,
75
76
  } from "../lib/host-env";
76
77
  import { handleMcpOp } from "../lib/mcp-connections";
77
78
  import { startMcpGateway } from "../lib/mcp-gateway";
@@ -252,6 +253,7 @@ import {
252
253
  import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
253
254
  import { LeaseManager } from "../lib/lease";
254
255
  import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
256
+ import { resumeSidecarTask } from "../lib/sidecar-resume";
255
257
  import { declareAgentClisReady } from "../lib/standard-image";
256
258
 
257
259
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
@@ -552,6 +554,21 @@ const leaseManager: LeaseManager | null = sidecarMode()
552
554
  // tokens, and project env on the machine (lib/sidecar.ts).
553
555
  markSidecarReady();
554
556
  console.log("[lease] credential store reconciled — the task may start");
557
+ // A runner that came back on a machine whose task is already up
558
+ // re-asserts the mounts and the editor (lib/sidecar-resume.ts).
559
+ void resumeSidecarTask()
560
+ .then((resumed) => {
561
+ if (resumed.outcome !== "resumed") return;
562
+ console.log(
563
+ `[sidecar] task ${sidecarTaskId()} resumed after a runner restart: shared files and editor re-asserted` +
564
+ (resumed.warnings.length > 0 ? ` (${resumed.warnings.join("; ")})` : ""),
565
+ );
566
+ })
567
+ .catch((error: unknown) => {
568
+ console.warn(
569
+ `[sidecar] resume failed: ${error instanceof Error ? error.message : String(error)}`,
570
+ );
571
+ });
555
572
  });
556
573
  startCredentialStorePickup({ repush: repushBrokeredCopies });
557
574
  },
@@ -1816,9 +1833,16 @@ async function connect(): Promise<void> {
1816
1833
  case "env.var.delete": {
1817
1834
  try {
1818
1835
  if (frame.kind === "env.var.set") {
1819
- setProjectEnvVar(frame.projectId, frame.key, frame.value);
1836
+ setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
1820
1837
  } else if (frame.kind === "env.var.delete") {
1821
1838
  deleteProjectEnvVar(frame.projectId, frame.key);
1839
+ } else if (frame.orgId) {
1840
+ // A list that names the org backfills rows mirrored before the
1841
+ // cloud sent it, and re-tags them for the task-machine lease.
1842
+ const updated = noteProjectOrg(frame.projectId, frame.orgId);
1843
+ if (updated > 0) {
1844
+ console.log(`[project-env] ${frame.projectId}: org recorded on ${updated} row(s); re-tagging`);
1845
+ }
1822
1846
  }
1823
1847
  // The ack carries the project's KEY NAMES only — values never cross
1824
1848
  // the bridge.
package/src/protocol.ts CHANGED
@@ -1657,13 +1657,18 @@ export type CloudToHost =
1657
1657
  // (env.var.ack) — the value on `env.var.set` is write-only (never echoed
1658
1658
  // back). `opId` correlates request↔ack; the ack's `keys` are that project's
1659
1659
  // keys.
1660
- | { kind: "env.var.list"; opId: string; projectId: string }
1660
+ // `orgId` (cloud from 2026-09-10): the project's org, so the host tags the
1661
+ // mirrored parameter for the tag-scoped task-machine lease policy (ADR-133,
1662
+ // docs/credential-store.md). A list carrying it also backfills rows that
1663
+ // were mirrored before the org was known. Absent from an older cloud.
1664
+ | { kind: "env.var.list"; opId: string; projectId: string; orgId?: string }
1661
1665
  | {
1662
1666
  kind: "env.var.set";
1663
1667
  opId: string;
1664
1668
  projectId: string;
1665
1669
  key: string;
1666
1670
  value: string;
1671
+ orgId?: string;
1667
1672
  }
1668
1673
  | { kind: "env.var.delete"; opId: string; projectId: string; key: string }
1669
1674
  // User-authed MCP connections (ADR-057). Ops run entirely host-side —