@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.
- package/lib/github-tokens.ts +14 -2
- package/lib/orchestrator.ts +74 -6
- package/lib/task-environment/apple-container.ts +7 -0
- package/lib/task-inventory.ts +101 -4
- package/package.json +1 -1
- package/scripts/agent/_common.sh +23 -0
- package/scripts/agent/task-down.sh +32 -1
- package/scripts/agent/task-up.sh +96 -6
- package/src/index.ts +4 -2
- package/src/main.ts +9 -6
- package/src/protocol.ts +5 -2
package/lib/github-tokens.ts
CHANGED
|
@@ -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
|
|
1769
|
+
if (attempt === 0 && taskStillActive) {
|
|
1770
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
1771
|
+
}
|
|
1760
1772
|
scheduleGithubRetry(
|
|
1761
1773
|
taskId,
|
|
1762
1774
|
userId,
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
-
*
|
|
5664
|
-
* exactly once.
|
|
5665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/task-inventory.ts
CHANGED
|
@@ -31,7 +31,10 @@ import {
|
|
|
31
31
|
taskContainerBackend,
|
|
32
32
|
taskContainerCli,
|
|
33
33
|
} from "./task-container-cli";
|
|
34
|
-
import {
|
|
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
|
|
443
|
-
//
|
|
444
|
-
// exact not-found grammars (a
|
|
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
package/scripts/agent/_common.sh
CHANGED
|
@@ -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
|
|
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"
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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
|
|
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
|
-
]
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
|
466
|
-
`automatically; no action needed unless this
|
|
467
|
-
|
|
468
|
-
: `gh authentication expired (reason: ${
|
|
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(
|
|
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,
|