@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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * macOS service install via launchd (ADR-028).
3
+ *
4
+ * Writes ~/Library/LaunchAgents/com.runuai.host.plist (KeepAlive + RunAtLoad),
5
+ * logs to ~/Library/Logs/Uai/, loads with `launchctl bootstrap gui/<uid>`.
6
+ * The service's PATH is the operator's PATH at install time (so node/pnpm/
7
+ * docker/git resolve); .env.local is loaded by the host process itself.
8
+ */
9
+
10
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, resolve } from "node:path";
13
+
14
+ import type { InstallContext, Installer } from "./types";
15
+ import { escapeXml, printFile, step } from "./util";
16
+
17
+ export const LABEL = "com.runuai.host";
18
+
19
+ const uid = process.getuid?.() ?? 0;
20
+ const domain = `gui/${uid}`;
21
+ const target = `${domain}/${LABEL}`;
22
+ const logDir = resolve(homedir(), "Library", "Logs", "Uai");
23
+
24
+ export function plistPath(): string {
25
+ return resolve(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
26
+ }
27
+
28
+ export interface PlistInputs {
29
+ execPath: string;
30
+ args: string[];
31
+ cwd: string;
32
+ path: string;
33
+ home: string;
34
+ outLog: string;
35
+ errLog: string;
36
+ }
37
+
38
+ export function renderPlist(p: PlistInputs): string {
39
+ const progArgs = [p.execPath, ...p.args]
40
+ .map((a) => ` <string>${escapeXml(a)}</string>`)
41
+ .join("\n");
42
+ return `<?xml version="1.0" encoding="UTF-8"?>
43
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
44
+ <plist version="1.0">
45
+ <dict>
46
+ <key>Label</key>
47
+ <string>${LABEL}</string>
48
+ <key>ProgramArguments</key>
49
+ <array>
50
+ ${progArgs}
51
+ </array>
52
+ <key>WorkingDirectory</key>
53
+ <string>${escapeXml(p.cwd)}</string>
54
+ <key>EnvironmentVariables</key>
55
+ <dict>
56
+ <key>PATH</key>
57
+ <string>${escapeXml(p.path)}</string>
58
+ <key>HOME</key>
59
+ <string>${escapeXml(p.home)}</string>
60
+ </dict>
61
+ <key>RunAtLoad</key>
62
+ <true/>
63
+ <key>KeepAlive</key>
64
+ <true/>
65
+ <key>ThrottleInterval</key>
66
+ <integer>10</integer>
67
+ <key>StandardOutPath</key>
68
+ <string>${escapeXml(p.outLog)}</string>
69
+ <key>StandardErrorPath</key>
70
+ <string>${escapeXml(p.errLog)}</string>
71
+ </dict>
72
+ </plist>
73
+ `;
74
+ }
75
+
76
+ function inputs(ctx: InstallContext): PlistInputs {
77
+ return {
78
+ execPath: ctx.execPath,
79
+ args: ctx.args,
80
+ cwd: ctx.cwd,
81
+ path: process.env.PATH ?? "",
82
+ home: homedir(),
83
+ outLog: resolve(logDir, "host.out.log"),
84
+ errLog: resolve(logDir, "host.log"),
85
+ };
86
+ }
87
+
88
+ async function install(ctx: InstallContext): Promise<void> {
89
+ const plist = renderPlist(inputs(ctx));
90
+ if (ctx.dryRun) {
91
+ printFile(plistPath(), plist);
92
+ step(true, "mkdir", ["-p", logDir]);
93
+ step(true, "launchctl", ["bootout", target]);
94
+ step(true, "launchctl", ["bootstrap", domain, plistPath()]);
95
+ step(true, "launchctl", ["enable", target]);
96
+ return;
97
+ }
98
+ mkdirSync(logDir, { recursive: true });
99
+ mkdirSync(dirname(plistPath()), { recursive: true });
100
+ writeFileSync(plistPath(), plist);
101
+ // Reload cleanly: bootout any prior instance (ignore "not loaded"), bootstrap.
102
+ step(false, "launchctl", ["bootout", target], { ignoreError: true });
103
+ await bootstrapWithRetry();
104
+ step(false, "launchctl", ["enable", target], { ignoreError: true });
105
+ }
106
+
107
+ /**
108
+ * `bootout` unloads the old job asynchronously, so an immediate `bootstrap`
109
+ * can race it ("Bootstrap failed: 5: Input/output error"). Retry a few times
110
+ * so re-running `install` is idempotent instead of leaving nothing loaded.
111
+ */
112
+ async function bootstrapWithRetry(): Promise<void> {
113
+ let lastErr: unknown;
114
+ for (let attempt = 0; attempt < 6; attempt += 1) {
115
+ try {
116
+ step(false, "launchctl", ["bootstrap", domain, plistPath()]);
117
+ return;
118
+ } catch (err) {
119
+ lastErr = err;
120
+ await new Promise((resolve) => setTimeout(resolve, 500));
121
+ }
122
+ }
123
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
124
+ }
125
+
126
+ async function uninstall(dryRun = false): Promise<void> {
127
+ step(dryRun, "launchctl", ["bootout", target], { ignoreError: true });
128
+ if (dryRun) {
129
+ console.log(`$ rm -f ${plistPath()}`);
130
+ return;
131
+ }
132
+ rmSync(plistPath(), { force: true });
133
+ }
134
+
135
+ async function start(dryRun = false): Promise<void> {
136
+ // bootstrap loads + RunAtLoad starts; if already loaded that errors, so also
137
+ // kickstart to be sure it's running.
138
+ step(dryRun, "launchctl", ["bootstrap", domain, plistPath()], {
139
+ ignoreError: true,
140
+ });
141
+ step(dryRun, "launchctl", ["kickstart", target], { ignoreError: true });
142
+ }
143
+
144
+ async function stop(dryRun = false): Promise<void> {
145
+ step(dryRun, "launchctl", ["bootout", target], { ignoreError: true });
146
+ }
147
+
148
+ async function restart(dryRun = false): Promise<void> {
149
+ step(dryRun, "launchctl", ["kickstart", "-k", target], { ignoreError: true });
150
+ }
151
+
152
+ async function status(): Promise<string> {
153
+ const out = step(false, "launchctl", ["print", target], { ignoreError: true });
154
+ if (!out) return "not installed / not loaded";
155
+ const state = /state = (\S+)/.exec(out)?.[1] ?? "unknown";
156
+ const pid = /pid = (\d+)/.exec(out)?.[1];
157
+ return `${state}${pid ? ` (pid ${pid})` : ""}`;
158
+ }
159
+
160
+ export const installer: Installer = {
161
+ install,
162
+ uninstall,
163
+ start,
164
+ stop,
165
+ restart,
166
+ status,
167
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Linux service install via a systemd **user** unit (ADR-028).
3
+ *
4
+ * Writes ~/.config/systemd/user/uai-host.service, enables it with
5
+ * `systemctl --user enable --now`. Logs go to journald
6
+ * (`journalctl --user -u uai-host`).
7
+ *
8
+ * v1 limitation (ADR-028 open question): systemd-user needs a logged-in user
9
+ * session unless lingering is enabled (`loginctl enable-linger <user>`); for a
10
+ * dedicated headless server a system unit + service user would be the move.
11
+ */
12
+
13
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, resolve } from "node:path";
16
+
17
+ import type { InstallContext, Installer } from "./types";
18
+ import { printFile, step } from "./util";
19
+
20
+ export const UNIT = "uai-host.service";
21
+
22
+ export function unitPath(): string {
23
+ return resolve(homedir(), ".config", "systemd", "user", UNIT);
24
+ }
25
+
26
+ export interface UnitInputs {
27
+ execPath: string;
28
+ args: string[];
29
+ cwd: string;
30
+ path: string;
31
+ }
32
+
33
+ export function renderUnit(p: UnitInputs): string {
34
+ const exec = [p.execPath, ...p.args].join(" ");
35
+ return `[Unit]
36
+ Description=Uai host service (ADR-028)
37
+ After=network-online.target
38
+ Wants=network-online.target
39
+
40
+ [Service]
41
+ Type=simple
42
+ WorkingDirectory=${p.cwd}
43
+ Environment=PATH=${p.path}
44
+ ExecStart=${exec}
45
+ Restart=on-failure
46
+ RestartSec=10
47
+
48
+ [Install]
49
+ WantedBy=default.target
50
+ `;
51
+ }
52
+
53
+ function inputs(ctx: InstallContext): UnitInputs {
54
+ return {
55
+ execPath: ctx.execPath,
56
+ args: ctx.args,
57
+ cwd: ctx.cwd,
58
+ path: process.env.PATH ?? "",
59
+ };
60
+ }
61
+
62
+ async function install(ctx: InstallContext): Promise<void> {
63
+ const unit = renderUnit(inputs(ctx));
64
+ if (ctx.dryRun) {
65
+ printFile(unitPath(), unit);
66
+ step(true, "systemctl", ["--user", "daemon-reload"]);
67
+ step(true, "systemctl", ["--user", "enable", "--now", UNIT]);
68
+ return;
69
+ }
70
+ mkdirSync(dirname(unitPath()), { recursive: true });
71
+ writeFileSync(unitPath(), unit);
72
+ step(false, "systemctl", ["--user", "daemon-reload"]);
73
+ step(false, "systemctl", ["--user", "enable", "--now", UNIT]);
74
+ }
75
+
76
+ async function uninstall(dryRun = false): Promise<void> {
77
+ step(dryRun, "systemctl", ["--user", "disable", "--now", UNIT], {
78
+ ignoreError: true,
79
+ });
80
+ if (dryRun) {
81
+ console.log(`$ rm -f ${unitPath()}`);
82
+ step(true, "systemctl", ["--user", "daemon-reload"]);
83
+ return;
84
+ }
85
+ rmSync(unitPath(), { force: true });
86
+ step(false, "systemctl", ["--user", "daemon-reload"], { ignoreError: true });
87
+ }
88
+
89
+ async function start(dryRun = false): Promise<void> {
90
+ step(dryRun, "systemctl", ["--user", "start", UNIT]);
91
+ }
92
+
93
+ async function stop(dryRun = false): Promise<void> {
94
+ step(dryRun, "systemctl", ["--user", "stop", UNIT], { ignoreError: true });
95
+ }
96
+
97
+ async function restart(dryRun = false): Promise<void> {
98
+ step(dryRun, "systemctl", ["--user", "restart", UNIT]);
99
+ }
100
+
101
+ async function status(): Promise<string> {
102
+ const active = step(false, "systemctl", ["--user", "is-active", UNIT], {
103
+ ignoreError: true,
104
+ }).trim();
105
+ return active || "unknown";
106
+ }
107
+
108
+ export const installer: Installer = {
109
+ install,
110
+ uninstall,
111
+ start,
112
+ stop,
113
+ restart,
114
+ status,
115
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-OS service-installer contract (ADR-028).
3
+ *
4
+ * `host-agent/src/cli.ts` dispatches `install`/`uninstall`/`start`/`stop`/
5
+ * `restart`/`status` to one of `darwin.ts` / `linux.ts` / `win.ts` based on
6
+ * `process.platform`. Each module exports an `installer: Installer`.
7
+ *
8
+ * Bash-3.2 constraint does NOT apply here — these are TypeScript.
9
+ */
10
+
11
+ export interface InstallContext {
12
+ /** Absolute command the service runs (e.g. the `uai-host` binary). */
13
+ execPath: string;
14
+ /** Arguments for that command (e.g. ["run"]). */
15
+ args: string[];
16
+ /** Absolute working directory for the service. */
17
+ cwd: string;
18
+ /**
19
+ * When true, print the unit file + the load commands instead of executing.
20
+ * Used by CI tests (no launchd/systemd/winsw side effects).
21
+ */
22
+ dryRun: boolean;
23
+ }
24
+
25
+ export interface Installer {
26
+ /** Write the unit/plist/xml and load it. */
27
+ install(ctx: InstallContext): Promise<void>;
28
+ /** Unload + remove the unit. */
29
+ uninstall(dryRun?: boolean): Promise<void>;
30
+ start(dryRun?: boolean): Promise<void>;
31
+ stop(dryRun?: boolean): Promise<void>;
32
+ restart(dryRun?: boolean): Promise<void>;
33
+ /** Human-readable one-line status (e.g. "running (pid 1234)"). */
34
+ status(): Promise<string>;
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared install helpers (ADR-028). A `step` runs a command, or in dry-run
3
+ * prints it (so CI can assert the install plan without launchd/systemd/winsw
4
+ * side effects).
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ export function step(
10
+ dryRun: boolean,
11
+ cmd: string,
12
+ args: string[],
13
+ opts: { ignoreError?: boolean } = {},
14
+ ): string {
15
+ if (dryRun) {
16
+ console.log(`$ ${cmd} ${args.join(" ")}`);
17
+ return "";
18
+ }
19
+ const res = spawnSync(cmd, args, { encoding: "utf8" });
20
+ if (res.status !== 0 && !opts.ignoreError) {
21
+ const detail = (res.stderr || res.stdout || res.error?.message || "").trim();
22
+ throw new Error(`\`${cmd} ${args.join(" ")}\` failed: ${detail}`);
23
+ }
24
+ return res.stdout ?? "";
25
+ }
26
+
27
+ /** Print a unit/plist/xml file's path + contents (dry-run). */
28
+ export function printFile(path: string, content: string): void {
29
+ console.log(`# ${path}`);
30
+ console.log(content);
31
+ }
32
+
33
+ export function escapeXml(s: string): string {
34
+ return s
35
+ .replace(/&/g, "&amp;")
36
+ .replace(/</g, "&lt;")
37
+ .replace(/>/g, "&gt;")
38
+ .replace(/"/g, "&quot;");
39
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Windows service install via winsw (ADR-028).
3
+ *
4
+ * Downloads a pinned winsw binary to %LOCALAPPDATA%\Uai\service\uai-host.exe,
5
+ * writes uai-host.xml beside it (winsw reads <basename>.xml), and registers
6
+ * with `uai-host.exe install`. Logs to %LOCALAPPDATA%\Uai\service\logs\.
7
+ *
8
+ * Untested on non-Windows hosts (dev is macOS); the dry-run plan + the XML
9
+ * render are covered by tests.
10
+ */
11
+
12
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import type { InstallContext, Installer } from "./types";
18
+ import { escapeXml, printFile, step } from "./util";
19
+
20
+ // Pinned winsw release (bump deliberately).
21
+ const WINSW_URL =
22
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe";
23
+
24
+ function serviceDir(): string {
25
+ const base = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
26
+ return join(base, "Uai", "service");
27
+ }
28
+ export function exePath(): string {
29
+ return join(serviceDir(), "uai-host.exe");
30
+ }
31
+ export function xmlPath(): string {
32
+ return join(serviceDir(), "uai-host.xml");
33
+ }
34
+ function logDir(): string {
35
+ return join(serviceDir(), "logs");
36
+ }
37
+
38
+ export interface XmlInputs {
39
+ execPath: string;
40
+ args: string[];
41
+ cwd: string;
42
+ path: string;
43
+ logDir: string;
44
+ }
45
+
46
+ export function renderXml(p: XmlInputs): string {
47
+ return `<service>
48
+ <id>uai-host</id>
49
+ <name>Uai host service</name>
50
+ <description>Uai host service (ADR-028)</description>
51
+ <executable>${escapeXml(p.execPath)}</executable>
52
+ <arguments>${escapeXml(p.args.join(" "))}</arguments>
53
+ <workingdirectory>${escapeXml(p.cwd)}</workingdirectory>
54
+ <env name="PATH" value="${escapeXml(p.path)}" />
55
+ <onfailure action="restart" delay="10 sec" />
56
+ <log mode="roll" />
57
+ <logpath>${escapeXml(p.logDir)}</logpath>
58
+ </service>
59
+ `;
60
+ }
61
+
62
+ function inputs(ctx: InstallContext): XmlInputs {
63
+ return {
64
+ execPath: ctx.execPath,
65
+ args: ctx.args,
66
+ cwd: ctx.cwd,
67
+ path: process.env.PATH ?? "",
68
+ logDir: logDir(),
69
+ };
70
+ }
71
+
72
+ async function downloadWinsw(dest: string): Promise<void> {
73
+ const res = await fetch(WINSW_URL);
74
+ if (!res.ok) throw new Error(`winsw download failed: ${res.status}`);
75
+ const buf = Buffer.from(await res.arrayBuffer());
76
+ await writeFile(dest, buf);
77
+ }
78
+
79
+ async function install(ctx: InstallContext): Promise<void> {
80
+ const xml = renderXml(inputs(ctx));
81
+ if (ctx.dryRun) {
82
+ printFile(xmlPath(), xml);
83
+ console.log(`# download winsw -> ${exePath()}`);
84
+ console.log(`# ${WINSW_URL}`);
85
+ step(true, exePath(), ["install"]);
86
+ step(true, exePath(), ["start"]);
87
+ return;
88
+ }
89
+ mkdirSync(logDir(), { recursive: true });
90
+ await downloadWinsw(exePath());
91
+ writeFileSync(xmlPath(), xml);
92
+ step(false, exePath(), ["install"]);
93
+ step(false, exePath(), ["start"]);
94
+ }
95
+
96
+ async function uninstall(dryRun = false): Promise<void> {
97
+ step(dryRun, exePath(), ["stop"], { ignoreError: true });
98
+ step(dryRun, exePath(), ["uninstall"], { ignoreError: true });
99
+ if (dryRun) {
100
+ console.log(`$ rm -f ${exePath()} ${xmlPath()}`);
101
+ return;
102
+ }
103
+ rmSync(exePath(), { force: true });
104
+ rmSync(xmlPath(), { force: true });
105
+ }
106
+
107
+ async function start(dryRun = false): Promise<void> {
108
+ step(dryRun, exePath(), ["start"]);
109
+ }
110
+
111
+ async function stop(dryRun = false): Promise<void> {
112
+ step(dryRun, exePath(), ["stop"], { ignoreError: true });
113
+ }
114
+
115
+ async function restart(dryRun = false): Promise<void> {
116
+ step(dryRun, exePath(), ["restart"]);
117
+ }
118
+
119
+ async function status(): Promise<string> {
120
+ return step(false, exePath(), ["status"], { ignoreError: true }).trim() || "unknown";
121
+ }
122
+
123
+ export const installer: Installer = {
124
+ install,
125
+ uninstall,
126
+ start,
127
+ stop,
128
+ restart,
129
+ status,
130
+ };