@runuai/host 0.9.64 → 0.9.66

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.
@@ -883,14 +883,21 @@ export async function detectContainerRuntime(
883
883
  appleUnavailable(preference, selectedAppleRuntime(deps) === null, deps)
884
884
  );
885
885
  }
886
- // ADR-106: prefer the bundled runtime wherever it can run — ambient
887
- // Docker existing is not a reason to skip it. A failed activation falls
888
- // through to Docker rather than degrading a machine that has one.
886
+ // ADR-119 (inverting ADR-106): a present, healthy ambient Docker/OrbStack
887
+ // daemon wins it is the battle-tested path, and `auto` must never
888
+ // migrate a machine off a working daemon it already trusts. The bundled
889
+ // runtime is the zero-dependency fallback for machines with no daemon:
890
+ // an absent/remote/unanswering Docker on a machine that never chose
891
+ // Docker is not an error to surface. One probe serves both outcomes —
892
+ // its rendered state is reused if the bundled runtime also fails.
893
+ const ambient = await detectAmbientDockerCore(preference, deps);
894
+ if (ambient.usable) return ambient.state;
889
895
  const selected = await probeAndSelectApple(preference, deps);
890
896
  if (selected !== null) return selected;
897
+ return ambient.state;
891
898
  }
892
899
 
893
- return detectAmbientDocker(preference, deps);
900
+ return (await detectAmbientDockerCore(preference, deps)).state;
894
901
  }
895
902
 
896
903
  function appleUnavailable(
@@ -915,10 +922,19 @@ function appleUnavailable(
915
922
  };
916
923
  }
917
924
 
918
- async function detectAmbientDocker(
925
+ /**
926
+ * Ambient-Docker detection. `usable: false` marks the ADR-119 fall-through
927
+ * verdicts — an absent daemon, a remote endpoint, an unresolvable context —
928
+ * so `auto` can try the bundled runtime instead of surfacing Docker's
929
+ * errors on a machine that never chose Docker, while still carrying the
930
+ * rendered state for callers that need it (explicit `docker`, and auto once
931
+ * the bundled runtime also failed). A ready selection and a pin conflict
932
+ * are both `usable: true`: neither may fall through.
933
+ */
934
+ async function detectAmbientDockerCore(
919
935
  preference: "auto" | "docker",
920
936
  deps: RuntimeDetectionDeps,
921
- ): Promise<ContainerRuntimeState> {
937
+ ): Promise<{ usable: boolean; state: ContainerRuntimeState }> {
922
938
  // A successful ambient `docker info` is not a durable selection: collapse a
923
939
  // named context to its exact local endpoint before publishing DOCKER_HOST.
924
940
  const selection =
@@ -929,24 +945,30 @@ async function detectAmbientDocker(
929
945
  : null;
930
946
  if (selection && !isLocalDockerEndpoint(selection.host)) {
931
947
  return {
932
- status: "no-runtime",
933
- preference,
934
- message:
935
- "The selected Docker endpoint is remote. " +
936
- "Uai Host requires a local Unix-socket daemon for workspace mounts and loopback tunnels. " +
937
- `Select a local Docker context, then run \`${RUNTIME_RESTART_COMMAND}\`.`,
938
- action: RUNTIME_RESTART_COMMAND,
948
+ usable: false,
949
+ state: {
950
+ status: "no-runtime",
951
+ preference,
952
+ message:
953
+ "The selected Docker endpoint is remote. " +
954
+ "Uai Host requires a local Unix-socket daemon for workspace mounts and loopback tunnels. " +
955
+ `Select a local Docker context, then run \`${RUNTIME_RESTART_COMMAND}\`.`,
956
+ action: RUNTIME_RESTART_COMMAND,
957
+ },
939
958
  };
940
959
  }
941
960
 
942
961
  if (deps.dockerInfo === undefined && selection === null) {
943
962
  return {
944
- status: "no-runtime",
945
- preference,
946
- message:
947
- "Uai Host could not resolve the selected Docker context to a local endpoint. " +
948
- `Select a local Docker context and run \`${RUNTIME_RECHECK_COMMAND}\`.`,
949
- action: RUNTIME_RECHECK_COMMAND,
963
+ usable: false,
964
+ state: {
965
+ status: "no-runtime",
966
+ preference,
967
+ message:
968
+ "Uai Host could not resolve the selected Docker context to a local endpoint. " +
969
+ `Select a local Docker context and run \`${RUNTIME_RECHECK_COMMAND}\`.`,
970
+ action: RUNTIME_RECHECK_COMMAND,
971
+ },
950
972
  };
951
973
  }
952
974
 
@@ -954,20 +976,26 @@ async function detectAmbientDocker(
954
976
  if (proof !== null) {
955
977
  if (selection) {
956
978
  if (!pinRuntime("docker", selection.host, proof.engineId)) {
957
- return pinnedRuntimeUnavailable(preference);
979
+ return { usable: true, state: pinnedRuntimeUnavailable(preference) };
958
980
  }
959
981
  }
960
- return { status: "ready", preference, provider: "docker" };
982
+ return {
983
+ usable: true,
984
+ state: { status: "ready", preference, provider: "docker" },
985
+ };
961
986
  }
962
987
  return {
963
- status: "no-runtime",
964
- preference,
965
- message:
966
- preference === "docker"
967
- ? "No working Docker runtime was found. Install or start Docker, then run " +
968
- `\`${RUNTIME_RECHECK_COMMAND}\`.`
969
- : NO_RUNTIME_MESSAGE,
970
- action: RUNTIME_RECHECK_COMMAND,
988
+ usable: false,
989
+ state: {
990
+ status: "no-runtime",
991
+ preference,
992
+ message:
993
+ preference === "docker"
994
+ ? "No working Docker runtime was found. Install or start Docker, then run " +
995
+ `\`${RUNTIME_RECHECK_COMMAND}\`.`
996
+ : NO_RUNTIME_MESSAGE,
997
+ action: RUNTIME_RECHECK_COMMAND,
998
+ },
971
999
  };
972
1000
  }
973
1001
 
@@ -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.64",
3
+ "version": "0.9.66",
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/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
  });