directionallylocal 0.1.2 → 0.3.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * "On this computer": where everything lives in the user's home, and the files written
3
+ * there. Nothing here needs root. Secrets sit in 0600 env files that `node --env-file`
4
+ * reads, so they never appear in a launchd plist, a systemd unit, or any command line.
5
+ */
6
+ import { join } from "node:path";
7
+ export function localPaths(home, platform) {
8
+ const root = join(home, ".directionallylocal");
9
+ const config = join(root, "config");
10
+ const autopilotHome = join(root, "autopilot");
11
+ return {
12
+ home,
13
+ root,
14
+ releases: join(root, "releases"),
15
+ current: join(root, "current"),
16
+ logs: join(root, "logs"),
17
+ state: join(root, "state.json"),
18
+ config,
19
+ hotcellEnv: join(config, "hotcell.env"),
20
+ autopilotEnv: join(config, "autopilot.env"),
21
+ githubEnv: join(config, "github.env"),
22
+ asterConfig: join(config, "aster.json"),
23
+ hotcellHome: join(root, "hotcell"),
24
+ autopilotHome,
25
+ workspace: join(autopilotHome, "workspaces", "default"),
26
+ services: platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"),
27
+ };
28
+ }
29
+ /** launchd labels and systemd user units, prefixed so they never clash with a user's own Hotcell. */
30
+ export const LOCAL_SERVICES = {
31
+ hotcell: { label: "com.directionallylocal.hotcell", unit: "directionallylocal-hotcell.service", log: "hotcell.log" },
32
+ autopilot: { label: "com.directionallylocal.autopilot", unit: "directionallylocal-autopilot.service", log: "autopilot.log" },
33
+ };
34
+ export function serviceFile(paths, platform, service) {
35
+ const entry = LOCAL_SERVICES[service];
36
+ return join(paths.services, platform === "darwin" ? `${entry.label}.plist` : entry.unit);
37
+ }
38
+ /** Preferred ports; each moves up to the next free one when something else has it. */
39
+ export const PREFERRED_PORTS = {
40
+ autopilot: 4096,
41
+ hotcell: 4750,
42
+ hotcellProxy: 4751,
43
+ hotcellEgress: 4752,
44
+ githubProxy: 4760,
45
+ };
46
+ export const LOCAL_CELL_MEMORY_MB = 1024;
47
+ export const LOCAL_MIN_DISK_GB = 10;
48
+ export const PAIRED_NAME = "this-computer";
49
+ export function localHotcellBin(paths) {
50
+ return join(paths.current, "node_modules", "hotcell", "dist", "index.js");
51
+ }
52
+ export function localAutopilotBin(paths) {
53
+ return join(paths.current, "node_modules", "autopilot-ai", "bin", "autopilot.mjs");
54
+ }
55
+ /**
56
+ * KEY=value lines for `node --env-file`. Values with spaces or `#` (a home like
57
+ * "/Users/Jane Doe") are double-quoted, which Node's parser understands.
58
+ */
59
+ export function localEnvFile(header, values) {
60
+ const lines = Object.entries(values).map(([key, value]) => {
61
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || /[\n\r"]/.test(value))
62
+ throw new Error(`invalid env entry ${key}`);
63
+ return `${key}=${/[\s#'`$\\]/.test(value) ? `"${value}"` : value}`;
64
+ });
65
+ return `# ${header}\n# Written by directionallylocal; re-running setup keeps your values.\n${lines.join("\n")}\n`;
66
+ }
67
+ export function parseLocalEnvFile(text) {
68
+ const values = {};
69
+ for (const line of (text ?? "").split("\n")) {
70
+ const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
71
+ if (match)
72
+ values[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
73
+ }
74
+ return values;
75
+ }
76
+ export function localHotcellEnv(input) {
77
+ const { paths, ports } = input;
78
+ return localEnvFile("Hotcell daemon (sandboxes + credential gateway), on this computer", {
79
+ HOTCELL_HOME: paths.hotcellHome,
80
+ // A 0600 file, not the login keychain: see setup.ts for why.
81
+ HOTCELL_KEYSTORE: "file",
82
+ HOTCELL_DB: join(paths.hotcellHome, "state.db"),
83
+ HOTCELL_BACKUP_DIR: join(paths.hotcellHome, "backups"),
84
+ HOTCELL_HOST: "127.0.0.1",
85
+ HOTCELL_PORT: String(ports.hotcell),
86
+ HOTCELL_PROXY_PORT: String(ports.hotcellProxy),
87
+ HOTCELL_API_KEY: input.apiKey,
88
+ HOTCELL_DRIVER: "container",
89
+ HOTCELL_EGRESS_HOST: input.net.listenHost,
90
+ HOTCELL_EGRESS_PORT: String(ports.hotcellEgress),
91
+ HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
92
+ });
93
+ }
94
+ export function localAutopilotEnv(input) {
95
+ const { paths, ports } = input;
96
+ return localEnvFile("Autopilot server (Aster API), on this computer", {
97
+ AUTOPILOT_ASTER_CONFIG: paths.asterConfig,
98
+ AUTOPILOT_ASTER_DB: join(paths.autopilotHome, "aster.db"),
99
+ AUTOPILOT_RUNS_DB: join(paths.autopilotHome, "runs.db"),
100
+ AUTOPILOT_PUBLIC_URL: `http://127.0.0.1:${ports.autopilot}`,
101
+ // The model key lives only in Hotcell; no model calls from this computer itself.
102
+ AUTOPILOT_ASTER_MEMORY: "off",
103
+ AUTOPILOT_HOTCELL_ENDPOINT: `http://127.0.0.1:${ports.hotcell}`,
104
+ AUTOPILOT_HOTCELL_API_KEY: input.hotcellApiKey,
105
+ AUTOPILOT_HOTCELL_DRIVER: "container",
106
+ AUTOPILOT_HOTCELL_IMAGE: input.image,
107
+ AUTOPILOT_HOTCELL_NETWORKED: "true",
108
+ AUTOPILOT_HOTCELL_ANY_MODEL: "true",
109
+ AUTOPILOT_HOTCELL_SLEEP_AFTER_MS: "900000",
110
+ AUTOPILOT_HOTCELL_MEMORY_MB: String(LOCAL_CELL_MEMORY_MB),
111
+ AUTOPILOT_HOTCELL_CPUS: "1",
112
+ AUTOPILOT_HOTCELL_PIDS: "512",
113
+ AUTOPILOT_HOTCELL_SPEND_CAP_USD: "10",
114
+ AUTOPILOT_HOTCELL_TOKEN_TTL_MS: String(30 * 24 * 60 * 60 * 1000),
115
+ // Cells clone and push through Autopilot's repository-scoped proxy; the token stays here.
116
+ AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN: `${input.net.listenHost}:${ports.githubProxy}`,
117
+ AUTOPILOT_ASTER_GITHUB_PROXY_URL: `http://host.docker.internal:${ports.githubProxy}`,
118
+ });
119
+ }
120
+ export function localGithubEnv(token) {
121
+ return localEnvFile("GitHub credential for cloning, pushing, and opening PRs (never sent to sandboxes)", { GITHUB_TOKEN: token });
122
+ }
123
+ export function localAsterConfig(paths) {
124
+ return `${JSON.stringify({
125
+ models: "openrouter",
126
+ workspaces: [{ id: "default", displayName: "Default workspace", rootPath: paths.workspace }],
127
+ }, null, 2)}\n`;
128
+ }
129
+ export function serviceSpecs(input) {
130
+ const { paths } = input;
131
+ const docker = input.dockerHost ? { DOCKER_HOST: input.dockerHost } : {};
132
+ return {
133
+ hotcell: {
134
+ service: "hotcell",
135
+ description: "Hotcell sandbox daemon (Directionally Local)",
136
+ node: input.node,
137
+ envFiles: [paths.hotcellEnv],
138
+ args: [localHotcellBin(paths), "start", "--foreground", "--defaults"],
139
+ // Its own HOME, so nothing falls back to a user's own ~/.hotcell.
140
+ env: { HOME: paths.hotcellHome, PATH: input.path, ...docker },
141
+ workingDirectory: paths.hotcellHome,
142
+ log: join(paths.logs, LOCAL_SERVICES.hotcell.log),
143
+ },
144
+ autopilot: {
145
+ service: "autopilot",
146
+ description: "Autopilot server (Directionally Local)",
147
+ node: input.node,
148
+ envFiles: [paths.autopilotEnv, paths.githubEnv],
149
+ args: [localAutopilotBin(paths), "serve", "--hostname", "127.0.0.1", "--port", String(input.ports.autopilot)],
150
+ // Its own HOME and XDG dirs: a user's own Autopilot config and keys are never picked up.
151
+ env: { HOME: paths.autopilotHome, PATH: input.path, ...autopilotXdg(paths), ...docker },
152
+ workingDirectory: paths.autopilotHome,
153
+ log: join(paths.logs, LOCAL_SERVICES.autopilot.log),
154
+ },
155
+ };
156
+ }
157
+ export function autopilotXdg(paths) {
158
+ return {
159
+ XDG_CONFIG_HOME: join(paths.autopilotHome, ".config"),
160
+ XDG_STATE_HOME: join(paths.autopilotHome, ".local", "state"),
161
+ XDG_DATA_HOME: join(paths.autopilotHome, ".local", "share"),
162
+ XDG_CACHE_HOME: join(paths.autopilotHome, ".cache"),
163
+ };
164
+ }
165
+ function xml(text) {
166
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
167
+ }
168
+ /** A LaunchAgent: starts at login, restarted if it exits, logs under ~/.directionallylocal/logs. */
169
+ export function launchAgentPlist(spec) {
170
+ const program = [spec.node, ...spec.envFiles.map((file) => `--env-file=${file}`), ...spec.args];
171
+ const env = Object.entries(spec.env).map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
172
+ return `<?xml version="1.0" encoding="UTF-8"?>
173
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
174
+ <!-- ${xml(spec.description)}. Written by directionallylocal; remove with: dl local uninstall -->
175
+ <plist version="1.0">
176
+ <dict>
177
+ <key>Label</key>
178
+ <string>${LOCAL_SERVICES[spec.service].label}</string>
179
+ <key>ProgramArguments</key>
180
+ <array>
181
+ ${program.map((arg) => ` <string>${xml(arg)}</string>`).join("\n")}
182
+ </array>
183
+ <key>EnvironmentVariables</key>
184
+ <dict>
185
+ ${env}
186
+ </dict>
187
+ <key>WorkingDirectory</key>
188
+ <string>${xml(spec.workingDirectory)}</string>
189
+ <key>RunAtLoad</key>
190
+ <true/>
191
+ <key>KeepAlive</key>
192
+ <true/>
193
+ <key>ThrottleInterval</key>
194
+ <integer>10</integer>
195
+ <key>ProcessType</key>
196
+ <string>Standard</string>
197
+ <key>StandardOutPath</key>
198
+ <string>${xml(spec.log)}</string>
199
+ <key>StandardErrorPath</key>
200
+ <string>${xml(spec.log)}</string>
201
+ </dict>
202
+ </plist>
203
+ `;
204
+ }
205
+ function systemdQuote(value) {
206
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
207
+ }
208
+ /** A systemd user unit: starts with the user's session (default.target), restarted on exit. */
209
+ export function systemdUserUnit(spec, after) {
210
+ const program = [spec.node, ...spec.envFiles.map((file) => `--env-file=${file}`), ...spec.args];
211
+ return `[Unit]
212
+ Description=${spec.description}
213
+ ${after ? `After=${after}\nWants=${after}\n` : ""}
214
+ [Service]
215
+ Type=simple
216
+ ${Object.entries(spec.env).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`).join("\n")}
217
+ WorkingDirectory=${spec.workingDirectory}
218
+ ExecStart=${program.map(systemdQuote).join(" ")}
219
+ Restart=always
220
+ RestartSec=5
221
+ StandardOutput=append:${spec.log}
222
+ StandardError=append:${spec.log}
223
+
224
+ [Install]
225
+ WantedBy=default.target
226
+ `;
227
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Exit 0 when HOST:PORT is free. Connecting first catches a listener on 0.0.0.0, which
3
+ * macOS would still let us bind 127.0.0.1 next to.
4
+ */
5
+ export const PORT_FREE_SCRIPT = [
6
+ "const net=require('net');const [h,p]=[process.argv[1],Number(process.argv[2])];",
7
+ "const c=net.connect(p,h);c.once('connect',()=>{c.destroy();process.exit(1)});",
8
+ "c.once('error',()=>{const s=net.createServer();s.once('error',()=>process.exit(1));s.listen(p,h,()=>s.close(()=>process.exit(0)))});",
9
+ ].join("");
10
+ /** Prints the HTTP status of GET URL (0 if nothing answers); a bearer token, if any, comes on stdin. */
11
+ export const HTTP_STATUS_SCRIPT = [
12
+ "let t='';process.stdin.on('data',d=>t+=d).on('end',async()=>{",
13
+ "try{const r=await fetch(process.argv[1],{headers:t.trim()?{authorization:'Bearer '+t.trim()}:{},signal:AbortSignal.timeout(5000)});",
14
+ "console.log(r.status)}catch{console.log(0)}});",
15
+ ].join("");
16
+ export async function portFree(system, node, host, port) {
17
+ return (await system.probe(node, ["-e", PORT_FREE_SCRIPT, host, String(port)])).code === 0;
18
+ }
19
+ export async function httpStatus(system, node, url, token = "") {
20
+ const result = await system.probe(node, ["-e", HTTP_STATUS_SCRIPT, url], { input: token });
21
+ return Number(result.stdout.trim()) || 0;
22
+ }
@@ -0,0 +1,68 @@
1
+ import { LOCAL_SERVICES, launchAgentPlist, serviceFile, systemdUserUnit, } from "./layout.js";
2
+ const ORDER = ["hotcell", "autopilot"];
3
+ export async function userId(system) {
4
+ const uid = (await system.probe("id", ["-u"])).stdout.trim();
5
+ if (!/^\d+$/.test(uid))
6
+ throw new Error("Couldn't tell which user this is (id -u).");
7
+ if (uid === "0")
8
+ throw new Error("Run this as yourself, not as root or with sudo: the agents run as your user.");
9
+ return uid;
10
+ }
11
+ /** Writes the service definition, then starts (or restarts) it. */
12
+ export async function installService(system, manager, spec) {
13
+ const { platform, paths } = manager;
14
+ const file = serviceFile(paths, platform, spec.service);
15
+ await system.mkdir(paths.services, { mode: platform === "darwin" ? 0o755 : 0o700 });
16
+ if (platform === "darwin") {
17
+ await system.writeFile(file, launchAgentPlist(spec), { mode: 0o644 });
18
+ await stopService(system, manager, spec.service);
19
+ const target = `gui/${manager.uid}/${LOCAL_SERVICES[spec.service].label}`;
20
+ // A service the user disabled stays disabled across bootstrap; setup means "on".
21
+ await system.run("launchctl", ["enable", target], { allowFailure: true });
22
+ // bootout returns before launchd has let go of the label; bootstrap can briefly fail with EIO.
23
+ for (let attempt = 1;; attempt++) {
24
+ const result = await system.run("launchctl", ["bootstrap", `gui/${manager.uid}`, file], { allowFailure: true });
25
+ if (result.code === 0)
26
+ break;
27
+ if (attempt >= 5)
28
+ throw new Error(`launchd wouldn't start ${spec.service}: ${(result.stderr || result.stdout).trim()}`);
29
+ await manager.sleep(1_000);
30
+ }
31
+ return;
32
+ }
33
+ const unit = LOCAL_SERVICES[spec.service].unit;
34
+ await system.writeFile(file, systemdUserUnit(spec, spec.service === "autopilot" ? LOCAL_SERVICES.hotcell.unit : undefined), { mode: 0o644 });
35
+ await system.run("systemctl", ["--user", "daemon-reload"]);
36
+ await system.run("systemctl", ["--user", "enable", unit]);
37
+ await system.run("systemctl", ["--user", "restart", unit]);
38
+ }
39
+ export async function stopService(system, manager, service) {
40
+ if (manager.platform === "darwin") {
41
+ await system.run("launchctl", ["bootout", `gui/${manager.uid}/${LOCAL_SERVICES[service].label}`], { allowFailure: true });
42
+ }
43
+ else {
44
+ await system.run("systemctl", ["--user", "disable", "--now", LOCAL_SERVICES[service].unit], { allowFailure: true });
45
+ }
46
+ }
47
+ /** Stops and deletes both service definitions. */
48
+ export async function removeServices(system, manager) {
49
+ for (const service of [...ORDER].reverse()) {
50
+ await stopService(system, manager, service);
51
+ await system.remove(serviceFile(manager.paths, manager.platform, service));
52
+ }
53
+ if (manager.platform === "linux") {
54
+ await system.run("systemctl", ["--user", "daemon-reload"], { allowFailure: true });
55
+ await system.run("systemctl", ["--user", "reset-failed"], { allowFailure: true });
56
+ }
57
+ }
58
+ export async function serviceState(system, manager, service) {
59
+ const installed = await system.exists(serviceFile(manager.paths, manager.platform, service));
60
+ if (manager.platform === "darwin") {
61
+ const printed = await system.probe("launchctl", ["print", `gui/${manager.uid}/${LOCAL_SERVICES[service].label}`]);
62
+ const running = printed.code === 0 && /^\s*state = running\s*$/m.test(printed.stdout);
63
+ const pid = Number(printed.stdout.match(/^\s*pid = (\d+)\s*$/m)?.[1]);
64
+ return { installed, running, ...(pid ? { pid } : {}) };
65
+ }
66
+ const active = await system.probe("systemctl", ["--user", "is-active", LOCAL_SERVICES[service].unit]);
67
+ return { installed, running: active.stdout.trim() === "active" };
68
+ }