@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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/uai-host.mjs +14 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/meta/_journal.json +41 -0
- package/db/schema.ts +82 -0
- package/images/standard/Dockerfile +232 -0
- package/images/standard/README.md +122 -0
- package/images/standard/container/code-server-settings.json +36 -0
- package/images/standard/container/uai-init +215 -0
- package/images/standard/tool-versions +2 -0
- package/lib/agent.ts +292 -0
- package/lib/agents/claude.ts +343 -0
- package/lib/agents/codex.ts +522 -0
- package/lib/agents/factory.ts +34 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/proc.ts +172 -0
- package/lib/agents/registry.ts +109 -0
- package/lib/agents/types.ts +133 -0
- package/lib/attachments.ts +46 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/command-db.ts +278 -0
- package/lib/db.ts +68 -0
- package/lib/env.ts +140 -0
- package/lib/git-diff.ts +370 -0
- package/lib/git-identity.ts +65 -0
- package/lib/github-tokens.ts +321 -0
- package/lib/orchestrator.ts +975 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/repo-clone.ts +127 -0
- package/lib/runtime-state.ts +120 -0
- package/lib/secrets.ts +71 -0
- package/lib/ssh.ts +186 -0
- package/lib/standard-image.ts +152 -0
- package/lib/task-diff.ts +113 -0
- package/lib/task-status.ts +46 -0
- package/lib/transcript.ts +30 -0
- package/lib/ulid.ts +7 -0
- package/package.json +85 -0
- package/scripts/agent/_common.sh +248 -0
- package/scripts/agent/task-down.sh +113 -0
- package/scripts/agent/task-status.sh +54 -0
- package/scripts/agent/task-up.sh +457 -0
- package/scripts/install/darwin.ts +167 -0
- package/scripts/install/linux.ts +115 -0
- package/scripts/install/types.ts +35 -0
- package/scripts/install/util.ts +39 -0
- package/scripts/install/win.ts +130 -0
- package/src/cli.ts +445 -0
- package/src/index.ts +375 -0
- package/src/load-env.ts +52 -0
- package/src/main.ts +1156 -0
- package/src/paths.ts +64 -0
- package/src/protocol.ts +413 -0
- package/src/ui/server.ts +343 -0
- package/src/ui/types.ts +78 -0
- package/ui/app.js +264 -0
- package/ui/index.html +55 -0
- package/ui/style.css +359 -0
- 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>
|
package/bin/uai-host.mjs
ADDED
|
@@ -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 @@
|
|
|
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.
|