directionallylocal 0.3.0 → 0.4.1
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 +202 -0
- package/README.md +4 -0
- package/dist/cli.js +33 -11
- package/dist/doctor.js +6 -3
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +130 -25
- package/dist/laptop/config.js +108 -22
- package/dist/laptop/credentials.js +2 -2
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/setup.js +101 -53
- package/dist/laptop/ssh.js +11 -6
- package/dist/layout.js +4 -0
- package/dist/local/cli.js +4 -1
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +3 -0
- package/dist/local/setup.js +13 -3
- package/dist/local/test-support/fake-computer.js +27 -3
- package/dist/model-prices.js +119 -0
- package/dist/up.js +16 -5
- package/package.json +2 -1
package/dist/laptop/setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { access as fileAccess, readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, hostname } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { TtyPrompter } from "../prompt.js";
|
|
7
7
|
import { palette, PlainUi, TerminalUi } from "../ui.js";
|
|
@@ -40,34 +40,12 @@ export async function setupWizard(deps) {
|
|
|
40
40
|
return setUpServer(deps);
|
|
41
41
|
}
|
|
42
42
|
async function setUpServer(deps) {
|
|
43
|
-
const c = palette(deps.color);
|
|
44
43
|
const home = deps.sources.home;
|
|
45
44
|
const sshDir = join(home, ".ssh");
|
|
46
45
|
const hosts = parseSshConfig(await deps.sources.readFile(join(sshDir, "config")) ?? "", home);
|
|
47
46
|
// 1. Which server, and can we get in?
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
for (let attempt = 0; !facts; attempt++) {
|
|
51
|
-
const ui = deps.newUi();
|
|
52
|
-
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
53
|
-
ui.start("ssh", display);
|
|
54
|
-
try {
|
|
55
|
-
facts = await checkServer(access, deps.ssh, display);
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
ui.fail("ssh", error instanceof SshAuthError ? "SSH key not accepted" : "can't set up here");
|
|
59
|
-
ui.finish();
|
|
60
|
-
if (!(error instanceof SshAuthError) || attempt >= 2)
|
|
61
|
-
throw explainAuth(error, access);
|
|
62
|
-
access = { ...access, identityFile: await chooseKey(deps, sshDir, access) };
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
display = `${facts.user}@${display.replace(/^[^@]*@/, "")}`;
|
|
66
|
-
ui.done("ssh", `${display} ${facts.os} · ${facts.arch}`);
|
|
67
|
-
ui.finish();
|
|
68
|
-
if (facts.newHostKey)
|
|
69
|
-
deps.newUi().line("info", "New server", "first connection: trusted its host key (accept-new)");
|
|
70
|
-
}
|
|
47
|
+
const chosen = await chooseServer(deps, hosts);
|
|
48
|
+
const { access, facts } = await reachServer(deps, sshDir, deps.identityFile ? { ...chosen.access, identityFile: deps.identityFile } : chosen.access, chosen.display);
|
|
71
49
|
// 2. Install dl there, then ask only for the keys it still lacks.
|
|
72
50
|
await bootstrap(access, facts, deps.ssh, deps.newUi(), deps.installUrl);
|
|
73
51
|
const needs = await serverNeeds(access, facts, deps.ssh);
|
|
@@ -90,7 +68,7 @@ async function setUpServer(deps) {
|
|
|
90
68
|
if (!remote) {
|
|
91
69
|
const pairing = await pairLaptop(access, facts, deps.ssh, `${laptopName(deps.hostname)} (dl)`);
|
|
92
70
|
remote = {
|
|
93
|
-
name: saved?.name ?? await remoteName(access, remotesFile),
|
|
71
|
+
name: saved?.name ?? await remoteName(access, remotesFile, chosen.alias ?? facts.hostname),
|
|
94
72
|
url: normalizeServerUrl(pairing.url),
|
|
95
73
|
token: pairing.token,
|
|
96
74
|
credentialId: pairing.credentialId,
|
|
@@ -135,6 +113,7 @@ async function chooseServer(deps, hosts) {
|
|
|
135
113
|
if (host) {
|
|
136
114
|
ui.answered("SSH", host.alias);
|
|
137
115
|
return {
|
|
116
|
+
alias: host.alias,
|
|
138
117
|
access: { target: host.alias },
|
|
139
118
|
display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
|
|
140
119
|
};
|
|
@@ -155,30 +134,85 @@ async function chooseServer(deps, hosts) {
|
|
|
155
134
|
}
|
|
156
135
|
}
|
|
157
136
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
deps.write("\n");
|
|
137
|
+
/**
|
|
138
|
+
* Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
|
|
139
|
+
* ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
|
|
140
|
+
* refusals come back fast, so this takes seconds. Only when none works does it stop, with
|
|
141
|
+
* what to do. A key named with --identity is the only one tried.
|
|
142
|
+
*/
|
|
143
|
+
async function reachServer(deps, sshDir, first, target) {
|
|
144
|
+
const home = deps.sources.home;
|
|
167
145
|
const ui = deps.newUi();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
146
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
147
|
+
ui.start("ssh", target);
|
|
148
|
+
const tried = [];
|
|
149
|
+
let searching = false;
|
|
150
|
+
let newHostKey = false;
|
|
151
|
+
const attempt = async (access) => {
|
|
152
|
+
try {
|
|
153
|
+
return await checkServer(access, deps.ssh, target);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof SshAuthError) {
|
|
157
|
+
newHostKey ||= error.newHostKey;
|
|
158
|
+
if (access.identityFile)
|
|
159
|
+
tried.push(access.identityFile);
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
if (searching)
|
|
163
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
164
|
+
ui.fail("ssh", "can't set up here");
|
|
165
|
+
ui.finish();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
let access = first;
|
|
170
|
+
let facts = await attempt(access);
|
|
171
|
+
if (!facts && !first.identityFile) {
|
|
172
|
+
const keys = discoverKeys(sshDir, await deps.sources.listDir(sshDir));
|
|
173
|
+
searching = keys.length > 0;
|
|
174
|
+
if (searching)
|
|
175
|
+
ui.plan([{ id: "keys", label: "Trying your SSH keys…" }]);
|
|
176
|
+
for (const key of keys) {
|
|
177
|
+
access = { ...first, identityFile: key };
|
|
178
|
+
ui.start("keys", tilde(key, home));
|
|
179
|
+
facts = await attempt(access);
|
|
180
|
+
if (facts)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (searching)
|
|
185
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
186
|
+
if (!facts) {
|
|
187
|
+
ui.fail("ssh", first.identityFile ? `${tilde(first.identityFile, home)} not accepted` : tried.length > 0 ? "none of your SSH keys was accepted" : "SSH key not accepted");
|
|
188
|
+
ui.finish();
|
|
189
|
+
throw noKeyWorks(first, tried, discoverKeys(sshDir, await deps.sources.listDir(sshDir)), home);
|
|
190
|
+
}
|
|
191
|
+
const display = `${facts.user}@${target.replace(/^[^@]*@/, "")}`;
|
|
192
|
+
ui.done("ssh", access.identityFile ? `${display} with ${tilde(access.identityFile, home)} · ${facts.os} · ${facts.arch}` : `${display} ${facts.os} · ${facts.arch}`);
|
|
193
|
+
ui.finish();
|
|
194
|
+
if (facts.newHostKey || newHostKey)
|
|
195
|
+
deps.newUi().line("info", "New server", "first connection: trusted its host key (accept-new)");
|
|
196
|
+
return { access, facts };
|
|
172
197
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
198
|
+
/** Every key was refused: what to do next, with this server and this laptop's own key filled in. */
|
|
199
|
+
function noKeyWorks(access, tried, keys, home) {
|
|
200
|
+
const port = access.port ? `-p ${access.port} ` : "";
|
|
201
|
+
const key = keys[0] ? tilde(keys[0], home) : undefined;
|
|
202
|
+
const lines = [
|
|
203
|
+
access.identityFile
|
|
204
|
+
? `${access.target} didn't accept ${tilde(access.identityFile, home)}.`
|
|
205
|
+
: `${access.target} didn't accept this computer's SSH keys (${["the default ones", ...tried.map((file) => tilde(file, home))].join(", ")}).`,
|
|
206
|
+
" Add your public key to the server (it asks for the password once):",
|
|
207
|
+
...(key ? [] : [" ssh-keygen -t ed25519"]),
|
|
208
|
+
` ssh-copy-id -i ${key ?? "~/.ssh/id_ed25519"}.pub ${port}${access.target}`,
|
|
180
209
|
" If your key has a passphrase, load it first with: ssh-add",
|
|
181
|
-
|
|
210
|
+
" Or name the key this server takes: dl setup --identity ~/.ssh/KEY",
|
|
211
|
+
];
|
|
212
|
+
return new Error(lines.join("\n"));
|
|
213
|
+
}
|
|
214
|
+
function tilde(path, home) {
|
|
215
|
+
return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
182
216
|
}
|
|
183
217
|
function sameServer(remote, access) {
|
|
184
218
|
return remote.ssh?.target === access.target && remote.ssh.port === access.port;
|
|
@@ -189,12 +223,14 @@ async function stillWorks(remote, open) {
|
|
|
189
223
|
return true;
|
|
190
224
|
}).catch(() => false);
|
|
191
225
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
226
|
+
/**
|
|
227
|
+
* What this laptop calls the server: the ~/.ssh/config alias the user picked, else the
|
|
228
|
+
* server's own hostname, else its address (203-0-113-10). Unique unless it's this same server.
|
|
229
|
+
*/
|
|
230
|
+
async function remoteName(access, remotesFile, preferred) {
|
|
194
231
|
const host = hostOf(access.target).toLowerCase();
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const name = REMOTE_NAME_RE.test(base) ? base : "server";
|
|
232
|
+
const fromAddress = /^\d+(\.\d+){3}$/.test(host) || host.includes(":") ? host.replace(/[.:]/g, "-") : host.split(".")[0] ?? "";
|
|
233
|
+
const name = [preferred, fromAddress].map((label) => serverLabel(label ?? "")).find((label) => label !== undefined) ?? "server";
|
|
198
234
|
const remotes = await loadRemotes(remotesFile);
|
|
199
235
|
for (let n = 1;; n++) {
|
|
200
236
|
const candidate = n === 1 ? name : `${name}-${n}`;
|
|
@@ -203,6 +239,11 @@ async function remoteName(access, remotesFile) {
|
|
|
203
239
|
return candidate;
|
|
204
240
|
}
|
|
205
241
|
}
|
|
242
|
+
/** A hostname as a server name (lowercase, dashes, room for a -2), or undefined if nothing usable is left. */
|
|
243
|
+
export function serverLabel(text) {
|
|
244
|
+
const label = text.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 36).replace(/-+$/, "");
|
|
245
|
+
return REMOTE_NAME_RE.test(label) && label !== "localhost" ? label : undefined;
|
|
246
|
+
}
|
|
206
247
|
function finishScreen(ui, capacity) {
|
|
207
248
|
ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
|
|
208
249
|
{ label: "From any GitHub repository", value: RUN_EXAMPLE },
|
|
@@ -230,6 +271,12 @@ export async function setup(options) {
|
|
|
230
271
|
const color = io.color === true;
|
|
231
272
|
const execFileAsync = promisify(execFile);
|
|
232
273
|
const home = homedir();
|
|
274
|
+
let identityFile;
|
|
275
|
+
if (options.identityFile !== undefined) {
|
|
276
|
+
// The private key, even when the .pub next to it was named.
|
|
277
|
+
identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
|
|
278
|
+
await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
|
|
279
|
+
}
|
|
233
280
|
await setupWizard({
|
|
234
281
|
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
235
282
|
prompter: new TtyPrompter(),
|
|
@@ -248,6 +295,7 @@ export async function setup(options) {
|
|
|
248
295
|
hostname: hostname(),
|
|
249
296
|
installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
|
|
250
297
|
...(options.localSetup ? { localSetup: options.localSetup } : {}),
|
|
298
|
+
...(identityFile ? { identityFile } : {}),
|
|
251
299
|
});
|
|
252
300
|
return 0;
|
|
253
301
|
}
|
package/dist/laptop/ssh.js
CHANGED
|
@@ -139,19 +139,23 @@ export function lines(collect, onLine) {
|
|
|
139
139
|
onLine(part.replace(/\r$/, ""));
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
|
-
/** The server refused our keys; the wizard
|
|
142
|
+
/** The server refused our keys; the wizard tries the others in ~/.ssh. */
|
|
143
143
|
export class SshAuthError extends Error {
|
|
144
144
|
access;
|
|
145
|
-
|
|
145
|
+
newHostKey;
|
|
146
|
+
constructor(access, detail, newHostKey = false) {
|
|
146
147
|
super(`The server didn't accept this computer's SSH key (${detail}).`);
|
|
147
148
|
this.access = access;
|
|
149
|
+
this.newHostKey = newHostKey;
|
|
148
150
|
this.name = "SshAuthError";
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
153
|
const SUPPORTED_OS = new Set(["ubuntu 22.04", "ubuntu 24.04", "debian 12"]);
|
|
152
|
-
// One round trip: who we are, the OS, the CPU,
|
|
154
|
+
// One round trip: who we are, the OS, the CPU, whether we can become root without a password,
|
|
155
|
+
// and the server's own name (what the laptop calls it unless ~/.ssh/config already names it).
|
|
153
156
|
const PROBE = 'id -u; id -un; . /etc/os-release; echo "$ID $VERSION_ID"; uname -m; ' +
|
|
154
|
-
'if [ "$(id -u)" = 0 ]; then echo root; elif sudo -n true 2>/dev/null; then echo sudo; else echo nosudo; fi'
|
|
157
|
+
'if [ "$(id -u)" = 0 ]; then echo root; elif sudo -n true 2>/dev/null; then echo sudo; else echo nosudo; fi; ' +
|
|
158
|
+
'hostname -s 2>/dev/null || cat /etc/hostname 2>/dev/null';
|
|
155
159
|
/** Connects once and checks the server can take an install: a supported OS, and root or passwordless sudo. */
|
|
156
160
|
export async function checkServer(access, ssh, display) {
|
|
157
161
|
const result = await ssh(access, PROBE);
|
|
@@ -159,14 +163,14 @@ export async function checkServer(access, ssh, display) {
|
|
|
159
163
|
if (result.code === 255 || result.stdout.trim() === "") {
|
|
160
164
|
const detail = lastLine(result.stderr) || `ssh exited with ${result.code}`;
|
|
161
165
|
if (/Permission denied|Too many authentication failures|no mutual signature|Authentication failed/i.test(result.stderr)) {
|
|
162
|
-
throw new SshAuthError(access, detail);
|
|
166
|
+
throw new SshAuthError(access, detail, newHostKey);
|
|
163
167
|
}
|
|
164
168
|
if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(result.stderr)) {
|
|
165
169
|
throw new Error(`${display}'s SSH host key changed since this computer last connected, so it stopped (that can mean someone is in the middle). If you rebuilt the server, remove the old key with: ssh-keygen -R ${hostOf(access.target)}`);
|
|
166
170
|
}
|
|
167
171
|
throw new Error(`Couldn't reach ${display} over SSH: ${detail}\n Check that this works: ${sshCommandLine(access)}`);
|
|
168
172
|
}
|
|
169
|
-
const [uid, user, osLine, arch, privilege] = result.stdout.trim().split("\n").map((line) => line.trim());
|
|
173
|
+
const [uid, user, osLine, arch, privilege, name] = result.stdout.trim().split("\n").map((line) => line.trim());
|
|
170
174
|
const os = formatOs(osLine ?? "");
|
|
171
175
|
const facts = {
|
|
172
176
|
user: user || "?",
|
|
@@ -175,6 +179,7 @@ export async function checkServer(access, ssh, display) {
|
|
|
175
179
|
os,
|
|
176
180
|
arch: arch === "aarch64" ? "arm64" : arch || "?",
|
|
177
181
|
newHostKey,
|
|
182
|
+
...(name ? { hostname: name } : {}),
|
|
178
183
|
};
|
|
179
184
|
if (!facts.root && !facts.sudo) {
|
|
180
185
|
throw new Error([
|
package/dist/layout.js
CHANGED
|
@@ -8,6 +8,8 @@ export const PATHS = {
|
|
|
8
8
|
hotcellEnv: "/etc/hotcell/hotcell.env",
|
|
9
9
|
hotcellEtc: "/etc/hotcell",
|
|
10
10
|
hotcellHome: "/var/lib/hotcell",
|
|
11
|
+
/** OpenRouter's prices, so Hotcell's spend-capped gateway forwards current models. */
|
|
12
|
+
modelPrices: "/var/lib/hotcell/model-prices.json",
|
|
11
13
|
autopilotEtc: "/etc/autopilot",
|
|
12
14
|
autopilotEnv: "/etc/autopilot/autopilot.env",
|
|
13
15
|
githubEnv: "/etc/autopilot/github.env",
|
|
@@ -70,6 +72,8 @@ export function hotcellEnv(input) {
|
|
|
70
72
|
HOTCELL_EGRESS_HOST: input.bridgeIp,
|
|
71
73
|
HOTCELL_EGRESS_PORT: String(HOTCELL_EGRESS_PORT),
|
|
72
74
|
HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
|
|
75
|
+
// Under a spend cap the gateway refuses models it can't price; `dl up` refreshes this list.
|
|
76
|
+
HOTCELL_MODEL_PRICES: PATHS.modelPrices,
|
|
73
77
|
});
|
|
74
78
|
}
|
|
75
79
|
export function autopilotEnv(input) {
|
package/dist/local/cli.js
CHANGED
|
@@ -28,12 +28,15 @@ export function formatLocalStatus(status, c, home) {
|
|
|
28
28
|
const mark = (ok) => (ok ? c.green("✓") : c.red("✗"));
|
|
29
29
|
const line = (ok, label, detail) => ` ${mark(ok)} ${label.padEnd(12)}${c.dim(detail)}\n`;
|
|
30
30
|
const describe = (entry) => entry.running ? `running${entry.pid ? ` (pid ${entry.pid})` : ""}` : entry.installed ? "stopped" : "not installed";
|
|
31
|
-
const healthy = services.hotcell.running && services.autopilot.running && status.answering && status.dockerRunning;
|
|
31
|
+
const healthy = services.hotcell.running && services.autopilot.running && status.answering && status.dockerRunning && status.modelPrices.ok;
|
|
32
32
|
return [
|
|
33
33
|
`\n ${c.bold("Directionally Local")} ${c.dim("on this computer")} ${healthy ? c.green("running") : c.red("needs attention")}\n\n`,
|
|
34
34
|
line(status.dockerRunning, "Docker", status.dockerRunning ? state.dockerRuntime : `${state.dockerRuntime} isn't running; start it`),
|
|
35
35
|
line(services.hotcell.running, "Hotcell", `${describe(services.hotcell)} · 127.0.0.1:${state.ports.hotcell} · ${state.hotcellVersion}`),
|
|
36
36
|
line(services.autopilot.running && status.answering, "Autopilot", `${describe(services.autopilot)}${status.answering ? "" : ", not answering"} · ${state.url} · ${state.autopilotVersion}`),
|
|
37
|
+
status.modelPrices.warn
|
|
38
|
+
? ` ${c.yellow("!")} ${"Prices".padEnd(12)}${c.dim(status.modelPrices.detail)}\n`
|
|
39
|
+
: line(status.modelPrices.ok, "Prices", status.modelPrices.detail),
|
|
37
40
|
`\n ${c.dim("Fits")} ${status.capacity !== undefined ? `~${status.capacity} agents at once (estimate, from Docker's memory)` : "unknown while Docker is stopped"}\n`,
|
|
38
41
|
` ${c.dim("Installed")} ${short(status.paths.current)}\n`,
|
|
39
42
|
` ${c.dim("Logs")} ${short(status.paths.logs)}\n`,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dl keys` for "on this computer": the same as on a server, in the user's home. The
|
|
3
|
+
* OpenRouter key goes to Hotcell's 0600 file keystore over stdin (HOTCELL_KEYSTORE=file,
|
|
4
|
+
* see setup.ts for why not the Keychain), the GitHub token to the 0600 github.env, and
|
|
5
|
+
* the service that reads it (a LaunchAgent or a systemd user unit) restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { checkKey, githubListing, hotcellKeyListed, KeyRejectedError } from "../keys.js";
|
|
10
|
+
import { RealSystem } from "../system.js";
|
|
11
|
+
import { supportedPlatform } from "./check.js";
|
|
12
|
+
import { LOCAL_SERVICES, localGithubEnv, localHotcellBin, localPaths, parseLocalEnvFile, serviceFile } from "./layout.js";
|
|
13
|
+
import { httpStatus } from "./probes.js";
|
|
14
|
+
import { userId } from "./services.js";
|
|
15
|
+
import { hotcellCliEnv } from "./setup.js";
|
|
16
|
+
import { readLocalState } from "./state.js";
|
|
17
|
+
async function installed(options) {
|
|
18
|
+
const home = options.home ?? homedir();
|
|
19
|
+
const platform = supportedPlatform(options.platform ?? process.platform);
|
|
20
|
+
if (!platform)
|
|
21
|
+
throw new Error("Agents run on this computer only on macOS and Linux.");
|
|
22
|
+
const system = options.system ?? new RealSystem(() => { });
|
|
23
|
+
const paths = localPaths(home, platform);
|
|
24
|
+
const state = await readLocalState(system, paths);
|
|
25
|
+
if (!state)
|
|
26
|
+
throw new Error("Directionally Local isn't set up on this computer; run dl setup.");
|
|
27
|
+
return {
|
|
28
|
+
system,
|
|
29
|
+
home,
|
|
30
|
+
platform,
|
|
31
|
+
paths,
|
|
32
|
+
state,
|
|
33
|
+
node: options.execPath ?? process.execPath,
|
|
34
|
+
sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Stores the key where setup does, restarts the service that reads it, and waits for it to answer. */
|
|
38
|
+
export async function setLocalKey(which, value, options = {}) {
|
|
39
|
+
const key = value.trim();
|
|
40
|
+
const checked = await checkKey(which, key, options.fetchImpl);
|
|
41
|
+
if (checked.problem)
|
|
42
|
+
throw new KeyRejectedError(checked.problem);
|
|
43
|
+
const ctx = await installed(options);
|
|
44
|
+
const { system, paths, state, node } = ctx;
|
|
45
|
+
const uid = await userId(system);
|
|
46
|
+
if (which === "openrouter") {
|
|
47
|
+
await system.run(node, [localHotcellBin(paths), "keys", "add", "openrouter", "--stdin"], {
|
|
48
|
+
input: key,
|
|
49
|
+
env: hotcellCliEnv(paths),
|
|
50
|
+
describe: "hotcell keys add openrouter --stdin",
|
|
51
|
+
});
|
|
52
|
+
await restartService(ctx, uid, "hotcell");
|
|
53
|
+
await waitUntil(ctx, "Hotcell", async () => await httpStatus(system, node, `http://127.0.0.1:${state.ports.hotcell}/`) > 0);
|
|
54
|
+
return { ok: true, key: which, detail: "replaced in Hotcell; Hotcell restarted" };
|
|
55
|
+
}
|
|
56
|
+
await system.writeFile(paths.githubEnv, localGithubEnv(key), { mode: 0o600, secret: true });
|
|
57
|
+
await restartService(ctx, uid, "autopilot");
|
|
58
|
+
// Without a token the Aster API answers 401: up, and refusing strangers.
|
|
59
|
+
await waitUntil(ctx, "Autopilot", async () => await httpStatus(system, node, `${state.url}/api/v1/health`) === 401);
|
|
60
|
+
return { ok: true, key: which, detail: `${checked.detail ? `${checked.detail}; ` : ""}Autopilot restarted` };
|
|
61
|
+
}
|
|
62
|
+
/** Which keys this computer's install has; never their values. */
|
|
63
|
+
export async function listLocalKeys(options = {}) {
|
|
64
|
+
const { system, paths, node } = await installed(options);
|
|
65
|
+
let openrouter = { key: "openrouter", set: false };
|
|
66
|
+
if (await system.exists(localHotcellBin(paths))) {
|
|
67
|
+
const listed = await system.probe(node, [localHotcellBin(paths), "keys", "ls"], { env: { ...hotcellCliEnv(paths), NO_COLOR: "1" } });
|
|
68
|
+
if (listed.code === 0)
|
|
69
|
+
openrouter = { key: "openrouter", ...hotcellKeyListed(listed.stdout, "openrouter") };
|
|
70
|
+
}
|
|
71
|
+
const token = parseLocalEnvFile(await system.readFile(paths.githubEnv)).GITHUB_TOKEN;
|
|
72
|
+
return [openrouter, await githubListing(token, options.fetchImpl)];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Restarts one service so it reads its keys again: `launchctl kickstart -k` on macOS (or
|
|
76
|
+
* bootstrap, when it isn't loaded), `systemctl --user restart` on Linux.
|
|
77
|
+
*/
|
|
78
|
+
async function restartService(ctx, uid, service) {
|
|
79
|
+
const { system, platform, paths } = ctx;
|
|
80
|
+
if (platform === "darwin") {
|
|
81
|
+
const restarted = await system.run("launchctl", ["kickstart", "-k", `gui/${uid}/${LOCAL_SERVICES[service].label}`], { allowFailure: true });
|
|
82
|
+
if (restarted.code === 0)
|
|
83
|
+
return;
|
|
84
|
+
const file = serviceFile(paths, platform, service);
|
|
85
|
+
if (!(await system.exists(file)))
|
|
86
|
+
throw new Error(`The ${service} service isn't installed on this computer; run dl setup to repair it.`);
|
|
87
|
+
await system.run("launchctl", ["bootstrap", `gui/${uid}`, file]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await system.run("systemctl", ["--user", "restart", LOCAL_SERVICES[service].unit]);
|
|
91
|
+
}
|
|
92
|
+
async function waitUntil(ctx, what, ready) {
|
|
93
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
94
|
+
if (await ready())
|
|
95
|
+
return;
|
|
96
|
+
await ctx.sleep(1_000);
|
|
97
|
+
}
|
|
98
|
+
const log = join(ctx.paths.logs, LOCAL_SERVICES[what === "Hotcell" ? "hotcell" : "autopilot"].log);
|
|
99
|
+
throw new Error(`${what} didn't come back up; see ${log.startsWith(`${ctx.home}/`) ? `~${log.slice(ctx.home.length)}` : log}`);
|
|
100
|
+
}
|
package/dist/local/layout.js
CHANGED
|
@@ -21,6 +21,7 @@ export function localPaths(home, platform) {
|
|
|
21
21
|
githubEnv: join(config, "github.env"),
|
|
22
22
|
asterConfig: join(config, "aster.json"),
|
|
23
23
|
hotcellHome: join(root, "hotcell"),
|
|
24
|
+
modelPrices: join(root, "hotcell", "model-prices.json"),
|
|
24
25
|
autopilotHome,
|
|
25
26
|
workspace: join(autopilotHome, "workspaces", "default"),
|
|
26
27
|
services: platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"),
|
|
@@ -89,6 +90,8 @@ export function localHotcellEnv(input) {
|
|
|
89
90
|
HOTCELL_EGRESS_HOST: input.net.listenHost,
|
|
90
91
|
HOTCELL_EGRESS_PORT: String(ports.hotcellEgress),
|
|
91
92
|
HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
|
|
93
|
+
// Under a spend cap the gateway refuses models it can't price; setup refreshes this list.
|
|
94
|
+
HOTCELL_MODEL_PRICES: paths.modelPrices,
|
|
92
95
|
});
|
|
93
96
|
}
|
|
94
97
|
export function localAutopilotEnv(input) {
|
package/dist/local/setup.js
CHANGED
|
@@ -28,6 +28,7 @@ import { removeRemote } from "../laptop/config.js";
|
|
|
28
28
|
import { RealSystem } from "../system.js";
|
|
29
29
|
import { checkComputer, computerProblems, localCapacity, servicePath, supportedPlatform } from "./check.js";
|
|
30
30
|
import { LOCAL_SERVICES, PAIRED_NAME, PREFERRED_PORTS, autopilotXdg, localAsterConfig, localAutopilotBin, localAutopilotEnv, localGithubEnv, localHotcellBin, localHotcellEnv, localPaths, parseLocalEnvFile, serviceSpecs, } from "./layout.js";
|
|
31
|
+
import { fileMtime, modelPricesHealth, refreshModelPrices } from "../model-prices.js";
|
|
31
32
|
import { httpStatus, portFree } from "./probes.js";
|
|
32
33
|
import { installService, removeServices, serviceState, stopService, userId } from "./services.js";
|
|
33
34
|
import { readLocalState, writeLocalState } from "./state.js";
|
|
@@ -153,6 +154,14 @@ export async function setUpOnThisComputer(input) {
|
|
|
153
154
|
const previousAutopilot = parseLocalEnvFile(await system.readFile(paths.autopilotEnv));
|
|
154
155
|
const ports = await choosePorts(ctx, node, net, previousPorts(previous, previousAutopilot));
|
|
155
156
|
const apiKey = previous.HOTCELL_API_KEY || randomBytes(24).toString("hex");
|
|
157
|
+
// Hotcell reads it once at start, so it's in place before the service starts below.
|
|
158
|
+
const prices = await refreshModelPrices(system, {
|
|
159
|
+
path: paths.modelPrices,
|
|
160
|
+
write: { mode: 0o644 },
|
|
161
|
+
fetchImpl: input.fetchImpl ?? fetch,
|
|
162
|
+
warn: (text) => ui.warn(text),
|
|
163
|
+
retry: "dl setup",
|
|
164
|
+
});
|
|
156
165
|
await system.writeFile(paths.hotcellEnv, localHotcellEnv({ paths, apiKey, ports, net }), { mode: 0o600, secret: true });
|
|
157
166
|
if (!(await system.readFile(paths.asterConfig)))
|
|
158
167
|
await system.writeFile(paths.asterConfig, localAsterConfig(paths), { mode: 0o600 });
|
|
@@ -168,7 +177,7 @@ export async function setUpOnThisComputer(input) {
|
|
|
168
177
|
// Without a token the Aster API answers 401: up, and refusing strangers.
|
|
169
178
|
await waitUntil(ctx, "Autopilot", join(paths.logs, LOCAL_SERVICES.autopilot.log), async () => await httpStatus(system, node, `http://127.0.0.1:${ports.autopilot}/api/v1/health`) === 401);
|
|
170
179
|
await pruneReleases(system, paths, release);
|
|
171
|
-
return { value: ports, detail: `start at login · logs in ${tilde(paths.logs, ctx.home)}` };
|
|
180
|
+
return { value: ports, detail: `start at login · logs in ${tilde(paths.logs, ctx.home)}${prices ? ` · ${prices}` : ""}` };
|
|
172
181
|
});
|
|
173
182
|
const url = `http://127.0.0.1:${ports.autopilot}`;
|
|
174
183
|
return await step("connect", async () => {
|
|
@@ -296,7 +305,8 @@ export async function localStatus(options = {}) {
|
|
|
296
305
|
capacity = localCapacity({ memoryMb: Math.round(Number(mem) / 1024 / 1024), cpus: Number(cpus) || 0 });
|
|
297
306
|
}
|
|
298
307
|
}
|
|
299
|
-
|
|
308
|
+
const modelPrices = modelPricesHealth(await system.readFile(paths.modelPrices), await fileMtime(system, paths.modelPrices, platform), (options.now ?? (() => new Date()))(), "dl setup");
|
|
309
|
+
return { installed: Boolean(state), state, services: { hotcell, autopilot }, answering, capacity, dockerRunning, modelPrices, paths, platform: ctx.rawPlatform };
|
|
300
310
|
}
|
|
301
311
|
function checkOptions(ctx, options) {
|
|
302
312
|
return {
|
|
@@ -320,7 +330,7 @@ async function openDockerApp(ctx, facts, options) {
|
|
|
320
330
|
}
|
|
321
331
|
return facts;
|
|
322
332
|
}
|
|
323
|
-
function hotcellCliEnv(paths) {
|
|
333
|
+
export function hotcellCliEnv(paths) {
|
|
324
334
|
return { HOME: paths.hotcellHome, HOTCELL_HOME: paths.hotcellHome, HOTCELL_KEYSTORE: "file" };
|
|
325
335
|
}
|
|
326
336
|
async function hotcellHasKey(system, node, paths, provider) {
|
|
@@ -9,6 +9,8 @@ export class FakeComputer {
|
|
|
9
9
|
home;
|
|
10
10
|
dryRun = false;
|
|
11
11
|
files = new Map();
|
|
12
|
+
/** Modification times (seconds) for `stat`; set on every write. */
|
|
13
|
+
mtimes = new Map();
|
|
12
14
|
dirs = new Map();
|
|
13
15
|
commands = [];
|
|
14
16
|
probes = [];
|
|
@@ -17,6 +19,8 @@ export class FakeComputer {
|
|
|
17
19
|
containers = [];
|
|
18
20
|
/** launchd labels or systemd units that are running. */
|
|
19
21
|
running = new Set();
|
|
22
|
+
/** Services restarted in place (launchctl kickstart -k, systemctl --user restart), in order. */
|
|
23
|
+
restarted = [];
|
|
20
24
|
/** Ports something else on this computer listens on. */
|
|
21
25
|
busy = new Set();
|
|
22
26
|
credentials = [];
|
|
@@ -60,6 +64,7 @@ export class FakeComputer {
|
|
|
60
64
|
}
|
|
61
65
|
async writeFile(path, content, options = {}) {
|
|
62
66
|
this.files.set(path, { content, mode: options.mode ?? 0o644 });
|
|
67
|
+
this.mtimes.set(path, Math.floor(Date.now() / 1000));
|
|
63
68
|
}
|
|
64
69
|
async mkdir(path, options = {}) {
|
|
65
70
|
this.dirs.set(path, options.mode);
|
|
@@ -128,8 +133,13 @@ export class FakeComputer {
|
|
|
128
133
|
this.files.set(`${home}/keys.json`, { content: "{}", mode: 0o600 });
|
|
129
134
|
return ok("stored\n");
|
|
130
135
|
}
|
|
131
|
-
if (args[1] === "keys" && args[2] === "ls")
|
|
132
|
-
|
|
136
|
+
if (args[1] === "keys" && args[2] === "ls") {
|
|
137
|
+
if (!this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`))
|
|
138
|
+
return ok("");
|
|
139
|
+
// Like Hotcell's own listing: a header, then each provider with its masked value.
|
|
140
|
+
const mask = (value) => value.length <= 8 ? "•".repeat(Math.max(4, value.length)) : `${value.slice(0, 5)}…${value.slice(-2)}`;
|
|
141
|
+
return ok(`PROVIDER KEY SOURCE\n${[...this.hotcellKeys].map(([key, value]) => `${key.padEnd(8)} ${mask(value).padEnd(12)} file\n`).join("")}`);
|
|
142
|
+
}
|
|
133
143
|
return ok();
|
|
134
144
|
}
|
|
135
145
|
if (command.endsWith("/node") && args[0]?.endsWith("autopilot-ai/bin/autopilot.mjs")) {
|
|
@@ -172,8 +182,10 @@ export class FakeComputer {
|
|
|
172
182
|
case "systemctl":
|
|
173
183
|
if (args[1] === "is-system-running")
|
|
174
184
|
return this.systemdUser ? ok("running\n") : fail("Failed to connect to bus");
|
|
175
|
-
if (args[1] === "restart")
|
|
185
|
+
if (args[1] === "restart") {
|
|
176
186
|
this.running.add(args[2]);
|
|
187
|
+
this.restarted.push(args[2]);
|
|
188
|
+
}
|
|
177
189
|
if (args[1] === "disable" && args[2] === "--now")
|
|
178
190
|
this.running.delete(args[3]);
|
|
179
191
|
if (args[1] === "is-active")
|
|
@@ -192,6 +204,14 @@ export class FakeComputer {
|
|
|
192
204
|
const label = args[1].split("/").at(-1);
|
|
193
205
|
return this.running.delete(label) ? ok() : fail("Boot-out failed: 3: No such process");
|
|
194
206
|
}
|
|
207
|
+
if (args[0] === "kickstart") {
|
|
208
|
+
// -k restarts a loaded service; one that isn't loaded can't be kickstarted.
|
|
209
|
+
const label = args.at(-1).split("/").at(-1);
|
|
210
|
+
if (!this.running.has(label))
|
|
211
|
+
return fail("Could not find service");
|
|
212
|
+
this.restarted.push(label);
|
|
213
|
+
return ok();
|
|
214
|
+
}
|
|
195
215
|
if (args[0] === "print") {
|
|
196
216
|
const label = args[1].split("/").at(-1);
|
|
197
217
|
return this.running.has(label) ? ok(`${args[1]} = {\n\tstate = running\n\tpid = 4242\n}\n`) : fail("Could not find service");
|
|
@@ -220,6 +240,10 @@ export class FakeComputer {
|
|
|
220
240
|
case "ls":
|
|
221
241
|
return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
|
|
222
242
|
case "mktemp": return ok("/tmp/tmp.build\n");
|
|
243
|
+
case "stat": {
|
|
244
|
+
const mtime = this.files.has(args.at(-1)) ? this.mtimes.get(args.at(-1)) : undefined;
|
|
245
|
+
return mtime === undefined ? fail("No such file or directory") : ok(`${mtime}\n`);
|
|
246
|
+
}
|
|
223
247
|
case "wc": return ok("0\n");
|
|
224
248
|
case "/usr/local/bin/docker": {
|
|
225
249
|
if (args[0] === "context")
|