@runuai/host 0.9.13 → 0.9.42

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.
Files changed (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
package/README.md CHANGED
@@ -6,7 +6,7 @@ that runs parallel AI coding tasks in ephemeral Docker containers on a machine
6
6
  your machine to the Uai cloud and runs the task containers locally. It also
7
7
  serves a small read-only monitor UI on `127.0.0.1` (ADR-028).
8
8
 
9
- One process does two things:
9
+ One process does three things:
10
10
 
11
11
  - **Cloud bridge** — a host-initiated *outbound* WSS to
12
12
  `wss://app.runuai.com/host` carrying every command/tunnel. The cloud never
@@ -14,6 +14,9 @@ One process does two things:
14
14
  - **Local UI** — an HTTP server on `127.0.0.1:5876` (next free port if taken):
15
15
  connection badge, service info, active tasks, recent events. Localhost only,
16
16
  no auth, read-only — mutations come from the cloud, not here.
17
+ - **MCP gateway** — a credential-injecting HTTP proxy for task containers,
18
+ bound to `127.0.0.1:5877` by default. Listener failures retry with capped
19
+ backoff and are advertised to the cloud as bounded health state.
17
20
 
18
21
  ## Requirements
19
22
 
@@ -40,9 +43,10 @@ uai-host install # per-user service: launchd (macOS) / systemd --user (Linu
40
43
  uai-host start
41
44
  ```
42
45
 
43
- The host generates its **own** bridge credential locally and sends the cloud
44
- only a hash the secret never leaves the machine (ADR-015). Once started it
45
- appears in your org and can run tasks. Data + config live under `~/.uai`
46
+ The host generates its **own** bridge credential locally. The cloud persists
47
+ only its hash; the raw credential transits authenticated TLS for bridge auth
48
+ and existing-host attachment but is not stored there (ADR-015). Once started
49
+ it appears in your org and can run tasks. Data + config live under `~/.uai`
46
50
  (`~/.uai/.env.local`, `~/.uai/data/`); from a repo checkout they stay under the
47
51
  repo instead. The monitor UI is at `http://127.0.0.1:5876` (exact port in
48
52
  `uai-host status`).
