@runuai/host 0.9.63 → 0.9.65

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.
@@ -1502,6 +1502,12 @@ function isBadCredentialError(reason: string): boolean {
1502
1502
  */
1503
1503
  export function isTransientGithubError(reason: string): boolean {
1504
1504
  if (isBadCredentialError(reason)) return false;
1505
+ // A blank reason proves nothing about the credential. Seen live 2026-08-25:
1506
+ // `gh auth login` raced its own task's teardown (the container died under
1507
+ // the exec), exited nonzero with EMPTY stderr, and the fall-through verdict
1508
+ // told the user their auth expired and to reconnect — it hadn't. Unknown
1509
+ // must route to the retrying/soft path, never to a reconnect demand.
1510
+ if (reason.trim() === "") return true;
1505
1511
  return (
1506
1512
  /HTTP 5\d\d|Service Unavailable|Bad Gateway|Gateway Time-?out|server error|rate limit|too many requests|secondary rate|unavailable|unreachable/i.test(
1507
1513
  reason,
@@ -1739,11 +1745,15 @@ export async function setupTaskGithub(
1739
1745
  if (err instanceof ContainerRuntimeUnavailableError) throw err;
1740
1746
  const reason = err instanceof Error ? err.message : String(err);
1741
1747
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
1748
+ // A task already torn down must not receive notes: its thread is
1749
+ // read-only, and a gh failure RACING that teardown (exec into a dying
1750
+ // container, live 2026-08-25) says nothing a reader can act on.
1751
+ const taskStillActive = (deps.taskIsActive ?? taskIsActive)(taskId);
1742
1752
  // A revoked refresh token can't recover by retrying — drop it so the user
1743
1753
  // gets a clean re-grant, and tell the chat once.
1744
1754
  if (isRevokedTokenError(reason)) {
1745
1755
  (deps.deleteToken ?? deleteToken)(userId);
1746
- authExpiredHandler?.(taskId, userId, reason);
1756
+ if (taskStillActive) authExpiredHandler?.(taskId, userId, reason);
1747
1757
  return false;
1748
1758
  }
1749
1759
  // A retry chain already armed for this task means a sibling entry point
@@ -1756,7 +1766,9 @@ export async function setupTaskGithub(
1756
1766
  // "gh auth" notes for one outage is noise. Exhaustion posts its own note
1757
1767
  // (in scheduleGithubRetry). The handler classifies the reason (transient
1758
1768
  // 5xx blip vs. genuine expiry) and words the note accordingly.
1759
- if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
1769
+ if (attempt === 0 && taskStillActive) {
1770
+ authExpiredHandler?.(taskId, userId, reason);
1771
+ }
1760
1772
  scheduleGithubRetry(
1761
1773
  taskId,
1762
1774
  userId,
@@ -73,6 +73,7 @@ import {
73
73
  import {
74
74
  appleCliRunner,
75
75
  appleTaskContainerName,
76
+ appleTaskStoreVolumeName,
76
77
  parseAppleTaskEnvironmentLocator,
77
78
  } from "./task-environment/apple-container";
78
79
  import { parseDockerTaskEnvironmentLocator } from "./task-environment/docker";
@@ -5660,9 +5661,15 @@ interface RecoveryTaskConsumers {
5660
5661
  * proof (the jq filter behind "verify task runtime isolation"): each required
5661
5662
  * canonical entry appears exactly once, every owned-npm-config spelling
5662
5663
  * (case-folded, underscores treated as hyphens) must be one of the canonical
5663
- * eight, and PATH plus the asdf/corepack controls hold their exact values
5664
- * exactly once. */
5665
- export function appleTaskEnvironmentAuthorityHolds(value: unknown): boolean {
5664
+ * entries, and PATH plus the asdf/corepack controls hold their exact values
5665
+ * exactly once. ADR-118: `storeDir` mirrors task-up's apple-only store
5666
+ * authority a string requires both canonical store-dir entries (and owns
5667
+ * the key), while null FORBIDS every store-dir spelling, so a container's
5668
+ * environment and its store mount can never disagree silently. */
5669
+ export function appleTaskEnvironmentAuthorityHolds(
5670
+ value: unknown,
5671
+ storeDir: string | null = null,
5672
+ ): boolean {
5666
5673
  if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
5667
5674
  return false;
5668
5675
  }
@@ -5681,8 +5688,30 @@ export function appleTaskEnvironmentAuthorityHolds(value: unknown): boolean {
5681
5688
  `npm_config_logs_dir=${npmCache}/_logs`,
5682
5689
  "NPM_CONFIG_UMASK=0002",
5683
5690
  "npm_config_umask=0002",
5691
+ ...(storeDir !== null
5692
+ ? [
5693
+ `NPM_CONFIG_STORE_DIR=${storeDir}`,
5694
+ `npm_config_store_dir=${storeDir}`,
5695
+ ]
5696
+ : []),
5697
+ ];
5698
+ const owned = [
5699
+ "prefix",
5700
+ "cache",
5701
+ "logs-dir",
5702
+ "umask",
5703
+ ...(storeDir !== null ? ["store-dir"] : []),
5684
5704
  ];
5685
- const owned = ["prefix", "cache", "logs-dir", "umask"];
5705
+ if (storeDir === null) {
5706
+ const carriesStoreKey = environment.some((entry) => {
5707
+ const folded = entry.split("=")[0]!.toLowerCase();
5708
+ return (
5709
+ folded.startsWith("npm_config_") &&
5710
+ folded.slice("npm_config_".length).replace(/_/g, "-") === "store-dir"
5711
+ );
5712
+ });
5713
+ if (carriesStoreKey) return false;
5714
+ }
5686
5715
  const countOf = (exact: string): number =>
5687
5716
  environment.filter((entry) => entry === exact).length;
5688
5717
  const isOwnedNpmConfig = (key: string): boolean => {
@@ -5829,6 +5858,30 @@ export async function proveAppleTaskRuntimeContract(options: {
5829
5858
  workspaceAuthorityHolds = false;
5830
5859
  }
5831
5860
  }
5861
+ // ADR-118: the store volume and the store environment must agree. A
5862
+ // pre-ADR-118 container carries neither (grandfathered until natural
5863
+ // teardown — an old container is a live fd cost, not a deadlock); a
5864
+ // current container carries exactly one writable attachment of ITS OWN
5865
+ // store volume at the store path plus the matching env authority. A
5866
+ // container claiming the env without the mount would silently drop its
5867
+ // store into the container layer; a foreign volume at the store path is
5868
+ // an integrity hole. Both are violations.
5869
+ const storeVolumeName = appleTaskStoreVolumeName(options.taskId);
5870
+ const storeMounts = mounts.filter(
5871
+ (mount) => mount?.destination === "/opt/uai/store",
5872
+ );
5873
+ const storeVolumeMounts = mounts.filter(
5874
+ (mount) => mount?.type?.volume?.name === storeVolumeName,
5875
+ );
5876
+ const storeContractHolds =
5877
+ storeMounts.length === 0
5878
+ ? storeVolumeMounts.length === 0
5879
+ : storeMounts.length === 1 &&
5880
+ storeVolumeMounts.length === 1 &&
5881
+ storeVolumeMounts[0] === storeMounts[0] &&
5882
+ Array.isArray(storeMounts[0]?.options) &&
5883
+ !(storeMounts[0]!.options as unknown[]).includes("ro");
5884
+ const storeDir = storeMounts.length === 0 ? null : "/opt/uai/store/pnpm";
5832
5885
  const rawEnvironment = record?.configuration?.initProcess?.environment;
5833
5886
  const holds =
5834
5887
  record?.status?.state === "running" &&
@@ -5843,7 +5896,8 @@ export async function proveAppleTaskRuntimeContract(options: {
5843
5896
  // contradictory and proves nothing.
5844
5897
  !(runtimeMounts[0]!.options as unknown[]).includes("rw") &&
5845
5898
  workspaceAuthorityHolds &&
5846
- appleTaskEnvironmentAuthorityHolds(rawEnvironment);
5899
+ storeContractHolds &&
5900
+ appleTaskEnvironmentAuthorityHolds(rawEnvironment, storeDir);
5847
5901
  return holds ? { kind: "holds" } : { kind: "violated" };
5848
5902
  } catch {
5849
5903
  return { kind: "violated" };
@@ -6039,7 +6093,21 @@ export async function recoverAppleTaskEnvironment(
6039
6093
  '[ ! -L /var/lib/uai/asdf-upper ] || { echo "upper is a symlink" >&2; exit 1; }; ' +
6040
6094
  '[ -d /var/lib/uai/asdf-upper ] || { echo "upper is not a directory" >&2; exit 1; }; ' +
6041
6095
  '[ "$(/usr/bin/stat -c %U /var/lib/uai/asdf-upper)" = "node" ] || { echo "upper is not node-owned" >&2; exit 1; }; ' +
6042
- "/usr/bin/test -d /opt/asdf-data/plugins",
6096
+ "/usr/bin/test -d /opt/asdf-data/plugins; " +
6097
+ // ADR-118: when the store volume is attached (pre-ADR-118 containers
6098
+ // have none — grandfathered), re-assert the same writable-mount +
6099
+ // node-owned-store proof as task-up §6a. The chown persisted on the
6100
+ // volume's ext4, but a resized/replaced attachment must not slip
6101
+ // through recovery unproven.
6102
+ 'if /bin/grep -q " /opt/uai/store " /proc/mounts; then ' +
6103
+ 'store_lines=$(/bin/grep -c " /opt/uai/store " /proc/mounts || true); ' +
6104
+ '[ "$store_lines" = "1" ] || { echo "expected exactly one /opt/uai/store mount, found $store_lines" >&2; exit 1; }; ' +
6105
+ 'sopts=$(/bin/grep " /opt/uai/store " /proc/mounts | /usr/bin/cut -d" " -f4); ' +
6106
+ 'case ",$sopts," in *",ro,"*) echo "store volume is unexpectedly read-only" >&2; exit 1 ;; esac; ' +
6107
+ "/bin/mkdir -p /opt/uai/store/pnpm; " +
6108
+ "/bin/chown node:node /opt/uai/store /opt/uai/store/pnpm; " +
6109
+ '[ "$(/usr/bin/stat -c %U /opt/uai/store/pnpm)" = "node" ] || { echo "store dir is not node-owned" >&2; exit 1; }; ' +
6110
+ "fi",
6043
6111
  ],
6044
6112
  user: "root",
6045
6113
  env: {},
@@ -138,6 +138,13 @@ export function appleTaskContainerName(taskId: string): string {
138
138
  return `task-${taskId}-app`;
139
139
  }
140
140
 
141
+ /** ADR-118: the task-private block volume carrying the pnpm store. Must
142
+ * match apple_store_volume_for_task in scripts/agent/_common.sh. */
143
+ export function appleTaskStoreVolumeName(taskId: string): string {
144
+ assertSafeHostTaskId(taskId);
145
+ return `uai-store-${taskId}`;
146
+ }
147
+
141
148
  export function createAppleContainerTaskEnvironmentProvider<
142
149
  TInput = never,
143
150
  TCredentials = never,
@@ -31,7 +31,10 @@ import {
31
31
  taskContainerBackend,
32
32
  taskContainerCli,
33
33
  } from "./task-container-cli";
34
- import { appleTaskContainerName } from "./task-environment/apple-container";
34
+ import {
35
+ appleTaskContainerName,
36
+ appleTaskStoreVolumeName,
37
+ } from "./task-environment/apple-container";
35
38
 
36
39
  export {
37
40
  assertSafeHostTaskId,
@@ -231,6 +234,49 @@ function parseAppleInventoryRows(stdout: string): AppleInventoryRow[] | null {
231
234
  return rows;
232
235
  }
233
236
 
237
+ type AppleVolumeInventoryRow = { name: string; taskLabel: string | null };
238
+
239
+ /** ADR-118: strict volume-inventory shape, same discipline as containers —
240
+ * `volume ls --format json` rows carry configuration.name (volumes have no
241
+ * configuration.id) and optional labels; any malformed row invalidates the
242
+ * WHOLE inventory, because a partially-readable answer could be hiding
243
+ * exactly the labeled volume the orphan proof exists to find. */
244
+ function parseAppleVolumeInventoryRows(
245
+ stdout: string,
246
+ ): AppleVolumeInventoryRow[] | null {
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(stdout);
250
+ } catch {
251
+ return null;
252
+ }
253
+ if (!Array.isArray(parsed)) return null;
254
+ const rows: AppleVolumeInventoryRow[] = [];
255
+ for (const row of parsed) {
256
+ if (typeof row !== "object" || row === null) return null;
257
+ const configuration = (row as { configuration?: unknown }).configuration;
258
+ if (typeof configuration !== "object" || configuration === null) {
259
+ return null;
260
+ }
261
+ const name = (configuration as { name?: unknown }).name;
262
+ if (typeof name !== "string" || name === "") return null;
263
+ const labels = (configuration as { labels?: unknown }).labels;
264
+ if (labels === undefined || labels === null) {
265
+ rows.push({ name, taskLabel: null });
266
+ continue;
267
+ }
268
+ if (typeof labels !== "object" || Array.isArray(labels)) return null;
269
+ const label = (labels as Record<string, unknown>)["com.uai.task"];
270
+ if (label === undefined) {
271
+ rows.push({ name, taskLabel: null });
272
+ continue;
273
+ }
274
+ if (typeof label !== "string") return null;
275
+ rows.push({ name, taskLabel: label });
276
+ }
277
+ return rows;
278
+ }
279
+
234
280
  function composeTaskId(project: string): string | null {
235
281
  if (!project.startsWith("task-")) return null;
236
282
  const taskId = project.slice("task-".length);
@@ -439,9 +485,10 @@ export async function proveOrphanTaskResourcesAbsent(
439
485
  ): Promise<void> {
440
486
  assertSafeHostTaskId(taskId);
441
487
  if (appleDeps.appleBackend()) {
442
- // Apple task resources: the fixed-name container and the derived image.
443
- // No compose networks/volumes exist there. Absence is proven only by the
444
- // exact not-found grammars (a failed query is never absence).
488
+ // Apple task resources: the fixed-name container, the derived image, and
489
+ // the ADR-118 store volume. No compose networks or anonymous volumes
490
+ // exist there. Absence is proven only by the exact not-found grammars (a
491
+ // failed query is never absence).
445
492
  // Derive the apple name from the branch's own backend decision, not the
446
493
  // global pin — this proof runs exactly when appleBackend() says apple.
447
494
  const containerName = appleTaskContainerName(taskId);
@@ -485,6 +532,31 @@ export async function proveOrphanTaskResourcesAbsent(
485
532
  : `orphan proof: derived image ${imageRef} could not be proven absent (exit ${inspected.status ?? "killed"})`,
486
533
  );
487
534
  }
535
+ // ADR-118: the store volume must be gone too — GC purging local state
536
+ // while the volume survives would leak it forever (nothing else ever
537
+ // deletes by this name; task-down is the remediation the caller retries).
538
+ const storeVolume = appleTaskStoreVolumeName(taskId);
539
+ const inspectedVolume = await appleDeps.appleCli([
540
+ "volume",
541
+ "inspect",
542
+ storeVolume,
543
+ ]);
544
+ const volumeAbsent =
545
+ typeof inspectedVolume.status === "number" &&
546
+ inspectedVolume.status !== 0 &&
547
+ inspectedVolume.stdout.trim() === "" &&
548
+ inspectedVolume.stderr
549
+ .split("\n")
550
+ .some(
551
+ (line) => line.trim() === `Error: volume not found: ${storeVolume}`,
552
+ );
553
+ if (!volumeAbsent) {
554
+ throw new Error(
555
+ inspectedVolume.status === 0
556
+ ? `orphan proof: store volume ${storeVolume} still exists`
557
+ : `orphan proof: store volume ${storeVolume} could not be proven absent (exit ${inspectedVolume.status ?? "killed"})`,
558
+ );
559
+ }
488
560
  // The same ownership set the enumeration uses (round 5): a RENAMED
489
561
  // container still carrying this task's label is this task's resource,
490
562
  // and the exact-name check above cannot see it. An unreadable inventory
@@ -509,6 +581,31 @@ export async function proveOrphanTaskResourcesAbsent(
509
581
  );
510
582
  }
511
583
  }
584
+ // ADR-118, same ownership rule for volumes: a RENAMED volume still
585
+ // carrying this task's label is this task's store, and the exact-name
586
+ // probe above cannot see it. An unreadable inventory proves nothing.
587
+ const volumesListed = await appleDeps.appleCli([
588
+ "volume",
589
+ "ls",
590
+ "--format",
591
+ "json",
592
+ ]);
593
+ if (volumesListed.status !== 0) {
594
+ throw new Error(
595
+ `orphan proof: volume inventory failed (exit ${volumesListed.status ?? "killed"})`,
596
+ );
597
+ }
598
+ const volumeRows = parseAppleVolumeInventoryRows(volumesListed.stdout);
599
+ if (volumeRows === null) {
600
+ throw new Error("orphan proof: volume inventory was unparseable");
601
+ }
602
+ for (const row of volumeRows) {
603
+ if (row.taskLabel === taskId) {
604
+ throw new Error(
605
+ `orphan proof: volume ${row.name} still carries this task's ownership label`,
606
+ );
607
+ }
608
+ }
512
609
  // Workspace absence, exactly like the Docker proof below.
513
610
  const appleTaskDir = resolve(env.workspaceRoot, "tasks", taskId);
514
611
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.63",
3
+ "version": "0.9.65",
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>",
@@ -383,6 +383,9 @@ app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
383
383
  # replica suffix. Must match appleTaskContainerName in
384
384
  # lib/task-environment/apple-container.ts.
385
385
  apple_app_container_for_task() { printf 'task-%s-app' "$(_uai_lower "$1")"; }
386
+ # ADR-118: per-task block volume carrying the pnpm store. Must match
387
+ # appleTaskStoreVolumeName in lib/task-environment/apple-container.ts.
388
+ apple_store_volume_for_task() { printf 'uai-store-%s' "$(_uai_lower "$1")"; }
386
389
 
387
390
  # -----------------------------------------------------------------------------
388
391
  # ADR-106 container runtime selection. The host publishes the selected backend
@@ -456,6 +459,26 @@ apple_container_proven_absent() {
456
459
  [ "$apple_absent_proven" -eq 1 ]
457
460
  }
458
461
 
462
+ # ADR-118: same single-response absence discipline for a named volume. Only
463
+ # the exact whole-line not-found grammar naming this volume proves absence;
464
+ # any other failure (broken apiserver, timeout) proves nothing.
465
+ apple_volume_proven_absent() {
466
+ apple_vabsent_name="$1"
467
+ apple_vabsent_dir=$(mktemp -d "${TMPDIR:-/tmp}/uai-vabsent.XXXXXX") \
468
+ || return 1
469
+ apple_vabsent_rc=0
470
+ "$CONTAINER_CLI" volume inspect "$apple_vabsent_name" \
471
+ >"$apple_vabsent_dir/out" 2>"$apple_vabsent_dir/err" \
472
+ || apple_vabsent_rc=$?
473
+ apple_vabsent_proven=1
474
+ [ "$apple_vabsent_rc" -ne 0 ] || apple_vabsent_proven=0
475
+ [ ! -s "$apple_vabsent_dir/out" ] || apple_vabsent_proven=0
476
+ grep -Fqx "Error: volume not found: $apple_vabsent_name" \
477
+ "$apple_vabsent_dir/err" 2>/dev/null || apple_vabsent_proven=0
478
+ rm -rf "$apple_vabsent_dir"
479
+ [ "$apple_vabsent_proven" -eq 1 ]
480
+ }
481
+
459
482
  # Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
460
483
  # the credential per task: the creator's connected GitHub token is primary;
461
484
  # SSH is a fallback only when that user has no GitHub connection on this host.
@@ -79,7 +79,9 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
79
79
  # writable Git data below is deleted or success is reported. A CLI failure
80
80
  # that does not name this container as not-found proves nothing — the
81
81
  # workspace and its Git data must survive until absence is confirmed. The
82
- # Apple runtime has no per-task networks, sidecars, or anonymous volumes.
82
+ # Apple runtime has no per-task networks or sidecars; its one per-task
83
+ # volume (the ADR-118 store) is removed under the same ownership proof
84
+ # below.
83
85
  if crt inspect "$app_container" >/dev/null 2>&1; then
84
86
  emit_err "COMPOSE_DOWN_FAILED" \
85
87
  "task runtime resources remain after teardown" \
@@ -90,6 +92,35 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
90
92
  "could not verify task container removal; refusing to delete the task workspace" \
91
93
  "container verification after cleanup"
92
94
  fi
95
+ # ADR-118: the task's store volume. Deleting only after the container is
96
+ # proven absent releases the attachment; the same exact-identity +
97
+ # ownership-label discipline gates destruction, and absence must be proven
98
+ # before success is reported — a leaked volume defeats the orphan GC that
99
+ # keys on this exact name. A pre-ADR-118 task simply has no such volume
100
+ # and passes straight through the absence proof.
101
+ step "COMPOSE_DOWN_FAILED" "remove task store volume"
102
+ store_volume=$(apple_store_volume_for_task "$task_id")
103
+ if store_volume_json=$(crt volume inspect "$store_volume" 2>/dev/null); then
104
+ store_volume_ok=$(printf '%s' "$store_volume_json" \
105
+ | jq -r --arg name "$store_volume" --arg tid "$task_id" '
106
+ if (type == "array" and length == 1
107
+ and (.[0] | type == "object")
108
+ and .[0].id == $name
109
+ and ((.[0].configuration.labels["com.uai.task"] // "") == $tid))
110
+ then "ok" else "mismatch" end' 2>/dev/null) \
111
+ || store_volume_ok="mismatch"
112
+ if [ "$store_volume_ok" != "ok" ]; then
113
+ emit_err "COMPOSE_DOWN_FAILED" \
114
+ "A volume named $store_volume exists but could not be proven as this task's own (exact identity + ownership label); Uai will not delete a volume it cannot prove it owns. Remove or rename it, then retry." \
115
+ "remove task store volume"
116
+ fi
117
+ crt volume delete "$store_volume" >/dev/null 2>&1 || true
118
+ fi
119
+ if ! apple_volume_proven_absent "$store_volume"; then
120
+ emit_err "COMPOSE_DOWN_FAILED" \
121
+ "could not verify task store volume removal; refusing to delete the task workspace" \
122
+ "store volume verification after cleanup"
123
+ fi
93
124
  else
94
125
  uai_require_compose_plugin
95
126
  step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
@@ -52,6 +52,15 @@ PW_VOLUME="uai-playwright"
52
52
  # directory shares carry no exclusivity, like /workspace itself.
53
53
  PW_BROWSERS_DIR="$UAI_WORKSPACE_ROOT/pw-browsers"
54
54
  DERIVED_IMAGE="uai-task-${task_id}"
55
+ # ADR-118 (apple only): the pnpm store rides a task-private block volume. On
56
+ # virtiofs every store file pins a host fd for the VM's lifetime — one
57
+ # monorepo install held ~130k host descriptors and two tasks exhausted the
58
+ # system file table (live 2026-08-25). A block volume costs one fd total.
59
+ # One volume per task, never shared: block volumes allow exactly ONE
60
+ # writable attachment, and the store was per-task on the old path anyway.
61
+ TASK_STORE_VOLUME=$(apple_store_volume_for_task "$task_id")
62
+ TASK_STORE_MOUNT="/opt/uai/store"
63
+ TASK_STORE_DIR="$TASK_STORE_MOUNT/pnpm"
55
64
 
56
65
  # ADR-106: runtime selection is shared with task-down/task-status via
57
66
  # _common.sh; `docker` keeps the historical Compose path byte-for-byte, while
@@ -1711,6 +1720,35 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1711
1720
  "Uai could not create the shared $ASDF_VOLUME volume on the Apple container runtime. Retry after the runtime is healthy." \
1712
1721
  "create shared volumes"
1713
1722
  fi
1723
+ # ADR-118: create or adopt this task's private store volume. Adoption
1724
+ # requires the same exact-identity + ownership-label proof as container
1725
+ # destruction: exactly one record, whose id is the requested name,
1726
+ # carrying com.uai.task=<this task>. A volume merely OCCUPYING the
1727
+ # reserved name is not this task's store and must never be mounted into
1728
+ # it — foreign bytes at the package store are an integrity boundary.
1729
+ step "CONTAINER_INIT_FAILED" "create task store volume"
1730
+ if store_volume_json=$(crt volume inspect "$TASK_STORE_VOLUME" 2>/dev/null); then
1731
+ store_volume_ok=$(printf '%s' "$store_volume_json" \
1732
+ | jq -r --arg name "$TASK_STORE_VOLUME" --arg tid "$task_id" '
1733
+ if (type == "array" and length == 1
1734
+ and (.[0] | type == "object")
1735
+ and .[0].id == $name
1736
+ and ((.[0].configuration.labels["com.uai.task"] // "") == $tid))
1737
+ then "ok" else "mismatch" end' 2>/dev/null) \
1738
+ || store_volume_ok="mismatch"
1739
+ if [ "$store_volume_ok" != "ok" ]; then
1740
+ emit_err "CONTAINER_INIT_FAILED" \
1741
+ "A volume named $TASK_STORE_VOLUME exists but could not be proven as this task's own (exact identity + ownership label); Uai will not attach a store volume it cannot prove it owns. Remove or rename it, then retry." \
1742
+ "create task store volume"
1743
+ fi
1744
+ else
1745
+ if ! crt volume create --label "com.uai.task=$task_id" \
1746
+ "$TASK_STORE_VOLUME" >/dev/null 2>&1; then
1747
+ emit_err "CONTAINER_INIT_FAILED" \
1748
+ "Uai could not create this task's store volume $TASK_STORE_VOLUME on the Apple container runtime. Retry after the runtime is healthy." \
1749
+ "create task store volume"
1750
+ fi
1751
+ fi
1714
1752
  else
1715
1753
  crt volume create "$PW_VOLUME" >/dev/null 2>&1 || true
1716
1754
  fi
@@ -2059,6 +2097,13 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2059
2097
  printf 'npm_config_logs_dir=%s/_logs\n' "$TASK_NPM_CACHE"
2060
2098
  printf 'NPM_CONFIG_UMASK=0002\n'
2061
2099
  printf 'npm_config_umask=0002\n'
2100
+ # ADR-118: pnpm's store must live on the task's block volume, never on
2101
+ # the virtiofs workspace (pnpm's same-filesystem fallback would drop it
2102
+ # at the workspace root). Env beats a project .npmrc, so repos need no
2103
+ # config. Cross-filesystem hard links are impossible; pnpm's
2104
+ # package-import-method=auto falls back to copying, which is correct.
2105
+ printf 'NPM_CONFIG_STORE_DIR=%s\n' "$TASK_STORE_DIR"
2106
+ printf 'npm_config_store_dir=%s\n' "$TASK_STORE_DIR"
2062
2107
  printf 'UAI_WORKSPACE=/workspace\n'
2063
2108
  printf 'UAI_RUNTIME_PROJECTS=/run/uai/runtime-projects\n'
2064
2109
  printf 'LD_PRELOAD=\n'
@@ -2124,6 +2169,9 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2124
2169
  -v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro"
2125
2170
  -v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro"
2126
2171
  -v "$PW_BROWSERS_DIR:/opt/pw-browsers"
2172
+ # ADR-118: task-private store volume, writable — created and
2173
+ # ownership-proven above; single-attach by runtime construction.
2174
+ -v "$TASK_STORE_VOLUME:$TASK_STORE_MOUNT"
2127
2175
  )
2128
2176
  if [ "$shared_files_mode" != "off" ]; then
2129
2177
  shared_suffix=""
@@ -2267,16 +2315,27 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2267
2315
  mounts_proof_filter='
2268
2316
  [ .[] | select(.destination == "/run/uai/asdf-lower") ] as $runtime
2269
2317
  | [ .[] | select((.type.volume.name? // "") == "uai-asdf-data") ] as $shared
2318
+ | [ .[] | select(.destination == $store_mount) ] as $store
2319
+ | [ .[] | select((.type.volume.name? // "") == $store_volume) ] as $store_vol
2270
2320
  | ($runtime | length) == 1
2271
2321
  and ($shared | length) == 1
2272
2322
  and (($runtime[0].type.volume.name? // "") == "uai-asdf-data")
2273
2323
  and ((($runtime[0].options // []) | index("ro")) != null)
2274
2324
  and ((($runtime[0].options // []) | index("rw")) == null)
2275
2325
  and (([ .[] | select(.destination == "/opt/asdf-data") ] | length) == 0)
2326
+ # ADR-118: the task-private store volume attaches WRITABLE at exactly
2327
+ # its one mount point, and no other volume occupies that path.
2328
+ and ($store | length) == 1
2329
+ and ($store_vol | length) == 1
2330
+ and (($store[0].type.volume.name? // "") == $store_volume)
2331
+ and ((($store[0].options // []) | index("ro")) == null)
2276
2332
  '
2277
2333
  fi
2278
2334
  if ! printf '%s\n' "$app_mounts_json" \
2279
- | jq -e "$mounts_proof_filter" >/dev/null 2>&1; then
2335
+ | jq -e \
2336
+ --arg store_mount "$TASK_STORE_MOUNT" \
2337
+ --arg store_volume "$TASK_STORE_VOLUME" \
2338
+ "$mounts_proof_filter" >/dev/null 2>&1; then
2280
2339
  if ! quarantine_untrusted_app_container; then
2281
2340
  emit_err "CONTAINER_INIT_FAILED" \
2282
2341
  "Uai found an unsafe shared runtime mount and could not prove the task container stopped. Stop it manually before retrying." \
@@ -2286,16 +2345,23 @@ if ! printf '%s\n' "$app_mounts_json" \
2286
2345
  "Uai stopped this task because its shared runtime cache was not the exact read-only uai-asdf-data volume. Retry to recreate the container safely." \
2287
2346
  "verify task runtime isolation"
2288
2347
  fi
2348
+ # ADR-118: the store authority exists only on the apple path (Docker tasks
2349
+ # keep pnpm's default resolution); an empty $store keeps the historical
2350
+ # docker proof byte-for-byte.
2351
+ env_proof_store_dir=""
2352
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
2353
+ || env_proof_store_dir="$TASK_STORE_DIR"
2289
2354
  if ! printf '%s\n' "$app_config_env_json" | jq -e \
2290
2355
  --arg path "$TASK_RUNTIME_PATH" \
2291
2356
  --arg prefix "$TASK_NPM_PREFIX" \
2292
- --arg cache "$TASK_NPM_CACHE" '
2357
+ --arg cache "$TASK_NPM_CACHE" \
2358
+ --arg store "$env_proof_store_dir" '
2293
2359
  def is_owned_npm_config($owned):
2294
2360
  ascii_downcase as $folded
2295
2361
  | ($folded | startswith("npm_config_"))
2296
2362
  and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
2297
2363
  (. // []) as $env
2298
- | [
2364
+ | ([
2299
2365
  "NPM_CONFIG_PREFIX=\($prefix)",
2300
2366
  "npm_config_prefix=\($prefix)",
2301
2367
  "NPM_CONFIG_CACHE=\($cache)",
@@ -2304,10 +2370,15 @@ if ! printf '%s\n' "$app_config_env_json" | jq -e \
2304
2370
  "npm_config_logs_dir=\($cache)/_logs",
2305
2371
  "NPM_CONFIG_UMASK=0002",
2306
2372
  "npm_config_umask=0002"
2307
- ] as $required
2308
- | [
2373
+ ]
2374
+ + (if $store != "" then [
2375
+ "NPM_CONFIG_STORE_DIR=\($store)",
2376
+ "npm_config_store_dir=\($store)"
2377
+ ] else [] end)) as $required
2378
+ | ([
2309
2379
  "prefix", "cache", "logs-dir", "umask"
2310
- ] as $owned
2380
+ ]
2381
+ + (if $store != "" then ["store-dir"] else [] end)) as $owned
2311
2382
  | ($env | type == "array")
2312
2383
  and all($env[]; type == "string")
2313
2384
  and all($required[];
@@ -2381,6 +2452,16 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2381
2452
  [ -d /var/lib/uai/asdf-upper ] || { echo "upper is not a directory" >&2; exit 1; }
2382
2453
  [ "$(/usr/bin/stat -c %U /var/lib/uai/asdf-upper)" = "node" ] || { echo "upper is not node-owned" >&2; exit 1; }
2383
2454
  /usr/bin/test -d /opt/asdf-data/plugins
2455
+ # ADR-118: the store volume is a fresh ext4 with a root-owned root; hand
2456
+ # the pnpm store dir to node and prove the attachment from the guest
2457
+ # mount table — exactly one writable mount at the store path.
2458
+ store_lines=$(/bin/grep -c " /opt/uai/store " /proc/mounts || true)
2459
+ [ "$store_lines" = "1" ] || { echo "expected exactly one /opt/uai/store mount, found $store_lines" >&2; exit 1; }
2460
+ sopts=$(/bin/grep " /opt/uai/store " /proc/mounts | /usr/bin/cut -d" " -f4)
2461
+ case ",$sopts," in *",ro,"*) echo "store volume is unexpectedly read-only" >&2; exit 1 ;; esac
2462
+ /bin/mkdir -p /opt/uai/store/pnpm
2463
+ /bin/chown node:node /opt/uai/store /opt/uai/store/pnpm
2464
+ [ "$(/usr/bin/stat -c %U /opt/uai/store/pnpm)" = "node" ] || { echo "store dir is not node-owned" >&2; exit 1; }
2384
2465
  ' >/dev/null || overlay_rc=$?
2385
2466
  if [ "$overlay_rc" -ne 0 ]; then
2386
2467
  require_docker_after_failed_step "establish task runtime overlay"
@@ -2550,6 +2631,15 @@ task_runtime_exec_env=(
2550
2631
  -e "BASH_ENV="
2551
2632
  -e "ENV="
2552
2633
  )
2634
+ # ADR-118 (apple only): every runtime-bearing exec — uai-init's installs
2635
+ # above all — must aim pnpm at the task's block volume, exactly like the
2636
+ # container env-file. Docker execs keep the historical environment.
2637
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2638
+ task_runtime_exec_env+=(
2639
+ -e "NPM_CONFIG_STORE_DIR=$TASK_STORE_DIR"
2640
+ -e "npm_config_store_dir=$TASK_STORE_DIR"
2641
+ )
2642
+ fi
2553
2643
 
2554
2644
  # OpenCode and code-server share ~/.local/share. OpenCode credentials are
2555
2645
  # copied as root below, so creating only its leaf with `mkdir -p` can leave the
package/src/index.ts CHANGED
@@ -177,7 +177,9 @@ export function setManagedHostRestartHook(hook: () => void): void {
177
177
  }
178
178
 
179
179
  export const hostCommands: HostCommands = {
180
- async hostUpdate(ctx) {
180
+ // The hostId argument is cloud-side routing (host-scoped commands carry
181
+ // their target in args, like filesOp); this process IS the target.
182
+ async hostUpdate(ctx, _hostId) {
181
183
  logCommand(ctx, "hostUpdate");
182
184
  try {
183
185
  const { updateManagedRuntime } = await import("../lib/managed-runtime");
@@ -215,7 +217,7 @@ export const hostCommands: HostCommands = {
215
217
  };
216
218
  }
217
219
  },
218
- async hostRestart(ctx) {
220
+ async hostRestart(ctx, _hostId) {
219
221
  logCommand(ctx, "hostRestart");
220
222
  if (!managedHostRestartHook) {
221
223
  return {
package/src/main.ts CHANGED
@@ -461,11 +461,14 @@ if (initialRuntime.status === "ready") {
461
461
  // would be wrong and alarming. Only a genuine expiry/revocation asks for a
462
462
  // reconnect. Both cases keep retrying in the background regardless.
463
463
  setAuthExpiredHandler((taskId, _userId, reason) => {
464
+ // A blank reason (gh died with no stderr — e.g. racing its own task's
465
+ // teardown) classifies as transient; don't interpolate an empty clause.
466
+ const detail = reason.trim();
464
467
  const note = isTransientGithubError(reason)
465
- ? `GitHub is temporarily unavailable — ${reason}. Uai is retrying ` +
466
- `automatically; no action needed unless this persists (then run ` +
467
- `/retry-gh).`
468
- : `gh authentication expired (reason: ${reason}). Reconnect GitHub on ` +
468
+ ? `GitHub setup hit a temporary failure${detail ? ` — ${detail}` : ""}. ` +
469
+ `Uai is retrying automatically; no action needed unless this ` +
470
+ `persists (then run /retry-gh).`
471
+ : `gh authentication expired (reason: ${detail}). Reconnect GitHub on ` +
469
472
  `Account, then run /retry-gh in this task to restore.`;
470
473
  getOrchestrator().emitSystemNote(taskId, note);
471
474
  });
@@ -2287,9 +2290,9 @@ async function dispatchCommand(
2287
2290
  expectNumberArg(args, 2),
2288
2291
  );
2289
2292
  case "hostUpdate":
2290
- return hostCommands.hostUpdate(ctx);
2293
+ return hostCommands.hostUpdate(ctx, typeof args[0] === "string" ? args[0] : "");
2291
2294
  case "hostRestart":
2292
- return hostCommands.hostRestart(ctx);
2295
+ return hostCommands.hostRestart(ctx, typeof args[0] === "string" ? args[0] : "");
2293
2296
  case "engineAccountRemove": {
2294
2297
  // ADR-116: mirror the local API's post-mutation behavior — the cloud's
2295
2298
  // account list self-heals through the capability re-advertisement.
package/src/protocol.ts CHANGED
@@ -1270,9 +1270,12 @@ export interface HostCommands {
1270
1270
  * remote trigger adds no trust surface. The reconnect on the new version
1271
1271
  * is the real confirmation.
1272
1272
  */
1273
- hostUpdate(ctx: CommandContext): Promise<HostCommandResult<HostUpdateOutcome>>;
1273
+ hostUpdate(
1274
+ ctx: CommandContext,
1275
+ hostId: string,
1276
+ ): Promise<HostCommandResult<HostUpdateOutcome>>;
1274
1277
  /** Drain in-flight work, then let the service supervisor respawn the host. */
1275
- hostRestart(ctx: CommandContext): Promise<HostCommandResult<void>>;
1278
+ hostRestart(ctx: CommandContext, hostId: string): Promise<HostCommandResult<void>>;
1276
1279
  taskUp(
1277
1280
  ctx: CommandContext,
1278
1281
  input: TaskLaunchInput,