@runuai/host 0.1.0

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Diogo Perillo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @runuai/host
2
+
3
+ The **host** for [Uai](https://github.com/runuai/uai) — a self-hosted service
4
+ that runs parallel AI coding tasks in ephemeral Docker containers on a machine
5
+ **you** control. This package is the long-running per-user daemon that connects
6
+ your machine to the Uai cloud and runs the task containers locally. It also
7
+ serves a small read-only monitor UI on `127.0.0.1` (ADR-028).
8
+
9
+ One process does two things:
10
+
11
+ - **Cloud bridge** — a host-initiated *outbound* WSS to
12
+ `wss://app.runuai.com/host` carrying every command/tunnel. The cloud never
13
+ connects in; nothing inbound is exposed.
14
+ - **Local UI** — an HTTP server on `127.0.0.1:5876` (next free port if taken):
15
+ connection badge, service info, active tasks, recent events. Localhost only,
16
+ no auth, read-only — mutations come from the cloud, not here.
17
+
18
+ ## Requirements
19
+
20
+ - **Docker** (running) — tasks are containers.
21
+ - **Node ≥ 20.**
22
+
23
+ ## Install & enroll
24
+
25
+ ```bash
26
+ npm i -g @runuai/host
27
+ ```
28
+
29
+ Then, in the Uai web app, open an organization and choose **“Add a host.”** It
30
+ mints a one-time enrollment token and shows the exact command to run on this
31
+ machine (ADR-031):
32
+
33
+ ```bash
34
+ uai-host setup --cloud wss://app.runuai.com/host --enroll uaienroll_…
35
+ uai-host install # per-user service: launchd (macOS) / systemd --user (Linux)
36
+ uai-host start
37
+ ```
38
+
39
+ The host generates its **own** bridge credential locally and sends the cloud
40
+ only a hash — the secret never leaves the machine (ADR-015). Once started it
41
+ appears in your org and can run tasks. Data + config live under `~/.uai`
42
+ (`~/.uai/.env.local`, `~/.uai/data/`); from a repo checkout they stay under the
43
+ repo instead. The monitor UI is at `http://127.0.0.1:5876` (exact port in
44
+ `uai-host status`).
45
+
46
+ ## CLI (`uai-host`)
47
+
48
+ ```
49
+ setup --cloud <wss-url> --enroll <token>
50
+ claim this machine via an enrollment token (writes config)
51
+ install [--dry-run] install as a per-user service for this OS
52
+ uninstall remove the service
53
+ start | stop control the installed service
54
+ restart stop + start
55
+ status connection, service info, active tasks (same data as the UI)
56
+ logs [--follow] tail the service log
57
+ run run in the foreground (debug)
58
+ pair <token> store a host token directly (manual fallback for setup)
59
+ open open the local UI in your browser
60
+ ```
61
+
62
+ `--dry-run` on `install` (and start/stop/…) prints the unit file + load
63
+ commands without touching launchd/systemd.
64
+
65
+ ## Headless
66
+
67
+ Runs identically with no display: install via the same flow, skip `uai-host
68
+ open`, and use `uai-host status` for everything the UI shows. The UI stays
69
+ reachable over an SSH port-forward to `127.0.0.1:5876` if you want it.
70
+
71
+ ## Security
72
+
73
+ Tasks run in ephemeral containers and **do not** mount your live AI session
74
+ dirs. Per-task credentials (GitHub tokens, SSH keys) are decrypted only on the
75
+ host, using a host-resident master key; the cloud stores only hashes and public
76
+ keys. See [ADR-015](https://github.com/runuai/uai/blob/main/docs/decisions.md)
77
+ for the secret-blind model.
78
+
79
+ ## Dev (from a repo checkout)
80
+
81
+ `pnpm host-agent` (from the repo root) = `uai-host run` — the service in the
82
+ foreground, no install, logs to stdout. The packaged binary runs the same
83
+ TypeScript source through [tsx](https://github.com/privatenumber/tsx); there is
84
+ no build step (ADR-032).
85
+
86
+ ## More
87
+
88
+ - [Uai on GitHub](https://github.com/runuai/uai)
89
+ - [docs/host-ui.md](https://github.com/runuai/uai/blob/main/docs/host-ui.md) — the local UI surface.
90
+ - [docs/host-packaging.md](https://github.com/runuai/uai/blob/main/docs/host-packaging.md) — how this package is built and published.
91
+ </content>
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Published entrypoint for `@runuai/host` (ADR-032).
4
+ *
5
+ * The host ships TypeScript source and runs it through tsx — no build step —
6
+ * so every runtime path (drizzle migrations, scripts/agent/*.sh, the
7
+ * images/standard build context) resolves off its source file exactly as in
8
+ * dev. This shim registers tsx's ESM loader, then hands off to src/cli.ts.
9
+ * argv passes straight through: src/cli.ts reads process.argv.slice(2).
10
+ */
11
+ import { register } from "tsx/esm/api";
12
+
13
+ register();
14
+ await import(new URL("../src/cli.ts", import.meta.url).href);
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `uai_host_tasks` (
2
+ `task_id` text PRIMARY KEY NOT NULL,
3
+ `code_server_port` integer,
4
+ `preview_ports` text DEFAULT '[]' NOT NULL,
5
+ `compose_project` text,
6
+ `worktree_path` text,
7
+ `status_mirror` text,
8
+ `locked_at` integer,
9
+ `started_at` integer,
10
+ `ended_at` integer,
11
+ `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
12
+ );
@@ -0,0 +1,11 @@
1
+ ALTER TABLE `uai_host_tasks` ADD `owner_user_id` text;--> statement-breakpoint
2
+ ALTER TABLE `uai_host_tasks` ADD `owner_email` text;--> statement-breakpoint
3
+ ALTER TABLE `uai_host_tasks` ADD `project_slugs` text DEFAULT '[]' NOT NULL;--> statement-breakpoint
4
+ CREATE TABLE `uai_host_events` (
5
+ `id` text PRIMARY KEY NOT NULL,
6
+ `task_id` text NOT NULL,
7
+ `kind` text NOT NULL,
8
+ `ts` integer DEFAULT (unixepoch() * 1000) NOT NULL,
9
+ `detail` text
10
+ );--> statement-breakpoint
11
+ CREATE INDEX `uai_host_events_ts_idx` ON `uai_host_events` (`ts`);
@@ -0,0 +1,8 @@
1
+ CREATE TABLE `host_github_tokens` (
2
+ `user_id` text PRIMARY KEY NOT NULL,
3
+ `installation_id` integer NOT NULL,
4
+ `refresh_token_ct` blob NOT NULL,
5
+ `refresh_token_nonce` blob NOT NULL,
6
+ `refresh_token_expires_at` integer,
7
+ `updated_at` integer NOT NULL
8
+ );
@@ -0,0 +1,8 @@
1
+ CREATE TABLE `host_ssh_keys` (
2
+ `user_id` text PRIMARY KEY NOT NULL,
3
+ `public_key` text NOT NULL,
4
+ `private_key_ct` blob NOT NULL,
5
+ `private_key_nonce` blob NOT NULL,
6
+ `created_at` integer NOT NULL,
7
+ `updated_at` integer NOT NULL
8
+ );
@@ -0,0 +1 @@
1
+ ALTER TABLE `uai_host_tasks` ADD `owner_name` text;
@@ -0,0 +1,41 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1779900001000,
9
+ "tag": "0000_host_tasks",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1779900002000,
16
+ "tag": "0001_host_ui",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1779900003000,
23
+ "tag": "0002_host_github_tokens",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1779900004000,
30
+ "tag": "0003_host_ssh_keys",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1779900005000,
37
+ "tag": "0004_host_owner_name",
38
+ "breakpoints": true
39
+ }
40
+ ]
41
+ }
package/db/schema.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Host-local SQLite schema (ADR-020).
3
+ *
4
+ * The cloud DB owns metadata and lifecycle. Each host owns only the runtime
5
+ * fields needed to recover containers, discover local ports, and tunnel
6
+ * editor/preview traffic.
7
+ */
8
+
9
+ import { sql } from "drizzle-orm";
10
+ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
11
+
12
+ export const hostTasks = sqliteTable("uai_host_tasks", {
13
+ taskId: text("task_id").primaryKey(),
14
+ codeServerPort: integer("code_server_port"),
15
+ previewPorts: text("preview_ports").notNull().default("[]"),
16
+ composeProject: text("compose_project"),
17
+ worktreePath: text("worktree_path"),
18
+ statusMirror: text("status_mirror"),
19
+ // Owner + project slugs — received in TaskLaunchInput at task-up and
20
+ // persisted here so the local UI (ADR-028) can show "who/what" without a
21
+ // cloud round-trip. The cloud stays the audit authority.
22
+ ownerUserId: text("owner_user_id"),
23
+ ownerEmail: text("owner_email"),
24
+ ownerName: text("owner_name"),
25
+ projectSlugs: text("project_slugs").notNull().default("[]"),
26
+ lockedAt: integer("locked_at", { mode: "number" }),
27
+ startedAt: integer("started_at", { mode: "number" }),
28
+ endedAt: integer("ended_at", { mode: "number" }),
29
+ updatedAt: integer("updated_at", { mode: "number" })
30
+ .notNull()
31
+ .default(sql`(unixepoch() * 1000)`),
32
+ });
33
+
34
+ export type HostTask = typeof hostTasks.$inferSelect;
35
+ export type NewHostTask = typeof hostTasks.$inferInsert;
36
+
37
+ // Host-side task lifecycle event log (ADR-028). HostEvent (the agent-output
38
+ // stream) is forwarded to the cloud, not stored; this table is the local
39
+ // `/api/events` feed — appended on task.created/started/ended (+ ship).
40
+ export const hostEvents = sqliteTable("uai_host_events", {
41
+ id: text("id").primaryKey(), // ulid
42
+ taskId: text("task_id").notNull(),
43
+ kind: text("kind").notNull(), // task.created | task.started | task.ended | task.ship
44
+ ts: integer("ts", { mode: "number" })
45
+ .notNull()
46
+ .default(sql`(unixepoch() * 1000)`),
47
+ detail: text("detail"),
48
+ });
49
+
50
+ export type HostEventRow = typeof hostEvents.$inferSelect;
51
+ export type NewHostEventRow = typeof hostEvents.$inferInsert;
52
+
53
+ // GitHub user refresh token, encrypted at rest with the host master key
54
+ // (ADR-027). The ONLY place a GitHub secret lives at rest. Access tokens are
55
+ // never stored — they go straight into the container's gh config.
56
+ export const githubTokens = sqliteTable("host_github_tokens", {
57
+ userId: text("user_id").primaryKey(),
58
+ installationId: integer("installation_id").notNull(),
59
+ refreshTokenCt: blob("refresh_token_ct").notNull(),
60
+ refreshTokenNonce: blob("refresh_token_nonce").notNull(),
61
+ refreshTokenExpiresAt: integer("refresh_token_expires_at"),
62
+ updatedAt: integer("updated_at").notNull(),
63
+ });
64
+
65
+ export type GithubToken = typeof githubTokens.$inferSelect;
66
+ export type NewGithubToken = typeof githubTokens.$inferInsert;
67
+
68
+ // Per-user ed25519 SSH key (ADR-029). Generated ON the host; the private key
69
+ // lives encrypted at rest with the host master key (same secret-blind pattern
70
+ // as host_github_tokens — ADR-015). The cloud only ever sees `public_key`,
71
+ // which the user pastes into GitHub. Keyed by the internal cloud user id.
72
+ export const sshKeys = sqliteTable("host_ssh_keys", {
73
+ userId: text("user_id").primaryKey(),
74
+ publicKey: text("public_key").notNull(), // OpenSSH "ssh-ed25519 AAAA… uai-<user>"
75
+ privateKeyCt: blob("private_key_ct").notNull(),
76
+ privateKeyNonce: blob("private_key_nonce").notNull(),
77
+ createdAt: integer("created_at").notNull(),
78
+ updatedAt: integer("updated_at").notNull(),
79
+ });
80
+
81
+ export type SshKey = typeof sshKeys.$inferSelect;
82
+ export type NewSshKey = typeof sshKeys.$inferInsert;
@@ -0,0 +1,232 @@
1
+ # syntax=docker/dockerfile:1.7
2
+ #
3
+ # uai standard image — the ONE per-host workbench image (ADR-022).
4
+ #
5
+ # Built once per host at host startup and cached as `uai-standard:dev`.
6
+ # A task reuses this image unchanged unless a selected project declares an
7
+ # `extra` snippet, in which case task-up derives `uai-task-<id>` FROM this
8
+ # image. See ../../../docs/runtime.md.
9
+ #
10
+ # Design points:
11
+ # - Debian bookworm-slim base, non-root `node` user (home /home/node).
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.
15
+ # - A default ~/.tool-versions (asdf global fallback) pins a working Node +
16
+ # Python so the agent CLIs and a 0-project scratchpad always have a runtime;
17
+ # a project's own .tool-versions takes precedence (asdf dir walk).
18
+ # - Claude Code + Codex + code-server installed globally via the root Node.
19
+ #
20
+ # This Dockerfile takes NO build args and substitutes NO placeholders — it is
21
+ # fully static. Per-task variation lives in the derived `uai-task-<id>` image.
22
+
23
+ FROM debian:bookworm-slim
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 0. Pinned versions (single source of truth for this image).
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # asdf release to clone. Pinned for reproducible builds; bump deliberately.
30
+ ENV ASDF_VERSION=v0.14.1
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # 1. System packages.
34
+ #
35
+ # build-essential + the -dev libs cover what asdf's nodejs/python/ruby/rust
36
+ # build plugins need to compile a runtime from source on first use. unzip is
37
+ # needed by several asdf plugins; gnupg + ca-certificates for the gh apt repo.
38
+ # ---------------------------------------------------------------------------
39
+
40
+ ENV DEBIAN_FRONTEND=noninteractive
41
+
42
+ RUN apt-get update \
43
+ && apt-get install -y --no-install-recommends \
44
+ build-essential \
45
+ ca-certificates \
46
+ curl \
47
+ direnv \
48
+ git \
49
+ gnupg \
50
+ jq \
51
+ libbz2-dev \
52
+ libffi-dev \
53
+ liblzma-dev \
54
+ libncurses-dev \
55
+ libreadline-dev \
56
+ libsqlite3-dev \
57
+ libssl-dev \
58
+ libxml2-dev \
59
+ libxmlsec1-dev \
60
+ libyaml-dev \
61
+ openssh-client \
62
+ pkg-config \
63
+ procps \
64
+ ripgrep \
65
+ tk-dev \
66
+ tmux \
67
+ unzip \
68
+ xz-utils \
69
+ zlib1g-dev \
70
+ zsh \
71
+ && rm -rf /var/lib/apt/lists/*
72
+
73
+ # GitHub CLI (`gh`) — used by the ship/PR flow inside the container.
74
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
75
+ | tee /usr/share/keyrings/githubcli-archive-keyring.gpg >/dev/null \
76
+ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
77
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
78
+ > /etc/apt/sources.list.d/github-cli.list \
79
+ && apt-get update \
80
+ && apt-get install -y --no-install-recommends gh \
81
+ && rm -rf /var/lib/apt/lists/*
82
+
83
+ # uv — fast Python package + venv manager (used by uai-init's Python path).
84
+ # Installed system-wide so both root and node can run it.
85
+ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # 2. Non-root `node` user.
89
+ #
90
+ # The container runs as `node`; uai-init and the agent CLIs expect
91
+ # /home/node. uid/gid 1000 matches the common host default so bind-mounted
92
+ # worktree files stay writable.
93
+ # ---------------------------------------------------------------------------
94
+
95
+ RUN groupadd --gid 1000 node \
96
+ && useradd --uid 1000 --gid 1000 --create-home --home-dir /home/node --shell /bin/bash node
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # 3. asdf.
100
+ #
101
+ # Clone to /opt/asdf (the code) and point the DATA dir at /opt/asdf-data
102
+ # (the host-wide cache volume mounts there). Both root and node must be able
103
+ # to read the install and write the data dir, so /opt/asdf-data is group
104
+ # owned by `node` and group-writable; the data volume inherits this.
105
+ # ---------------------------------------------------------------------------
106
+
107
+ ENV ASDF_DIR=/opt/asdf
108
+ ENV ASDF_DATA_DIR=/opt/asdf-data
109
+ # Leave the tool-versions filename at asdf's default (`.tool-versions`) so asdf
110
+ # walks UP from the cwd and a project's /workspace/<slug>/.tool-versions wins.
111
+ # The baked default lives at the node user's home (~/.tool-versions) as asdf's
112
+ # global fallback for tasks with no project file (agent CLIs, scratchpad).
113
+ # Do NOT set ASDF_DEFAULT_TOOL_VERSIONS_FILENAME to an absolute path — that
114
+ # overrides the search *filename* and bypasses project files entirely (it made
115
+ # asdf read only /etc/.tool-versions, ignoring per-project pins).
116
+
117
+ RUN git clone --depth 1 --branch "$ASDF_VERSION" https://github.com/asdf-vm/asdf.git "$ASDF_DIR" \
118
+ && mkdir -p "$ASDF_DATA_DIR" \
119
+ && chown -R node:node "$ASDF_DIR" "$ASDF_DATA_DIR" \
120
+ && chmod -R g+rwX "$ASDF_DATA_DIR"
121
+
122
+ # Make asdf available on PATH + shell init for BOTH root and node, for login
123
+ # and non-login shells (uai-init runs as node via a non-interactive exec).
124
+ # - asdf.sh sources the shim + completions and prepends the shims dir.
125
+ # - The shims dir is also added to PATH directly so a bare `docker exec`
126
+ # (which sources neither profile) still sees installed runtimes.
127
+ ENV PATH=/opt/asdf-data/shims:/opt/asdf/bin:$PATH
128
+
129
+ RUN printf '%s\n' \
130
+ '. /opt/asdf/asdf.sh' \
131
+ > /etc/profile.d/asdf.sh \
132
+ && chmod 0644 /etc/profile.d/asdf.sh
133
+
134
+ # Source asdf from each user's bashrc as well (interactive non-login shells,
135
+ # e.g. the code-server terminal) and enable direnv.
136
+ RUN for rc in /root/.bashrc /home/node/.bashrc; do \
137
+ printf '\n%s\n%s\n' \
138
+ '. /opt/asdf/asdf.sh' \
139
+ 'eval "$(direnv hook bash)"' >> "$rc"; \
140
+ done \
141
+ && chown node:node /home/node/.bashrc
142
+
143
+ # zsh + oh-my-zsh as the interactive login shell for `node` (the code-server
144
+ # terminal and `docker exec -it`). Installed unattended; the installer writes a
145
+ # default ~/.zshrc (robbyrussell theme, git plugin) which we extend with the
146
+ # same asdf + direnv wiring as .bashrc. Non-interactive execs (uai-init, claude,
147
+ # codex, git) invoke their command directly, so the login-shell change does not
148
+ # affect them.
149
+ RUN su node -c 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended' \
150
+ && printf '\n%s\n%s\n' \
151
+ '. /opt/asdf/asdf.sh' \
152
+ 'eval "$(direnv hook zsh)"' >> /home/node/.zshrc \
153
+ && chown node:node /home/node/.zshrc \
154
+ && usermod -s /usr/bin/zsh node
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # 4. asdf plugins + the root default runtimes.
158
+ #
159
+ # Plugins are added for all common runtimes; only the root defaults are
160
+ # *installed* at build time. Other versions are installed lazily by uai-init
161
+ # and cached on the /opt/asdf-data volume.
162
+ #
163
+ # Run as node so the install lands in /opt/asdf-data owned by node.
164
+ # ---------------------------------------------------------------------------
165
+
166
+ # Default .tool-versions at the node user's home = asdf's GLOBAL fallback, used
167
+ # only when no project /workspace/<slug>/.tool-versions is in scope (agent CLIs,
168
+ # scratchpad tasks). A project file takes precedence via asdf's dir walk. The
169
+ # `tool-versions` file in this directory is the AUTHORITATIVE source.
170
+ COPY tool-versions /home/node/.tool-versions
171
+ RUN chmod 0644 /home/node/.tool-versions \
172
+ && chown node:node /home/node/.tool-versions
173
+
174
+ USER node
175
+
176
+ # Add plugins for all common runtimes, then install exactly the versions the
177
+ # default ~/.tool-versions pins (read straight from the file so there is one
178
+ # source of truth). Other versions install lazily at task-up via uai-init.
179
+ RUN bash -lc '\
180
+ set -e; \
181
+ . /opt/asdf/asdf.sh; \
182
+ asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git; \
183
+ asdf plugin add python https://github.com/asdf-community/asdf-python.git; \
184
+ asdf plugin add golang https://github.com/asdf-community/asdf-golang.git; \
185
+ asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git; \
186
+ asdf plugin add rust https://github.com/asdf-community/asdf-rust.git; \
187
+ cd /home/node && asdf install nodejs && asdf install python; \
188
+ '
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # 5. Global agent CLIs + code-server, via the root-default Node.
192
+ #
193
+ # These are Node programs and must be present even for a 0-project task, so
194
+ # they are installed globally against the asdf-default Node and then reshimmed
195
+ # so `claude` / `codex` / `code-server` resolve through the asdf shims dir
196
+ # (which is on PATH for a bare docker exec).
197
+ # ---------------------------------------------------------------------------
198
+
199
+ RUN bash -lc '\
200
+ set -e; \
201
+ . /opt/asdf/asdf.sh; \
202
+ npm install -g @anthropic-ai/claude-code @openai/codex; \
203
+ curl -fsSL https://code-server.dev/install.sh | sh -s -- --method standalone --prefix /home/node/.local; \
204
+ asdf reshim nodejs; \
205
+ '
206
+
207
+ ENV PATH=/home/node/.local/bin:$PATH
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # 6. uai-init baked in.
211
+ # ---------------------------------------------------------------------------
212
+
213
+ USER root
214
+
215
+ COPY --chown=root:root container/uai-init /usr/local/bin/uai-init
216
+ RUN chmod 0755 /usr/local/bin/uai-init
217
+
218
+ # Curated code-server defaults. uai-init seeds these into the per-task User
219
+ # settings dir when none exists (slim chrome, telemetry off, trust off).
220
+ COPY --chown=root:root container/code-server-settings.json /usr/local/share/uai/code-server-settings.json
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # 7. Default working directory + entrypoint.
224
+ #
225
+ # The container is a long-lived workbench. uai-init (deps + code-server) and
226
+ # the agent CLIs are started by the host via `docker exec` after task-up.
227
+ # ---------------------------------------------------------------------------
228
+
229
+ USER node
230
+ WORKDIR /workspace
231
+
232
+ CMD ["sleep", "infinity"]
@@ -0,0 +1,122 @@
1
+ # Uai standard image
2
+
3
+ The single per-host workbench image, built once at host startup and cached as
4
+ `uai-standard:dev` (ADR-022). Every task reuses it unchanged unless a selected
5
+ project declares an [`extra`](../../../docs/runtime.md#the-extra-escape-hatch)
6
+ snippet, in which case task-up derives `uai-task-<id>` FROM this image.
7
+
8
+ See [`docs/runtime.md`](../../../docs/runtime.md) for the authoritative spec.
9
+
10
+ ## What's inside
11
+
12
+ - **Base**: `debian:bookworm-slim`, non-root `node` user (home `/home/node`,
13
+ uid/gid 1000). The container runs as `node`; uai-init and the agent CLIs
14
+ expect `/home/node`.
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.
20
+ - **Default `~/.tool-versions`** (asdf's global fallback, at `/home/node`)
21
+ pinning a default Node + Python. The `tool-versions` file in this directory is
22
+ the authoritative source for the numbers; the `Dockerfile` `COPY`s it to
23
+ `/home/node/.tool-versions` and installs exactly those versions at build time.
24
+ This guarantees the agent CLIs and a 0-project scratchpad always have a
25
+ runtime. asdf uses its default `.tool-versions` filename and walks UP from the
26
+ cwd, so a project's `/workspace/<slug>/.tool-versions` **takes precedence**;
27
+ the home file is only the fallback. (Do **not** set
28
+ `ASDF_DEFAULT_TOOL_VERSIONS_FILENAME` to an absolute path — that overrides the
29
+ search filename and makes asdf ignore per-project files.)
30
+ - **uv uses asdf's Python.** For Python folders, `uai-init` runs
31
+ `uv venv --python "$(asdf which python)"` so the venv matches the asdf-pinned
32
+ version (`.tool-versions`) rather than uv's own `.python-version` /
33
+ `requires-python` discovery, which can disagree or be junk.
34
+ - **Claude Code** + **Codex**, installed globally via the root-default Node
35
+ (`@anthropic-ai/claude-code`, `@openai/codex`), reshimmed onto the asdf
36
+ shims dir so `claude` / `codex` resolve under a bare `docker exec`.
37
+ - **code-server** (the Editor pane), **gh**, **git**, **curl**,
38
+ **ca-certificates**, **jq**, **ripgrep**, **tmux**, **direnv**, **uv**,
39
+ plus `build-essential` and the `-dev` libraries asdf's build plugins need.
40
+ - **`uai-init`** baked at `/usr/local/bin/uai-init`.
41
+ - `WORKDIR /workspace`; `CMD ["sleep","infinity"]`.
42
+
43
+ Codex credentials are **not** baked in or mounted — task-up `docker cp`s them
44
+ into `/home/node/.codex` at init (ADR-015 keeps secrets host-side). `~/.claude`
45
+ is bind-mounted read-only by the generated compose.
46
+
47
+ ## Pinned constants (must match the rest of the host runtime)
48
+
49
+ | Thing | Value |
50
+ | --- | --- |
51
+ | Standard image tag | `uai-standard:dev` |
52
+ | Per-task derived image (only with `extra`) | `uai-task-<taskId>` |
53
+ | Shared asdf data volume | `uai-asdf-data` → `/opt/asdf-data` |
54
+ | Compose service name | `app` (container `task-<taskId>-app-1`) |
55
+ | In-container workspace | `/workspace` |
56
+ | code-server port | `8080` (inside the container) |
57
+
58
+ ## Build
59
+
60
+ The host process builds this at startup when the cached digest is missing or
61
+ stale. To build by hand from the repo root:
62
+
63
+ ```bash
64
+ docker build -t uai-standard:dev host-agent/images/standard
65
+ ```
66
+
67
+ A derived per-task image (when a project has `extra`) is built by `task-up`
68
+ from a generated `.uai/Dockerfile` that is `FROM uai-standard:dev` plus the
69
+ concatenated `extra` RUN lines, tagged `uai-task-<id>`.
70
+
71
+ ## Smoke test (no uai involved)
72
+
73
+ These files are validated at the Phase E smoke test (no Docker in CI), so the
74
+ steps below are for local image iteration only.
75
+
76
+ ```bash
77
+ # 1. Build.
78
+ docker build -t uai-standard:dev host-agent/images/standard
79
+
80
+ # 2. Create the shared asdf data volume + a throwaway workspace.
81
+ docker volume create uai-asdf-data
82
+ mkdir -p /tmp/uai-smoke/workspace/demo
83
+ printf 'nodejs 22.11.0\n' > /tmp/uai-smoke/workspace/demo/.tool-versions
84
+ printf '{"name":"demo","packageManager":"npm@10"}\n' \
85
+ > /tmp/uai-smoke/workspace/demo/package.json
86
+
87
+ # 3. Run the container the way compose would (service `app`, workspace bind,
88
+ # asdf cache volume, code-server port published on loopback).
89
+ docker run -d --name uai-smoke \
90
+ -v /tmp/uai-smoke/workspace:/workspace \
91
+ -v uai-asdf-data:/opt/asdf-data \
92
+ -p 127.0.0.1::8080 \
93
+ uai-standard:dev
94
+
95
+ # 4. Sanity-check the baked toolchain (bare exec — no profile sourced).
96
+ docker exec uai-smoke bash -lc 'asdf --version && node -v && python --version'
97
+ docker exec uai-smoke claude --version
98
+ docker exec uai-smoke codex --version
99
+
100
+ # 5. Run uai-init: per-folder asdf install + deps, then code-server on 8080.
101
+ docker exec uai-smoke /usr/local/bin/uai-init
102
+ docker exec uai-smoke bash -lc 'curl -sf http://127.0.0.1:8080/healthz || \
103
+ curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/'
104
+
105
+ # 6. Tear down.
106
+ docker rm -f uai-smoke
107
+ ```
108
+
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
111
+ `0.0.0.0:8080` and the HTTP probe returns a 2xx/3xx.
112
+
113
+ ## Notes / gotchas
114
+
115
+ - **Bash 3.2 constraint does NOT apply here.** `uai-init` runs on the Linux
116
+ base image (bash 5) and may use `shopt`, arrays, etc. The 3.2 constraint is
117
+ only for host scripts under `host-agent/scripts/agent/`.
118
+ - **First-use asdf cost.** The first task needing a given runtime version pays
119
+ the download/build time; results persist on `uai-asdf-data` for every later
120
+ 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.