@@ -51,7 +55,11 @@ repo instead. The monitor UI is at `http://127.0.0.1:5876` (exact port in
51
55
 
52
56
  ```
53
57
  setup --cloud <wss-url> --enroll <token>
54
- claim this machine via an enrollment token (writes config)
58
+ claim this machine, or enroll its existing identity in
59
+ the token's workspace
60
+ enroll <token> [--cloud <wss-url>]
61
+ first-time setup, or attach this machine to another
62
+ workspace; uses the stored cloud URL when available
55
63
  install [--dry-run] install as a per-user service for this OS
56
64
  uninstall remove the service
57
65
  start | stop control the installed service
@@ -63,6 +71,9 @@ pair <token> store a host token directly (manual fallback for setup)
63
71
  open open the local UI in your browser
64
72
  ```
65
73
 
74
+ `enroll` is available in host version 0.9.15 and newer. Its compatible cloud
75
+ endpoint is intentionally deployed before that host release.
76
+
66
77
  `--dry-run` on `install` (and start/stop/…) prints the unit file + load
67
78
  commands without touching launchd/systemd.
68
79
 
@@ -72,6 +83,12 @@ Runs identically with no display: install via the same flow, skip `uai-host
72
83
  open`, and use `uai-host status` for everything the UI shows. The UI stays
73
84
  reachable over an SSH port-forward to `127.0.0.1:5876` if you want it.
74
85
 
86
+ At boot, task and channel starts wait for the initial agent-CLI maintenance
87
+ pass to settle before any in-container writer or runner executes. The cloud
88
+ host page shows this short preparation state. It also shows MCP gateway
89
+ listener failures while the host retries them; gateway health is independent
90
+ of ordinary task admission.
91
+
75
92
  ## Security
76
93
 
77
94
  Tasks run in ephemeral containers and **do not** mount your live AI session
@@ -0,0 +1 @@
1
+ CREATE INDEX `uai_host_events_task_id_idx` ON `uai_host_events` (`task_id`);
@@ -0,0 +1,9 @@
1
+ CREATE TABLE `host_settings` (
2
+ `id` text PRIMARY KEY DEFAULT 'host' NOT NULL,
3
+ `retain_terminal_history` integer DEFAULT true NOT NULL,
4
+ `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
5
+ CONSTRAINT "host_settings_singleton" CHECK (`host_settings`.`id` = 'host'),
6
+ CONSTRAINT "host_settings_retention_boolean" CHECK (`host_settings`.`retain_terminal_history` IN (0, 1))
7
+ );
8
+ --> statement-breakpoint
9
+ INSERT INTO `host_settings` (`id`, `retain_terminal_history`) VALUES ('host', true);
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `uai_host_tasks` ADD `environment_provider` text;--> statement-breakpoint
2
+ ALTER TABLE `uai_host_tasks` ADD `environment_locator` text CONSTRAINT `uai_host_tasks_environment_pair` CHECK ((`environment_provider` IS NULL) = (`environment_locator` IS NULL));
@@ -99,6 +99,27 @@
99
99
  "when": 1786045000000,
100
100
  "tag": "0013_github_credential_generations",
101
101
  "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "6",
106
+ "when": 1786406400000,
107
+ "tag": "0014_host_inventory_event_index",
108
+ "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "6",
113
+ "when": 1786406401000,
114
+ "tag": "0015_host_settings",
115
+ "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "6",
120
+ "when": 1786406402000,
121
+ "tag": "0016_task_environment",
122
+ "breakpoints": true
102
123
  }
103
124
  ]
104
125
  }
package/db/schema.ts CHANGED
@@ -9,49 +9,99 @@
9
9
  import { sql } from "drizzle-orm";
10
10
  import {
11
11
  blob,
12
+ check,
13
+ index,
12
14
  integer,
13
15
  primaryKey,
14
16
  sqliteTable,
15
17
  text,
16
18
  } from "drizzle-orm/sqlite-core";
17
19
 
18
- export const hostTasks = sqliteTable("uai_host_tasks", {
19
- taskId: text("task_id").primaryKey(),
20
- codeServerPort: integer("code_server_port"),
21
- previewPorts: text("preview_ports").notNull().default("[]"),
22
- composeProject: text("compose_project"),
23
- worktreePath: text("worktree_path"),
24
- statusMirror: text("status_mirror"),
25
- // Owner + project slugs — received in TaskLaunchInput at task-up and
26
- // persisted here so the local UI (ADR-028) can show "who/what" without a
27
- // cloud round-trip. The cloud stays the audit authority.
28
- ownerUserId: text("owner_user_id"),
29
- ownerEmail: text("owner_email"),
30
- ownerName: text("owner_name"),
31
- projectSlugs: text("project_slugs").notNull().default("[]"),
32
- lockedAt: integer("locked_at", { mode: "number" }),
33
- startedAt: integer("started_at", { mode: "number" }),
34
- endedAt: integer("ended_at", { mode: "number" }),
35
- updatedAt: integer("updated_at", { mode: "number" })
36
- .notNull()
37
- .default(sql`(unixepoch() * 1000)`),
38
- });
20
+ export const hostTasks = sqliteTable(
21
+ "uai_host_tasks",
22
+ {
23
+ taskId: text("task_id").primaryKey(),
24
+ codeServerPort: integer("code_server_port"),
25
+ previewPorts: text("preview_ports").notNull().default("[]"),
26
+ composeProject: text("compose_project"),
27
+ worktreePath: text("worktree_path"),
28
+ // ADR-101 Phase C. Null rows predate the provider seam and are inferred as
29
+ // Docker Compose only when their legacy runtime identity is complete.
30
+ environmentProvider: text("environment_provider"),
31
+ environmentLocator: text("environment_locator"),
32
+ statusMirror: text("status_mirror"),
33
+ // Owner + project slugs — received in TaskLaunchInput at task-up and
34
+ // persisted here so the local UI (ADR-028) can show "who/what" without a
35
+ // cloud round-trip. The cloud stays the audit authority.
36
+ ownerUserId: text("owner_user_id"),
37
+ ownerEmail: text("owner_email"),
38
+ ownerName: text("owner_name"),
39
+ projectSlugs: text("project_slugs").notNull().default("[]"),
40
+ lockedAt: integer("locked_at", { mode: "number" }),
41
+ startedAt: integer("started_at", { mode: "number" }),
42
+ endedAt: integer("ended_at", { mode: "number" }),
43
+ updatedAt: integer("updated_at", { mode: "number" })
44
+ .notNull()
45
+ .default(sql`(unixepoch() * 1000)`),
46
+ },
47
+ (table) => ({
48
+ environmentPair: check(
49
+ "uai_host_tasks_environment_pair",
50
+ sql`(${table.environmentProvider} IS NULL) = (${table.environmentLocator} IS NULL)`,
51
+ ),
52
+ }),
53
+ );
39
54
 
40
55
  export type HostTask = typeof hostTasks.$inferSelect;
41
56
  export type NewHostTask = typeof hostTasks.$inferInsert;
42
57
 
58
+ // Singleton host-local settings row (ADR-100). Runtime switches that must be
59
+ // visible before SQLite opens remain in `.env.local`; retention policy belongs
60
+ // here because it governs owner-facing local history only. Correctness
61
+ // tombstones and orphan-GC evidence are never controlled by this setting.
62
+ export const hostSettings = sqliteTable(
63
+ "host_settings",
64
+ {
65
+ id: text("id").primaryKey().default("host"),
66
+ retainTerminalHistory: integer("retain_terminal_history", {
67
+ mode: "boolean",
68
+ })
69
+ .notNull()
70
+ .default(true),
71
+ updatedAt: integer("updated_at", { mode: "number" })
72
+ .notNull()
73
+ .default(sql`(unixepoch() * 1000)`),
74
+ },
75
+ (table) => ({
76
+ singleton: check("host_settings_singleton", sql`${table.id} = 'host'`),
77
+ retentionBoolean: check(
78
+ "host_settings_retention_boolean",
79
+ sql`${table.retainTerminalHistory} IN (0, 1)`,
80
+ ),
81
+ }),
82
+ );
83
+
84
+ export type HostSettings = typeof hostSettings.$inferSelect;
85
+ export type NewHostSettings = typeof hostSettings.$inferInsert;
86
+
43
87
  // Host-side task lifecycle event log (ADR-028). HostEvent (the agent-output
44
88
  // stream) is forwarded to the cloud, not stored; this table is the local
45
89
  // `/api/events` feed — appended on task.created/started/ended (+ ship).
46
- export const hostEvents = sqliteTable("uai_host_events", {
47
- id: text("id").primaryKey(), // ulid
48
- taskId: text("task_id").notNull(),
49
- kind: text("kind").notNull(), // task.created | task.started | task.ended | task.ship
50
- ts: integer("ts", { mode: "number" })
51
- .notNull()
52
- .default(sql`(unixepoch() * 1000)`),
53
- detail: text("detail"),
54
- });
90
+ export const hostEvents = sqliteTable(
91
+ "uai_host_events",
92
+ {
93
+ id: text("id").primaryKey(), // ulid
94
+ taskId: text("task_id").notNull(),
95
+ kind: text("kind").notNull(), // task.created | task.started | task.ended | task.ship
96
+ ts: integer("ts", { mode: "number" })
97
+ .notNull()
98
+ .default(sql`(unixepoch() * 1000)`),
99
+ detail: text("detail"),
100
+ },
101
+ (t) => ({
102
+ taskIdIdx: index("uai_host_events_task_id_idx").on(t.taskId),
103
+ }),
104
+ );
55
105
 
56
106
  export type HostEventRow = typeof hostEvents.$inferSelect;
57
107
  export type NewHostEventRow = typeof hostEvents.$inferInsert;
@@ -10,8 +10,9 @@
10
10
  # Design points:
11
11
  # - Debian bookworm-slim base, non-root `node` user (home /home/node).
12
12
  # - asdf at /opt/asdf with plugins for nodejs/python/golang/ruby/rust.
13
- # Concrete runtime versions are installed lazily at task-up by uai-init,
14
- # cached on the host-wide named volume mounted at /opt/asdf-data.
13
+ # Before Compose starts a task, a host-controlled maintenance container
14
+ # materializes its declared versions into the host-wide /opt/asdf-data
15
+ # volume. uai-init only installs task-local dependencies afterward.
15
16
  # - A default ~/.tool-versions (asdf global fallback) pins a working Node +
16
17
  # Python so the agent CLIs and a 0-project scratchpad always have a runtime;
17
18
  # a project's own .tool-versions takes precedence (asdf dir walk).
@@ -65,6 +66,7 @@ RUN apt-get update \
65
66
  tk-dev \
66
67
  tmux \
67
68
  unzip \
69
+ util-linux \
68
70
  xz-utils \
69
71
  zlib1g-dev \
70
72
  zsh \
@@ -121,18 +123,33 @@ RUN groupadd --gid 1000 node \
121
123
 
122
124
  ENV ASDF_DIR=/opt/asdf
123
125
  ENV ASDF_DATA_DIR=/opt/asdf-data
126
+ ENV ASDF_CONFIG_FILE=/dev/null
127
+ ENV ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions
128
+ ENV COREPACK_HOME=/opt/asdf-data/corepack
129
+ ENV COREPACK_DEFAULT_TO_LATEST=0
130
+ ENV COREPACK_ENABLE_AUTO_PIN=0
131
+ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
132
+ ENV COREPACK_ENV_FILE=0
133
+ ENV COREPACK_ENABLE_PROJECT_SPEC=1
134
+ ENV COREPACK_ENABLE_STRICT=1
135
+ ENV COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0
136
+ ENV COREPACK_INTEGRITY_CHECK=1
137
+ ENV COREPACK_ENABLE_NETWORK=0
138
+ ENV COREPACK_NPM_REGISTRY=https://registry.npmjs.org
124
139
  # Leave the tool-versions filename at asdf's default (`.tool-versions`) so asdf
125
140
  # walks UP from the cwd and a project's /workspace/<slug>/.tool-versions wins.
126
141
  # The baked default lives at the node user's home (~/.tool-versions) as asdf's
127
142
  # global fallback for tasks with no project file (agent CLIs, scratchpad).
128
- # Do NOT set ASDF_DEFAULT_TOOL_VERSIONS_FILENAME to an absolute path that
129
- # overrides the search *filename* and bypasses project files entirely (it made
143
+ # Keep ASDF_DEFAULT_TOOL_VERSIONS_FILENAME as the basename above, never an
144
+ # absolute path: an absolute value bypasses project files entirely (it made
130
145
  # asdf read only /etc/.tool-versions, ignoring per-project pins).
131
146
 
132
147
  RUN git clone --depth 1 --branch "$ASDF_VERSION" https://github.com/asdf-vm/asdf.git "$ASDF_DIR" \
133
148
  && mkdir -p "$ASDF_DATA_DIR" \
149
+ && touch "$ASDF_DATA_DIR/.uai-maintenance.lock" \
134
150
  && chown -R node:node "$ASDF_DIR" "$ASDF_DATA_DIR" \
135
- && chmod -R g+rwX "$ASDF_DATA_DIR"
151
+ && chmod -R g+rwX "$ASDF_DATA_DIR" \
152
+ && chmod 0664 "$ASDF_DATA_DIR/.uai-maintenance.lock"
136
153
 
137
154
  # Make asdf available on PATH + shell init for BOTH root and node, for login
138
155
  # and non-login shells (uai-init runs as node via a non-interactive exec).
@@ -172,8 +189,8 @@ RUN su node -c 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/oh
172
189
  # 4. asdf plugins + the root default runtimes.
173
190
  #
174
191
  # Plugins are added for all common runtimes; only the root defaults are
175
- # *installed* at build time. Other versions are installed lazily by uai-init
176
- # and cached on the /opt/asdf-data volume.
192
+ # *installed* at build time. A pre-Compose maintenance container installs
193
+ # project versions into the shared /opt/asdf-data volume.
177
194
  #
178
195
  # Run as node so the install lands in /opt/asdf-data owned by node.
179
196
  # ---------------------------------------------------------------------------
@@ -183,14 +200,17 @@ RUN su node -c 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/oh
183
200
  # scratchpad tasks). A project file takes precedence via asdf's dir walk. The
184
201
  # `tool-versions` file in this directory is the AUTHORITATIVE source.
185
202
  COPY tool-versions /home/node/.tool-versions
186
- RUN chmod 0644 /home/node/.tool-versions \
203
+ COPY --chown=root:root container/corepack-version /usr/local/share/uai/corepack-version
204
+ COPY --chown=root:root container/uai-materialize-runtimes /usr/local/bin/uai-materialize-runtimes
205
+ RUN chmod 0644 /home/node/.tool-versions /usr/local/share/uai/corepack-version \
206
+ && chmod 0755 /usr/local/bin/uai-materialize-runtimes \
187
207
  && chown node:node /home/node/.tool-versions
188
208
 
189
209
  USER node
190
210
 
191
211
  # Add plugins for all common runtimes, then install exactly the versions the
192
212
  # default ~/.tool-versions pins (read straight from the file so there is one
193
- # source of truth). Other versions install lazily at task-up via uai-init.
213
+ # source of truth). Other versions install in the pre-Compose materializer.
194
214
  RUN bash -lc '\
195
215
  set -e; \
196
216
  . /opt/asdf/asdf.sh; \
@@ -214,7 +234,13 @@ RUN bash -lc '\
214
234
  RUN bash -lc '\
215
235
  set -e; \
216
236
  . /opt/asdf/asdf.sh; \
217
- npm install -g @anthropic-ai/claude-code @openai/codex; \
237
+ COREPACK_VERSION="$(sed -n "1p" /usr/local/share/uai/corepack-version)"; \
238
+ printf "%s" "$COREPACK_VERSION" | grep -Eq "^[0-9]+\\.[0-9]+\\.[0-9]+$"; \
239
+ npm install -g @anthropic-ai/claude-code @openai/codex "corepack@$COREPACK_VERSION" \
240
+ --registry=https://registry.npmjs.org/ \
241
+ --userconfig=/dev/null \
242
+ --globalconfig=/dev/uai-empty-npmrc; \
243
+ corepack enable; \
218
244
  curl -fsSL https://code-server.dev/install.sh | sh -s -- --method standalone --prefix /home/node/.local; \
219
245
  asdf reshim nodejs; \
220
246
  '
@@ -13,10 +13,12 @@ See [`docs/runtime.md`](../../../docs/runtime.md) for the authoritative spec.
13
13
  uid/gid 1000). The container runs as `node`; uai-init and the agent CLIs
14
14
  expect `/home/node`.
15
15
  - **asdf** at `/opt/asdf`, data dir `/opt/asdf-data` (the host-wide
16
- `uai-asdf-data` volume mounts there). Plugins pre-added for `nodejs`,
17
- `python`, `golang`, `ruby`, `rust`. Only the root defaults are installed at
18
- build time; other versions install lazily at task-up via `uai-init` and are
19
- cached on the data volume.
16
+ `uai-asdf-data` volume mounts there read-only in task apps). Plugins
17
+ pre-added for `nodejs`, `python`, `golang`, `ruby`, `rust`. Only the root
18
+ defaults are installed at build time; after worktree resolution, a
19
+ host-controlled pre-start maintenance container is the bounded, locked
20
+ read-write user that installs and proves other versions before the task app
21
+ exists.
20
22
  - **Default `~/.tool-versions`** (asdf's global fallback, at `/home/node`)
21
23
  pinning a default Node + Python. The `tool-versions` file in this directory is
22
24
  the authoritative source for the numbers; the `Dockerfile` `COPY`s it to
@@ -37,7 +39,18 @@ See [`docs/runtime.md`](../../../docs/runtime.md) for the authoritative spec.
37
39
  - **code-server** (the Editor pane), **gh**, **git**, **curl**,
38
40
  **ca-certificates**, **jq**, **ripgrep**, **tmux**, **direnv**, **uv**,
39
41
  plus `build-essential` and the `-dev` libraries asdf's build plugins need.
40
- - **`uai-init`** baked at `/usr/local/bin/uai-init`.
42
+ - **`uai-materialize-runtimes`** baked at
43
+ `/usr/local/bin/uai-materialize-runtimes`; it is the credential-free,
44
+ workspace-read-only shared-cache writer used before Compose starts a task.
45
+ The host supplies the selected project slugs at the fixed, read-only
46
+ `/run/uai/runtime-projects` allowlist; only those immediate Git worktrees are
47
+ inspected, so workspace control/shared directories cannot become projects.
48
+ - **`uai-init`** baked at `/usr/local/bin/uai-init`; it installs task-local
49
+ dependencies and starts code-server, but does not mutate asdf runtimes.
50
+ - **Task-private npm globals** under `/home/node/.local/share/uai-npm`, with
51
+ cache/logs under `/home/node/.cache/uai-npm`. The shared asdf shims and
52
+ system controls remain ahead of this prefix's `bin` on `PATH`, and
53
+ `ASDF_SKIP_RESHIM=1` prevents npm's asdf hook from writing shared shims.
41
54
  - `WORKDIR /workspace`; `CMD ["sleep","infinity"]`.
42
55
 
43
56
  Codex credentials are **not** baked in or mounted — task-up `docker cp`s them
@@ -50,7 +63,8 @@ is bind-mounted read-only by the generated compose.
50
63
  | --- | --- |
51
64
  | Standard image tag | `uai-standard:dev` |
52
65
  | Per-task derived image (only with `extra`) | `uai-task-<taskId>` |
53
- | Shared asdf data volume | `uai-asdf-data` → `/opt/asdf-data` |
66
+ | Shared asdf data volume | app: `uai-asdf-data:/opt/asdf-data:ro`; maintenance: `:rw` |
67
+ | Task npm prefix / cache | `/home/node/.local/share/uai-npm` / `/home/node/.cache/uai-npm` |
54
68
  | Compose service name | `app` (container `task-<taskId>-app-1`) |
55
69
  | In-container workspace | `/workspace` |
56
70
  | code-server port | `8080` (inside the container) |
@@ -77,37 +91,61 @@ steps below are for local image iteration only.
77
91
  # 1. Build.
78
92
  docker build -t uai-standard:dev host-agent/images/standard
79
93
 
80
- # 2. Create the shared asdf data volume + a throwaway workspace.
94
+ # 2. Create the shared asdf data volume + a throwaway Git worktree and the
95
+ # host-controlled selected-project allowlist.
81
96
  docker volume create uai-asdf-data
82
- mkdir -p /tmp/uai-smoke/workspace/demo
97
+ mkdir -p /tmp/uai-smoke/workspace/demo/.git
83
98
  printf 'nodejs 22.11.0\n' > /tmp/uai-smoke/workspace/demo/.tool-versions
84
- printf '{"name":"demo","packageManager":"npm@10"}\n' \
99
+ printf '{"name":"demo","packageManager":"pnpm@10.15.0"}\n' \
85
100
  > /tmp/uai-smoke/workspace/demo/package.json
101
+ printf 'demo\n' > /tmp/uai-smoke/runtime-projects
86
102
 
87
- # 3. Run the container the way compose would (service `app`, workspace bind,
103
+ # 3. Materialize before the app exists, the way task-up does.
104
+ docker run --rm --user node --workdir /workspace \
105
+ -v /tmp/uai-smoke/workspace:/workspace:ro \
106
+ -v /tmp/uai-smoke/runtime-projects:/run/uai/runtime-projects:ro \
107
+ -v uai-asdf-data:/opt/asdf-data:rw \
108
+ uai-standard:dev /usr/bin/timeout --signal=TERM --kill-after=10s 600s \
109
+ /usr/local/bin/uai-materialize-runtimes
110
+
111
+ # 4. Run the container the way compose would (service `app`, workspace bind,
88
112
  # asdf cache volume, code-server port published on loopback).
89
113
  docker run -d --name uai-smoke \
90
114
  -v /tmp/uai-smoke/workspace:/workspace \
91
- -v uai-asdf-data:/opt/asdf-data \
115
+ -v /tmp/uai-smoke/runtime-projects:/run/uai/runtime-projects:ro \
116
+ -v uai-asdf-data:/opt/asdf-data:ro \
117
+ -e ASDF_SKIP_RESHIM=1 \
118
+ -e NPM_CONFIG_PREFIX=/home/node/.local/share/uai-npm \
119
+ -e npm_config_prefix=/home/node/.local/share/uai-npm \
120
+ -e NPM_CONFIG_CACHE=/home/node/.cache/uai-npm \
121
+ -e npm_config_cache=/home/node/.cache/uai-npm \
122
+ -e NPM_CONFIG_LOGS_DIR=/home/node/.cache/uai-npm/_logs \
123
+ -e npm_config_logs_dir=/home/node/.cache/uai-npm/_logs \
124
+ -e NPM_CONFIG_UMASK=0002 -e npm_config_umask=0002 \
125
+ -e 'PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/node/.local/share/uai-npm/bin' \
92
126
  -p 127.0.0.1::8080 \
93
127
  uai-standard:dev
94
128
 
95
- # 4. Sanity-check the baked toolchain (bare exec — no profile sourced).
129
+ # 5. Prove isolation and sanity-check the baked toolchain (bare exec — no
130
+ # profile sourced).
131
+ docker exec uai-smoke sh -c \
132
+ '! touch /opt/asdf-data/.uai-app-write-probe 2>/dev/null && test "$(npm config get prefix)" = /home/node/.local/share/uai-npm'
96
133
  docker exec uai-smoke bash -lc 'asdf --version && node -v && python --version'
97
134
  docker exec uai-smoke claude --version
98
135
  docker exec uai-smoke codex --version
99
136
 
100
- # 5. Run uai-init: per-folder asdf install + deps, then code-server on 8080.
137
+ # 6. Run uai-init: task-local deps + code-server on 8080.
101
138
  docker exec uai-smoke /usr/local/bin/uai-init
102
139
  docker exec uai-smoke bash -lc 'curl -sf http://127.0.0.1:8080/healthz || \
103
140
  curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/'
104
141
 
105
- # 6. Tear down.
142
+ # 7. Tear down.
106
143
  docker rm -f uai-smoke
107
144
  ```
108
145
 
109
- Expected: step 4 prints the asdf version and the root-default Node + Python;
110
- `claude` / `codex` resolve; step 5 launches code-server bound to
146
+ Expected: step 5 proves the app cannot write the shared cache and npm resolves
147
+ the private prefix, then prints the asdf version and root-default Node +
148
+ Python; `claude` / `codex` resolve; step 6 launches code-server bound to
111
149
  `0.0.0.0:8080` and the HTTP probe returns a 2xx/3xx.
112
150
 
113
151
  ## Notes / gotchas
@@ -118,5 +156,12 @@ Expected: step 4 prints the asdf version and the root-default Node + Python;
118
156
  - **First-use asdf cost.** The first task needing a given runtime version pays
119
157
  the download/build time; results persist on `uai-asdf-data` for every later
120
158
  task on the host.
121
- - **`uai-init` always exits 0.** A failing per-folder install logs and is
122
- skipped so one bad repo never blocks task-up; code-server is still launched.
159
+ - **Rolling recovery is preserve-and-recreate.** Before shared maintenance, an
160
+ upgraded host stops legacy writable/non-current apps and leaves their
161
+ worktrees intact. Docker mount modes are immutable, so only an exact current
162
+ read-only app can recover in place; explicit Resume recreates anything else.
163
+ - **Ordinary `uai-init` failures remain best-effort.** A failing task-local
164
+ dependency install logs and is skipped so one bad repo never blocks editor
165
+ startup. Runtime materialization is separate and fail-closed before the app
166
+ starts; a workspace runtime-authority violation exits 78 so the host can
167
+ quarantine the container instead of treating it as ready.
@@ -0,0 +1 @@
1
+ 0.33.0