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.
@@ -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
- let { access, display } = await chooseServer(deps, hosts);
49
- let facts;
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
- /** The server refused the default keys: offer the ones in ~/.ssh and retry with -i. */
159
- async function chooseKey(deps, sshDir, access) {
160
- const c = palette(deps.color);
161
- const keys = discoverKeys(sshDir, await deps.sources.listDir(sshDir)).filter((key) => key !== access.identityFile);
162
- if (keys.length === 0)
163
- throw explainAuth(new SshAuthError(access, "no other keys in ~/.ssh"), access);
164
- deps.write(`\n ${c.dim("Which key does this server accept?")}\n`);
165
- keys.forEach((key, index) => deps.write(` ${c.cyan(String(index + 1))} ${key.replace(deps.sources.home, "~")}\n`));
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
- const answer = (await deps.prompter.text(ui.prompt("SSH key", `1-${keys.length}`))).trim();
169
- const key = keys[(Number(answer) || 1) - 1] ?? keys[0];
170
- ui.answered("SSH key", key.replace(deps.sources.home, "~"));
171
- return key;
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
- function explainAuth(error, access) {
174
- if (!(error instanceof SshAuthError))
175
- return error instanceof Error ? error : new Error(String(error));
176
- return new Error([
177
- error.message,
178
- " Add this computer's public key to the server (it asks for the password once):",
179
- ` ssh-copy-id ${access.port ? `-p ${access.port} ` : ""}${access.target}`,
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
- ].join("\n"));
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
- /** box (from an ssh alias) or 203-0-113-10, unique unless it's this same server again. */
193
- async function remoteName(access, remotesFile) {
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 label = /^\d+(\.\d+){3}$/.test(host) || host.includes(":") ? host.replace(/[.:]/g, "-") : host.split(".")[0] ?? "server";
196
- const base = label.replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 36) || "server";
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
  }
@@ -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 offers others from ~/.ssh. */
142
+ /** The server refused our keys; the wizard tries the others in ~/.ssh. */
143
143
  export class SshAuthError extends Error {
144
144
  access;
145
- constructor(access, detail) {
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, and whether we can become root without a password.
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
+ }
@@ -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) {
@@ -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
- return { installed: Boolean(state), state, services: { hotcell, autopilot }, answering, capacity, dockerRunning, paths, platform: ctx.rawPlatform };
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
- return !this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`) ? ok("") : ok([...this.hotcellKeys.keys()].map((key) => ` ${key} file\n`).join(""));
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")