anon-pi 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anon-pi.ts CHANGED
@@ -1,37 +1,77 @@
1
1
  // anon-pi: the PURE logic (no process spawning, no interactive I/O) so every
2
2
  // decision is unit-testable. cli.ts wires this to the real filesystem + spawn.
3
3
  //
4
- // What anon-pi does (settled design):
5
- // - ALWAYS seed a per-workdir writable copy of the canonical anon-pi config
6
- // (~/.config/anon-pi/agent) into a per-session dir keyed by the workdir, and
7
- // mount THAT as the container's pi global (PI_CODING_AGENT_DIR). The
8
- // canonical config is only ever READ (at seed time), never mounted, so the
9
- // container cannot mutate it.
10
- // - Mount the workdir separately at /work (pi's cwd; the user's files land on
11
- // the host). A user-supplied /work/.pi/ override is just pi's own
12
- // project-over-global layering; anon-pi neither creates nor requires it.
13
- // - Open exactly ONE direct hole (--allow-direct <ANON_PI_LLM>) so pi can reach
14
- // a local model while all other egress stays forced through the proxy.
15
- // - NEVER auto-populate the canonical seed: if it is absent, error and tell the
16
- // user to populate it (their anon accounts / chosen skills / a valid
17
- // trust.json that trusts /work). anon-pi does not synthesize pi's trust.json.
18
- // - Session identity = the ABSOLUTE workdir path (hashed). Same folder resumes
19
- // the same session config+state; reseed is manual (delete the session dir).
4
+ // The model (machines + projects; see CONTEXT.md + docs/adr/0001):
5
+ // - A MACHINE is an image + a persistent HOST home (`machines/<M>/home`),
6
+ // bind-mounted into the jail at /root. It holds shell config, pi config +
7
+ // extensions, and pi conversations (`~/.pi/agent/sessions/`). The container
8
+ // is disposable; ALL valuable state is in this host home.
9
+ // - A PROJECT is a folder under the PROJECTS ROOT, bind-mounted at /projects,
10
+ // so a project's cwd is /projects/<name>. pi keys a conversation by its
11
+ // launch cwd, so /projects/<name> is the conversation key (per-machine,
12
+ // since it lives in that machine's home).
13
+ // - TWO invariant container mounts, always: /root (the machine home) and
14
+ // /projects (the projects root). `--mount <parent>` adds EXACTLY one more
15
+ // mount at the DISTINCT /work and re-roots cwd there; nothing else changes,
16
+ // so we never remount a running container.
17
+ // - Throwaway (`--rm`) is the DEFAULT; `--keep` leaves the container kept so
18
+ // its filesystem survives (found + resumed by netcage's `netcage.managed`
19
+ // label via `netcage start`). The machine home persists either way.
20
+ // - Open exactly ONE direct hole (--allow-direct <llm>) so pi can reach a
21
+ // local model while ALL other egress stays forced through the socks5h proxy
22
+ // (fail-closed; the proxy is REQUIRED and never guessed).
23
+ // - Seed-if-fresh (marker-guarded, per MACHINE home): on a fresh home, promote
24
+ // the image's /root defaults + pi staging + the generated models.json into
25
+ // the home once, then stamp the marker and never clobber it again.
26
+ //
27
+ // This module holds every DECISION as a pure function (config load + precedence,
28
+ // machine/project resolvers, name validation, the RunPlan argv, the menu
29
+ // choice-list, project usage, the run-vs-start rule, models.json generation,
30
+ // init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
31
+ // the interactive TUI, the netcage query, the spawn).
20
32
 
21
33
  import {existsSync} from 'node:fs';
22
34
  import {homedir} from 'node:os';
23
- import {dirname, isAbsolute, join, resolve} from 'node:path';
35
+ import {dirname, join, resolve} from 'node:path';
24
36
  import {fileURLToPath} from 'node:url';
25
37
 
26
- /** The container path the workdir is mounted at (pi's cwd). */
27
- export const CONTAINER_WORKDIR = '/work';
38
+ /**
39
+ * The jail cwd root for the projects-root launch: the projects root is mounted
40
+ * here and a project `<name>` is `/projects/<name>` (pi keys a conversation by
41
+ * its launch cwd, so `/projects/<name>` is the conversation key). This is the
42
+ * machines + projects mount (distinct from `--mount`'s /work).
43
+ */
44
+ export const CONTAINER_PROJECTS_ROOT = '/projects';
45
+
46
+ /**
47
+ * The jail cwd root for a `--mount <parent>` launch: the HOST parent is mounted
48
+ * here (kept DISTINCT from /projects so the two roots never collide), and a
49
+ * project `<name>` is `/work/<name>`. See ADR-0001 (`--mount` keeps `/work`).
50
+ */
51
+ export const CONTAINER_MOUNT_ROOT = '/work';
52
+
53
+ /**
54
+ * The jail cwd root for a machine (its persistent home, bind-mounted at /root).
55
+ * A machine root has no named subfolders: only the root token `.` (a scratch pi
56
+ * / shell at `~`) is valid. Written as `~` so it reads as "the machine home".
57
+ */
58
+ export const CONTAINER_MACHINE_HOME = '~';
59
+
60
+ /**
61
+ * The REAL container path the machine home is bind-mounted at (the source is
62
+ * the host `machineHomeDir`). This is what a shell-at-`~` launch actually cwds
63
+ * into (`-w /root`), distinct from CONTAINER_MACHINE_HOME (`~`), which is the
64
+ * human-readable menu token. It is the parent of CONTAINER_AGENT_DIR
65
+ * (`/root/.pi/agent`); the seed-if-fresh promotes the image's `/root` defaults +
66
+ * pi staging into the mounted home here.
67
+ */
68
+ export const CONTAINER_HOME_ROOT = '/root';
28
69
 
29
70
  /**
30
71
  * The container path pi uses as its config+state home. anon-pi mounts a
31
72
  * PERSISTENT host dir here (Model B), so everything pi writes, sessions,
32
73
  * history, settings (your model choice), `pi install`ed extensions, downloaded
33
- * bin/fd, survives across launches. Statefulness is the default; --ephemeral
34
- * mounts a throwaway dir here instead.
74
+ * bin/fd, survives across launches. Statefulness is the default.
35
75
  */
36
76
  export const CONTAINER_AGENT_DIR = '/root/.pi/agent';
37
77
 
@@ -50,12 +90,30 @@ export const CONTAINER_STAGE_DIR = '/opt/anon-pi-seed/agent';
50
90
  */
51
91
  export const CONTAINER_MODELS_SEED = '/anon-pi-seed/models.json';
52
92
 
93
+ /**
94
+ * Where anon-pi mounts the generated settings SEED (the local-model default
95
+ * selection: defaultProvider/defaultModel/enabledModels) read-only, so the
96
+ * first-launch seed can MERGE it into the fresh home's settings.json (never
97
+ * clobbering image-staged packages/extensions).
98
+ */
99
+ export const CONTAINER_SETTINGS_SEED = '/anon-pi-seed/settings.json';
100
+
53
101
  /** Marker file written into the agent dir after seeding; holds the seed version. */
54
102
  export const SEED_MARKER = '.anon-pi-seed';
55
103
 
56
- /** The single file the host-side seed carries: pi's model/provider registry. */
104
+ /** The file the host-side seed carries: pi's model/provider registry. */
57
105
  export const MODELS_FILE = 'models.json';
58
106
 
107
+ /** pi's settings file (holds defaultModel/defaultProvider/enabledModels + more). */
108
+ export const SETTINGS_FILE = 'settings.json';
109
+
110
+ /**
111
+ * The settings SEED file anon-pi writes next to a machine (the local-model
112
+ * selection fragment). Distinct name so it never collides with a real
113
+ * settings.json; the seed MERGES it into the home's settings on first launch.
114
+ */
115
+ export const SETTINGS_SEED_FILE = 'settings-seed.json';
116
+
59
117
  /**
60
118
  * containerRunCmd builds the container command: on a FRESH home (no seed
61
119
  * marker), promote the image's staged defaults + the mounted models.json into
@@ -91,16 +149,26 @@ export interface AnonPiEnv {
91
149
  home: string;
92
150
  /** socks5h proxy URL. REQUIRED (no default: the proxy is what anonymizes). */
93
151
  proxy?: string;
94
- /** The anon-pi home dir. Default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi. */
152
+ /** The anon-pi home dir. Default ~/.anon-pi (NOT under ~/.config). */
95
153
  anonPiHome?: string;
96
- /** Override the canonical seed dir. Default <anonPiHome>/agent. */
97
- configSeed?: string;
154
+ /**
155
+ * Projects-root override from env (ANON_PI_PROJECTS). Sits above
156
+ * machine.json/config.json in the projects-root chain, below the later
157
+ * --mount CLI override. See resolveProjectsRoot.
158
+ */
159
+ projects?: string;
98
160
  /** The container image that has `pi` on PATH. REQUIRED. */
99
161
  image?: string;
100
162
  /** The RFC1918/link-local IP[:port] of the local model. REQUIRED. */
101
163
  llmDirect?: string;
102
164
  /** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
103
165
  xdgConfigHome?: string;
166
+ /**
167
+ * The host pi agent dir (PI_CODING_AGENT_DIR), used ONLY to locate the host
168
+ * `~/.pi/agent/models.json` that `init` reads the matching local provider
169
+ * from. Defaults to ~/.pi/agent. Never written.
170
+ */
171
+ piAgentDir?: string;
104
172
  /**
105
173
  * Absolute path to the Dockerfile.pi that ships with anon-pi, used only to
106
174
  * make the missing-image error's build command concrete. cli.ts resolves it
@@ -113,355 +181,1853 @@ export interface AnonPiEnv {
113
181
  * + SearXNG), used to make the missing-image error mention the fuller build.
114
182
  */
115
183
  webveilDockerfilePath?: string;
116
- /** `import` source models.json override (ANON_PI_SOURCE_MODELS). */
117
- sourceModels?: string;
118
- /** The host pi agent dir override (PI_CODING_AGENT_DIR), used to find models.json. */
119
- piAgentDir?: string;
120
- /** When true, use a throwaway state home (no persistence). Default false. */
121
- ephemeral?: boolean;
122
184
  /** The seed version anon-pi stamps into a fresh home. Default SEED_VERSION. */
123
185
  seedVersion?: string;
124
186
  }
125
187
 
126
- /** The fully-resolved run plan cli.ts executes. */
127
- export interface RunPlan {
128
- /** Absolute workdir on the host (mounted at /work). */
129
- workdir: string;
130
- /**
131
- * The PERSISTENT per-workdir state dir on the host, mounted at the container's
132
- * ~/.pi/agent. Everything pi writes here survives. For --ephemeral this is a
133
- * throwaway path cli.ts creates + discards.
134
- */
135
- stateDir: string;
136
- /** The canonical host models.json (from `import`) mounted read-only for the seed, or '' if absent. */
137
- configSeed: string;
138
- /** True when this workdir has no state yet (fresh home; the seed will run). */
139
- fresh: boolean;
140
- /** The argv passed to `netcage` (after the `netcage` program name). */
141
- netcageArgs: string[];
142
- }
143
-
144
188
  /** A user-facing error whose message is meant to be printed verbatim (no stack). */
145
189
  export class AnonPiError extends Error {}
146
190
 
147
- /** Resolve the anon-pi home dir (holds the seed). */
191
+ /**
192
+ * The verbatim guidance printed when no proxy is supplied. Kept as a single
193
+ * source so the fail-closed path (resolveProxy) emits byte-identical
194
+ * copy-pasteable guidance. The proxy is REQUIRED and never guessed: it is what
195
+ * anonymizes egress (fail-closed is the anonymity invariant).
196
+ */
197
+ export const PROXY_REQUIRED_MESSAGE =
198
+ 'anon-pi: set ANON_PI_PROXY to your socks5h proxy. anon-pi has no default:\n' +
199
+ 'the proxy is what makes the session anonymous, so it is never guessed.\n' +
200
+ '\n' +
201
+ 'Pick the one you run (copy-paste), then re-run anon-pi:\n' +
202
+ '\n' +
203
+ '# Tor (system tor / Tor Browser bundle default port)\n' +
204
+ 'export ANON_PI_PROXY=socks5h://127.0.0.1:9050\n' +
205
+ '\n' +
206
+ '# wireproxy -> a WireGuard VPN (Mullvad, Proton, ...); use YOUR configured\n' +
207
+ '# [Socks5] BindAddress port (1080 in wireproxy examples):\n' +
208
+ 'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
209
+ '\n' +
210
+ '# an SSH dynamic-forward (ssh -D 1080 host) or any other socks5h endpoint\n' +
211
+ 'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
212
+ '\n' +
213
+ 'Only socks5h:// is accepted (plain socks5:// resolves DNS locally and leaks).';
214
+
215
+ /**
216
+ * Resolve the anon-pi home dir: the dedicated, browsable workspace folder
217
+ * (`~/.anon-pi/`, NOT under `~/.config`), holding config.json, machines/<M>/,
218
+ * and the default global projects root. Overridable via ANON_PI_HOME.
219
+ */
148
220
  export function resolveAnonPiHome(env: AnonPiEnv): string {
149
221
  if (env.anonPiHome) return resolve(env.anonPiHome);
150
- const base =
151
- env.xdgConfigHome && env.xdgConfigHome.trim() !== ''
152
- ? env.xdgConfigHome
153
- : join(env.home, '.config');
154
- return join(base, 'anon-pi');
222
+ return join(env.home, '.anon-pi');
223
+ }
224
+
225
+ /** A machine's directory: <home>/machines/<name> (holds machine.json + home/). */
226
+ export function machineDir(env: AnonPiEnv, name: string): string {
227
+ return join(resolveAnonPiHome(env), 'machines', name);
228
+ }
229
+
230
+ /** A machine's persistent HOST home: <home>/machines/<name>/home (bind-mounted at /root). */
231
+ export function machineHomeDir(env: AnonPiEnv, name: string): string {
232
+ return join(machineDir(env, name), 'home');
233
+ }
234
+
235
+ /** A machine's machine.json path: <home>/machines/<name>/machine.json. */
236
+ export function machineJsonPath(env: AnonPiEnv, name: string): string {
237
+ return join(machineDir(env, name), 'machine.json');
155
238
  }
156
239
 
240
+ /** The sessions dirname pi keeps its per-cwd conversation dirs under (in the agent dir). */
241
+ export const SESSIONS_DIRNAME = 'sessions';
242
+
157
243
  /**
158
- * The CANONICAL host seed dir holding models.json (written by `anon-pi import`).
159
- * Mounted read-only so the first-launch seed can copy models.json into a fresh
160
- * persistent home. Workdir-independent (import does not need a workdir).
244
+ * A machine's HOST pi agent dir: the host side of the container's
245
+ * CONTAINER_AGENT_DIR (`/root/.pi/agent`, since the home is bind-mounted at
246
+ * /root). i.e. <machineHome>/.pi/agent. Where pi's config + sessions live.
161
247
  */
162
- export function resolveConfigSeed(env: AnonPiEnv): string {
163
- if (env.configSeed) return resolve(env.configSeed);
164
- return join(resolveAnonPiHome(env), 'agent');
248
+ export function machineAgentDir(env: AnonPiEnv, name: string): string {
249
+ return join(machineHomeDir(env, name), '.pi', 'agent');
165
250
  }
166
251
 
167
252
  /**
168
- * Encode an absolute path into a directory name using pi's OWN convention (see
169
- * pi coding-agent session-manager: `--${cwd without leading slash, / \ : -> -}--`),
170
- * so an anon-pi state dir is readable and matches pi's mental model (no opaque
171
- * hash). e.g. /home/u/dev/x -> --home-u-dev-x--
253
+ * A machine's HOST pi sessions dir: <machineAgentDir>/sessions. Each per-cwd
254
+ * conversation is a slug-named subdir here (projectSessionSlug for a project).
172
255
  */
173
- export function pathSlug(absPath: string): string {
174
- return `--${absPath.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
256
+ export function machineSessionsDir(env: AnonPiEnv, name: string): string {
257
+ return join(machineAgentDir(env, name), SESSIONS_DIRNAME);
175
258
  }
176
259
 
177
260
  /**
178
- * The persistent per-workdir state dir on the host (mounted at the container's
179
- * ~/.pi/agent). Keyed by the workdir via pi's path-slug convention:
180
- * <anonPiHome>/state/<slug>/agent
261
+ * The HOST session dir a given project's conversation occupies in a given
262
+ * machine's home: <machineSessionsDir>/<projectSessionSlug>. Because the slug is
263
+ * MACHINE-INVARIANT (pi keys by the `/projects/<name>` cwd, identical on every
264
+ * machine), the SAME shared project has this dir in each machine that used it.
265
+ * Validates the project name (rejecting traversal) via projectSessionSlug.
181
266
  */
182
- export function stateAgentDir(env: AnonPiEnv, absWorkdir: string): string {
183
- return join(resolveAnonPiHome(env), 'state', pathSlug(absWorkdir), 'agent');
267
+ export function machineProjectSessionDir(
268
+ env: AnonPiEnv,
269
+ machine: string,
270
+ project: string,
271
+ ): string {
272
+ return join(machineSessionsDir(env, machine), projectSessionSlug(project));
273
+ }
274
+
275
+ /** The built-in default global projects root: <home>/projects. */
276
+ export function builtinProjectsRoot(env: AnonPiEnv): string {
277
+ return join(resolveAnonPiHome(env), 'projects');
278
+ }
279
+
280
+ // --- The destructive cleanup verbs' affected-path resolvers ------------------
281
+ //
282
+ // `--delete-home [<machine>]` and `--delete-project <project>` replace the old
283
+ // `--fresh`. This module owns only the PURE affected-path resolution (which host
284
+ // paths a delete would remove); the CLI does the confirm prompt + the actual
285
+ // `rm` (cli-delete.test.ts). Per the prd behaviour table:
286
+ // - delete-home drops ONE machine's home (config + convos + shell env) and
287
+ // keeps the project FILES (they live under the projects root, not the home);
288
+ // - delete-project drops that project's FILES and its per-machine session dir
289
+ // in EVERY machine home (the machine-invariant slug), keeping the homes.
290
+
291
+ /** The affected-path plan for `--delete-home <machine>`. */
292
+ export interface DeleteHomePlan {
293
+ /** The machine whose home is dropped. */
294
+ machine: string;
295
+ /**
296
+ * The single dir removed: the machine's persistent HOST home
297
+ * (machineHomeDir). The machine dir's machine.json (its image pin) is KEPT, so
298
+ * the machine can be relaunched to seed a FRESH home.
299
+ */
300
+ home: string;
184
301
  }
185
302
 
186
303
  /**
187
- * Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
188
- * baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
189
- * Returns `host` (no port) or `host:port`, lowercased, scheme/path stripped.
304
+ * PURE: resolve the affected path for `--delete-home <machine>`: the machine's
305
+ * HOME dir only (config + convos + shell env), NOT the whole machine dir, so the
306
+ * image pin (machine.json) survives a re-seed. Validates the machine name
307
+ * (rejecting traversal) via machineHomeDir's join being under a validated name;
308
+ * we validate explicitly here so the plan itself is a safe single segment.
190
309
  */
191
- export function hostPortKey(value: string): string {
192
- let v = value.trim();
193
- const scheme = v.indexOf('://');
194
- if (scheme >= 0) v = v.slice(scheme + 3);
195
- v = v.split('/')[0]; // drop path (/v1, ...)
196
- v = v.replace(/^[^@]*@/, ''); // drop any user:pass@
197
- return v.toLowerCase();
310
+ export function resolveDeleteHome(
311
+ env: AnonPiEnv,
312
+ machine: string,
313
+ ): DeleteHomePlan {
314
+ validateName(machine, 'machine');
315
+ return {machine, home: machineHomeDir(env, machine)};
316
+ }
317
+
318
+ /** The affected-path plan for `--delete-project <project>`. */
319
+ export interface DeleteProjectPlan {
320
+ /** The project whose files + per-machine sessions are dropped. */
321
+ project: string;
322
+ /** The project's files: <projectsRoot>/<project> (the host folder). */
323
+ folder: string;
324
+ /**
325
+ * The per-machine session dirs for this project's (machine-invariant) slug,
326
+ * ONE per supplied machine, in the SUPPLIED order. The homes themselves are
327
+ * kept; only these slug dirs are dropped. The CLI supplies the machine names
328
+ * (readdir of machines/) and skips any that do not exist on disk.
329
+ */
330
+ sessions: string[];
198
331
  }
199
332
 
200
333
  /**
201
- * A pi provider entry (as it appears under models.json `providers[name]`). Only
202
- * the fields anon-pi reads are typed; the rest is preserved verbatim.
334
+ * PURE: resolve the affected paths for `--delete-project <project>`: the
335
+ * project's files under the RESOLVED projects root, plus that project's session
336
+ * dir in each SUPPLIED machine home (the machine-invariant slug). Validates the
337
+ * project name (rejecting traversal) so both the folder join and every session
338
+ * join stay inside their roots. The homes are NOT targeted (only the per-project
339
+ * slug dir inside each), matching the prd behaviour table.
203
340
  */
204
- export interface PiProvider {
205
- baseUrl?: string;
206
- apiKey?: string;
207
- api?: string;
208
- models?: unknown[];
209
- [k: string]: unknown;
341
+ export function resolveDeleteProject(args: {
342
+ env: AnonPiEnv;
343
+ project: string;
344
+ /** The resolved projects root (host dir mounted at /projects). */
345
+ projectsRoot: string;
346
+ /** The machine names whose homes may hold this project's session dir. */
347
+ machines: readonly string[];
348
+ }): DeleteProjectPlan {
349
+ const {env, project, projectsRoot, machines} = args;
350
+ validateName(project, 'project');
351
+ return {
352
+ project,
353
+ folder: projectHostDir(projectsRoot, project),
354
+ sessions: machines.map((m) => machineProjectSessionDir(env, m, project)),
355
+ };
210
356
  }
211
357
 
212
- /** Parsed shape of a pi models.json (only `providers` is required). */
213
- export interface PiModelsFile {
214
- providers?: Record<string, PiProvider>;
215
- [k: string]: unknown;
216
- }
358
+ // --- Name validation + the "." root token ------------------------------------
217
359
 
218
- /** The result of picking the ANON_PI_LLM provider out of a host models.json. */
219
- export interface ImportResult {
220
- /** The provider key (e.g. "llamacpp-router"). */
221
- name: string;
222
- /** The barebones models.json to write (just the matched provider). */
223
- models: PiModelsFile;
224
- /** True if the matched provider's apiKey looks like a REAL secret (warn). */
225
- apiKeyLooksReal: boolean;
360
+ /**
361
+ * The project token meaning "the root itself": cwd `/projects` (projects root),
362
+ * `/work` (`--mount`), or `~` (a machine home). It is NOT a valid machine or
363
+ * project name (validateName rejects it) so a folder can never shadow it.
364
+ */
365
+ export const ROOT_TOKEN = '.';
366
+
367
+ /**
368
+ * Reserved names that a machine/project may NOT take (case-sensitive). Kept
369
+ * DELIBERATELY minimal: only the two structural path tokens. `.` is the root
370
+ * token (see ROOT_TOKEN); `..` is parent-traversal. Both are also rejected by
371
+ * the leading-dot / `..` structural checks below, but are listed here so the
372
+ * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
373
+ * CONTAINER path, not a name in this namespace, so it needs no reservation.
374
+ */
375
+ export const RESERVED_NAMES: readonly string[] = ['.', '..'];
376
+
377
+ /** What a name names, for a clear validation error. */
378
+ export type NameKind = 'machine' | 'project';
379
+
380
+ /**
381
+ * PURE: validate a machine or project name as a safe single path segment, and
382
+ * return it unchanged on success. Rejects (with AnonPiError):
383
+ * - empty
384
+ * - a path separator `/` or `\`, or a colon `:`
385
+ * - the traversal token `..` (and any leading dot, incl. `.`)
386
+ * - any whitespace
387
+ * - a reserved name (RESERVED_NAMES)
388
+ * A valid name is thus a single folder segment safe to join under the projects
389
+ * root or the machines dir with no traversal or drive/scheme surprises.
390
+ */
391
+ export function validateName(name: string, kind: NameKind): string {
392
+ const bad = (why: string): never => {
393
+ throw new AnonPiError(
394
+ `anon-pi: invalid ${kind} name ${JSON.stringify(name)}: ${why}. ` +
395
+ `A ${kind} name must be a single folder segment (no / \\ : whitespace, ` +
396
+ `no leading dot, not "..").`,
397
+ );
398
+ };
399
+ if (name === '') return bad('it is empty');
400
+ if (/[/\\:]/.test(name)) return bad('it contains / \\ or :');
401
+ if (/\s/.test(name)) return bad('it contains whitespace');
402
+ if (name.startsWith('.')) return bad('it starts with a dot');
403
+ if (name === '..') return bad('it is the parent-traversal token');
404
+ if (RESERVED_NAMES.includes(name)) return bad('it is a reserved name');
405
+ return name;
226
406
  }
227
407
 
228
- /** apiKey values that are NOT real secrets (safe to carry into the seed). */
229
- const BENIGN_API_KEYS = new Set(['', 'none', 'ollama', 'no-key', 'local']);
408
+ /**
409
+ * PURE: map a validated project `<name>` to its host folder under the resolved
410
+ * projects root (the parent from resolveProjectsRoot / a `--mount` parent).
411
+ * Validates the name (rejecting traversal) so the join stays inside the root.
412
+ */
413
+ export function projectHostDir(projectsRoot: string, name: string): string {
414
+ return join(projectsRoot, validateName(name, 'project'));
415
+ }
230
416
 
231
417
  /**
232
- * PURE: given a parsed host models.json and the ANON_PI_LLM value, select the
233
- * provider whose baseUrl points at that host:port and return a barebones
234
- * models.json carrying ONLY that provider (verbatim, with its models). Throws
235
- * AnonPiError if nothing matches. Carries no other provider (so etherplay /
236
- * google / paid API keys never enter the seed).
418
+ * PURE: the jail cwd for a validated project `<name>`: `/projects/<name>`. This
419
+ * is pi's conversation key (pi keys a session by its launch cwd). Validates the
420
+ * name. For the `--mount` root use resolveCwd('mount', name) (=> /work/<name>).
237
421
  */
238
- export function pickProviderForLlm(
239
- hostModels: PiModelsFile,
240
- llmDirect: string,
241
- ): ImportResult {
242
- const providers = hostModels.providers ?? {};
243
- const want = hostPortKey(llmDirect);
422
+ export function projectContainerCwd(name: string): string {
423
+ return `${CONTAINER_PROJECTS_ROOT}/${validateName(name, 'project')}`;
424
+ }
244
425
 
245
- const matches: string[] = [];
246
- for (const [name, p] of Object.entries(providers)) {
247
- if (!p || typeof p !== 'object' || !p.baseUrl) continue;
248
- if (hostPortKey(p.baseUrl) === want) matches.push(name);
426
+ /** Which mounted root a launch cwds into (see the CONTAINER_* root constants). */
427
+ export type RootKind = 'projects' | 'mount' | 'machine';
428
+
429
+ /** True iff `token` is exactly the root token `.` ("the root itself"). */
430
+ export function isRootToken(token: string | undefined): boolean {
431
+ return token === ROOT_TOKEN;
432
+ }
433
+
434
+ /** PURE: the jail cwd of a root itself: /projects, /work (mount), or ~ (machine). */
435
+ export function rootCwd(kind: RootKind): string {
436
+ switch (kind) {
437
+ case 'projects':
438
+ return CONTAINER_PROJECTS_ROOT;
439
+ case 'mount':
440
+ return CONTAINER_MOUNT_ROOT;
441
+ case 'machine':
442
+ return CONTAINER_MACHINE_HOME;
249
443
  }
444
+ }
250
445
 
251
- if (matches.length === 0) {
252
- const known = Object.entries(providers)
253
- .filter(([, p]) => p && p.baseUrl)
254
- .map(([n, p]) => ` ${n}: ${p.baseUrl}`)
255
- .join('\n');
446
+ /**
447
+ * PURE: resolve a launch's jail cwd UNIFORMLY from a `token` and its root kind.
448
+ * The root token `.` means "the root itself" (rootCwd) in every context; any
449
+ * other token is a project name resolved to `<root>/<name>` (validated). A
450
+ * machine root has no named subfolders (projects live at /projects or /work,
451
+ * never under the machine home), so a non-`.` token for a machine is rejected.
452
+ * This is the one seam so `anon-pi --mount <p> .` and a menu "here" entry agree.
453
+ */
454
+ export function resolveCwd(kind: RootKind, token: string): string {
455
+ if (isRootToken(token)) return rootCwd(kind);
456
+ if (kind === 'machine') {
256
457
  throw new AnonPiError(
257
- `anon-pi import: no provider in your host models.json points at ANON_PI_LLM (${want}).\n` +
258
- (known
259
- ? `Providers found:\n${known}\n`
260
- : 'No providers with a baseUrl were found.\n') +
261
- 'Set ANON_PI_LLM to the host:port of a provider above, or add that provider to pi first.',
458
+ `anon-pi: a machine root takes only "${ROOT_TOKEN}" (the machine home ${CONTAINER_MACHINE_HOME}), ` +
459
+ `not a named project ${JSON.stringify(token)}. Projects live under /projects or /work.`,
262
460
  );
263
461
  }
462
+ return `${rootCwd(kind)}/${validateName(token, 'project')}`;
463
+ }
264
464
 
265
- const name = matches[0];
266
- const provider = providers[name];
267
- const key = (provider.apiKey ?? '').trim().toLowerCase();
268
- const apiKeyLooksReal = !BENIGN_API_KEYS.has(key);
465
+ /** Parsed shape of config.json. All fields optional (a hand-edited file may omit any). */
466
+ export interface AnonPiConfig {
467
+ /** socks5h proxy URL. */
468
+ proxy?: string;
469
+ /** The local-model direct target (host[:port]). */
470
+ llm?: string;
471
+ /** The machine bare `anon-pi` launches by default. */
472
+ defaultMachine?: string;
473
+ /** Override the projects root (host dir mounted at /projects). */
474
+ projects?: string;
475
+ }
269
476
 
270
- return {
271
- name,
272
- models: {providers: {[name]: provider}},
273
- apiKeyLooksReal,
274
- };
477
+ /** Parsed shape of a per-machine machine.json. All fields optional. */
478
+ export interface MachineConfig {
479
+ /** The container image with `pi` on PATH for this machine. */
480
+ image?: string;
481
+ /** Per-machine projects-root override (above config, below env/--mount). */
482
+ projects?: string;
483
+ }
484
+
485
+ /** Pick a string field from a parsed-JSON object, or undefined if absent/non-string. */
486
+ function strField(o: unknown, key: string): string | undefined {
487
+ if (!o || typeof o !== 'object') return undefined;
488
+ const v = (o as Record<string, unknown>)[key];
489
+ return typeof v === 'string' ? v : undefined;
275
490
  }
276
491
 
277
492
  /**
278
- * The default host models.json path `import` reads FROM. Overridable via
279
- * ANON_PI_SOURCE_MODELS; defaults to the real pi config (~/.pi/agent/models.json
280
- * under the container-less host HOME, or PI_CODING_AGENT_DIR if the user set it).
493
+ * PURE: parse an already-JSON-decoded config.json value into an AnonPiConfig,
494
+ * keeping only the known string fields (defensive against a hand-edited file).
495
+ * Tolerates undefined/null/partial input (an absent config is `{}`).
281
496
  */
282
- export function resolveSourceModelsPath(env: AnonPiEnv): string {
283
- if (env.sourceModels && env.sourceModels.trim() !== '') {
284
- return resolve(env.sourceModels);
285
- }
286
- const agentDir =
287
- env.piAgentDir && env.piAgentDir.trim() !== ''
288
- ? env.piAgentDir
289
- : join(env.home, '.pi', 'agent');
290
- return join(agentDir, MODELS_FILE);
497
+ export function parseConfigJson(raw: unknown): AnonPiConfig {
498
+ const out: AnonPiConfig = {};
499
+ const proxy = strField(raw, 'proxy');
500
+ if (proxy !== undefined) out.proxy = proxy;
501
+ const llm = strField(raw, 'llm');
502
+ if (llm !== undefined) out.llm = llm;
503
+ const defaultMachine = strField(raw, 'defaultMachine');
504
+ if (defaultMachine !== undefined) out.defaultMachine = defaultMachine;
505
+ const projects = strField(raw, 'projects');
506
+ if (projects !== undefined) out.projects = projects;
507
+ return out;
291
508
  }
292
509
 
293
510
  /**
294
- * Build the run plan from the environment + the (optional) workdir arg. PURE: it
295
- * resolves paths and composes the netcage argv, performing NO filesystem writes
296
- * or spawns. It THROWS AnonPiError for the required inputs (image, llm, proxy).
511
+ * PURE: parse an already-JSON-decoded machine.json value into a MachineConfig.
512
+ * Tolerates undefined/null/partial input (an absent machine.json is `{}`).
513
+ */
514
+ export function parseMachineJson(raw: unknown): MachineConfig {
515
+ const out: MachineConfig = {};
516
+ const image = strField(raw, 'image');
517
+ if (image !== undefined) out.image = image;
518
+ const projects = strField(raw, 'projects');
519
+ if (projects !== undefined) out.projects = projects;
520
+ return out;
521
+ }
522
+
523
+ /** A non-empty (after-trim) string, or undefined. */
524
+ function nonEmpty(v: string | undefined): string | undefined {
525
+ return v && v.trim() !== '' ? v.trim() : undefined;
526
+ }
527
+
528
+ /**
529
+ * PURE: resolve the projects root (the host dir mounted at /projects) with the
530
+ * decided precedence, highest first:
531
+ * --mount (CLI) > env ANON_PI_PROJECTS > machine.json.projects >
532
+ * config.json.projects > built-in <home>/projects
533
+ * This task delivers the config/env/machine layers; `mountParent` is the
534
+ * documented top slot the later --mount CLI task threads in (pass the resolved
535
+ * host parent). A relative override is resolved to an absolute path.
536
+ */
537
+ export function resolveProjectsRoot(args: {
538
+ env: AnonPiEnv;
539
+ config?: AnonPiConfig;
540
+ machine?: MachineConfig;
541
+ /** The later --mount CLI override (a HOST parent path); top of the chain. */
542
+ mountParent?: string;
543
+ }): string {
544
+ const {env, config, machine, mountParent} = args;
545
+ const pick =
546
+ nonEmpty(mountParent) ??
547
+ nonEmpty(env.projects) ??
548
+ nonEmpty(machine?.projects) ??
549
+ nonEmpty(config?.projects);
550
+ if (pick !== undefined) return resolve(pick);
551
+ return builtinProjectsRoot(env);
552
+ }
553
+
554
+ /**
555
+ * PURE: resolve the proxy with env-over-config precedence, REQUIRED /
556
+ * fail-closed. Throws AnonPiError with the verbatim PROXY_REQUIRED_MESSAGE when
557
+ * neither env nor config supplies a non-empty proxy (never a guessed default:
558
+ * fail-closed is the anonymity invariant).
559
+ */
560
+ export function resolveProxy(args: {
561
+ config?: AnonPiConfig;
562
+ env: {proxy?: string};
563
+ }): string {
564
+ const pick = nonEmpty(args.env.proxy) ?? nonEmpty(args.config?.proxy);
565
+ if (pick === undefined) throw new AnonPiError(PROXY_REQUIRED_MESSAGE);
566
+ return pick;
567
+ }
568
+
569
+ /**
570
+ * PURE: resolve the local-model direct target with env-over-config precedence.
571
+ * Unlike the proxy this is NOT fail-closed here (a launch with no local model
572
+ * is a later decision); returns undefined when neither supplies one.
573
+ */
574
+ export function resolveLlm(args: {
575
+ config?: AnonPiConfig;
576
+ env: {llmDirect?: string};
577
+ }): string | undefined {
578
+ return nonEmpty(args.env.llmDirect) ?? nonEmpty(args.config?.llm);
579
+ }
580
+
581
+ // --- The per-machine RunPlan resolver ----------------------------------------
582
+ //
583
+ // The heart of the machines+projects rework: given a resolved launch intent
584
+ // (machine + mode + project token + the forced-egress inputs), compose the
585
+ // netcage argv for every mode, ALWAYS carrying the two invariant mounts
586
+ // (<home>:/root, <projects-root>:/projects) and the forced-egress flags
587
+ // (--proxy + exactly one --allow-direct). PURE: no spawn, no fs.
588
+ //
589
+ // This REPLACED the old per-workdir buildRunPlan's shape with a per-machine one.
590
+
591
+ /** A resolved machine: its host home (bind-mounted at /root) + its image. */
592
+ export interface Machine {
593
+ /** The machine's name (already validated by validateName elsewhere). */
594
+ name: string;
595
+ /** The persistent HOST home dir (machineHomeDir), bind-mounted at /root. */
596
+ home: string;
597
+ /** The container image with `pi` on PATH for this machine. */
598
+ image: string;
599
+ }
600
+
601
+ /**
602
+ * What a launch runs. `menu` is the BARE launch: no target is chosen yet, so no
603
+ * netcage argv is composed (the host-side TUI picks a project/shell, THEN a
604
+ * fresh intent is resolved into a launch plan). `pi` runs pi (optionally with
605
+ * forwarded args); `shell` runs bash (the project-hopper, since pi cannot cd).
606
+ */
607
+ export type LaunchMode = 'menu' | 'pi' | 'shell';
608
+
609
+ /**
610
+ * A parsed launch intent, injected so the resolver stays pure. The proxy + the
611
+ * direct-hole llm are threaded in RESOLVED (via resolveProxy/resolveLlm); the
612
+ * resolver re-asserts them non-empty so a plan can NEVER be produced without the
613
+ * forced-egress flags (fail-closed is the anonymity invariant).
614
+ */
615
+ export interface LaunchIntent {
616
+ /** The machine to launch on (home + image). */
617
+ machine: Machine;
618
+ /** menu (bare) | pi | shell. */
619
+ mode: LaunchMode;
620
+ /**
621
+ * The resolved HOST projects root, bind-mounted at /projects. One of the two
622
+ * invariant mounts, present on every launch regardless of --mount.
623
+ */
624
+ projectsRoot: string;
625
+ /**
626
+ * The project token: a validated project name, the root token `.`, or
627
+ * undefined (shell-at-home / menu). Resolves the cwd via resolveCwd.
628
+ */
629
+ project?: string;
630
+ /**
631
+ * `--mount <parent>`: a resolved HOST parent path. When set it adds EXACTLY
632
+ * one mount (<parent>:/work) and re-roots the cwd there (/work[/<project>]);
633
+ * it changes nothing else (the two invariant mounts stay). Sidesteps podman
634
+ * mount immutability (we never remount a running box).
635
+ */
636
+ mountParent?: string;
637
+ /** Extra args forwarded to `pi` (headless/one-shot). Ignored for shell. */
638
+ piArgs?: string[];
639
+ /**
640
+ * `--keep`: omit `--rm` so the container is left KEPT (its filesystem
641
+ * survives the apt-install/re-enter flow). Default (false) => `--rm`
642
+ * (throwaway); the machine home persists regardless (it is a host mount).
643
+ */
644
+ keep?: boolean;
645
+ /** The resolved socks5h proxy (REQUIRED; the resolver fails closed without it). */
646
+ proxy: string;
647
+ /** The resolved local-model direct target (REQUIRED: the one --allow-direct hole). */
648
+ llmDirect: string;
649
+ /**
650
+ * The host models.json to mount read-only for the first-launch seed, keyed to
651
+ * THIS machine (e.g. <machine-dir>/models.json). Omitted => no seed mount (pi
652
+ * starts with no models; you add them in-session).
653
+ */
654
+ modelsSeed?: string;
655
+ /**
656
+ * The settings SEED to mount read-only for the first-launch seed (the
657
+ * local-model default selection, e.g. <machine-dir>/settings-seed.json).
658
+ * Omitted => no settings seed (no default model is pre-selected).
659
+ */
660
+ settingsSeed?: string;
661
+ /** The seed version stamped into a fresh home's marker. Default SEED_VERSION. */
662
+ seedVersion?: string;
663
+ }
664
+
665
+ /**
666
+ * The resolved launch plan. A discriminated union so the BARE `menu` mode is a
667
+ * distinct, argv-less marker (the host-side TUI runs first) while every real
668
+ * launch carries a composed netcage argv. The forced-egress invariant is
669
+ * asserted on the `launch` variant's netcageArgs by construction.
670
+ */
671
+ export type LaunchPlan =
672
+ | {
673
+ /** Bare launch: run the host-side menu, then re-resolve into a launch. */
674
+ kind: 'menu';
675
+ machine: Machine;
676
+ }
677
+ | {
678
+ kind: 'launch';
679
+ machine: Machine;
680
+ /** The jail cwd (`-w`): /projects[/<p>], /work[/<p>] (--mount), or /root (shell ~). */
681
+ cwd: string;
682
+ /** True when the machine home is fresh (informational; the seed is marker-guarded). */
683
+ fresh: boolean;
684
+ /** The argv passed to `netcage` (after the `netcage` program name). */
685
+ netcageArgs: string[];
686
+ };
687
+
688
+ // --- Grammar A: the pure argv -> ParsedLaunch parser -------------------------
689
+ //
690
+ // A bare positional is a PROJECT; `-m` picks the machine. The CLI (cli.ts)
691
+ // combines the ParsedLaunch with config/machine reads (proxy, llm, image, home,
692
+ // projects root) into a LaunchIntent and runs resolveRunPlan. Kept PURE (argv
693
+ // in -> struct out, or AnonPiError) so parsing + the reserved-name guard are
694
+ // unit-testable; the CLI stays thin I/O.
695
+
696
+ /** The machine bare `anon-pi` launches when no `-m` and no config default. */
697
+ export const DEFAULT_MACHINE = 'default';
698
+
699
+ /**
700
+ * A parsed grammar-A launch. `mode` is `menu` when no project/shell target was
701
+ * chosen (bare `anon-pi`, or `-m <machine>` / `--mount <parent>` with no
702
+ * project): the CLI runs the host-side menu. `pi`/`shell` carry the chosen
703
+ * target. `project` is a validated project name, the `.` root token, or
704
+ * undefined (menu / shell-at-home). `mountParent` is the `--mount` HOST parent
705
+ * (a path, NOT a name-namespaced token). `keep` is `--keep` (default false =>
706
+ * throwaway `--rm`). `piArgs` are the trailing tokens forwarded to pi (pi mode
707
+ * only; undefined otherwise).
708
+ */
709
+ export interface ParsedLaunch {
710
+ mode: LaunchMode;
711
+ machine: string;
712
+ /**
713
+ * True iff `-m`/`--machine` was given explicitly (so the CLI can let an
714
+ * explicit `-m default` win over `config.defaultMachine`, rather than treat
715
+ * the DEFAULT_MACHINE value as "unset").
716
+ */
717
+ machineExplicit: boolean;
718
+ project?: string;
719
+ mountParent?: string;
720
+ keep: boolean;
721
+ piArgs?: string[];
722
+ }
723
+
724
+ /**
725
+ * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
726
+ * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
727
+ * project positional; the FIRST bare positional is the project (`.` allowed as
728
+ * the root token). In pi mode every token AFTER the project is forwarded to pi
729
+ * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
730
+ * the project. In shell/menu mode a stray extra positional is an error (bash has
731
+ * no forwarded-args grammar; the menu takes no project).
297
732
  *
298
- * Statefulness (Model B): a persistent per-workdir host dir is mounted at the
299
- * container's ~/.pi/agent, so pi's sessions/history/settings/extensions persist.
300
- * First-launch seed (Model C): when that home is FRESH, the container run
301
- * command promotes the image's staged defaults + the imported models.json into
302
- * it and stamps a marker; thereafter pi OWNS the home and nothing is clobbered.
733
+ * Validates the project name and the `-m` machine name via validateName (the
734
+ * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
735
+ * distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
736
+ * it is NOT name-validated here. Throws AnonPiError for an unknown option, a
737
+ * missing `-m`/`--mount` argument, a contradictory `--keep --rm`, or a bad name.
738
+ */
739
+ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
740
+ let machine = DEFAULT_MACHINE;
741
+ let machineSet = false;
742
+ let shell = false;
743
+ let mountParent: string | undefined;
744
+ let keepSeen = false;
745
+ let rmSeen = false;
746
+ let project: string | undefined;
747
+ let piArgs: string[] | undefined;
748
+
749
+ const fail = (msg: string): never => {
750
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi --help\`.`);
751
+ };
752
+
753
+ let i = 0;
754
+ for (; i < args.length; i++) {
755
+ const a = args[i];
756
+ if (a === '-m' || a === '--machine') {
757
+ const v = args[++i];
758
+ if (v === undefined) fail(`${a} needs a machine name`);
759
+ machine = validateName(v as string, 'machine');
760
+ machineSet = true;
761
+ continue;
762
+ }
763
+ if (a === '--shell') {
764
+ shell = true;
765
+ continue;
766
+ }
767
+ if (a === '--mount') {
768
+ const v = args[++i];
769
+ if (v === undefined) fail('--mount needs a HOST parent path');
770
+ mountParent = v as string;
771
+ continue;
772
+ }
773
+ if (a === '--keep') {
774
+ keepSeen = true;
775
+ continue;
776
+ }
777
+ if (a === '--rm') {
778
+ rmSeen = true;
779
+ continue;
780
+ }
781
+ if (a === '.') {
782
+ // the root token is a valid project positional (not a name).
783
+ project = ROOT_TOKEN;
784
+ i++;
785
+ break;
786
+ }
787
+ if (a.startsWith('-')) {
788
+ fail(`unknown option: ${a}`);
789
+ }
790
+ // the first bare positional is the project.
791
+ project = validateName(a, 'project');
792
+ i++;
793
+ break;
794
+ }
795
+
796
+ if (keepSeen && rmSeen) {
797
+ fail('--keep and --rm are contradictory (pick one; --rm is the default)');
798
+ }
799
+
800
+ // tokens remaining after the project.
801
+ const rest = args.slice(i);
802
+ if (shell) {
803
+ if (rest.length > 0) {
804
+ fail(
805
+ `--shell takes at most one project, got extra: ${rest.join(' ')} ` +
806
+ '(a shell forwards no args; run pi from inside it instead)',
807
+ );
808
+ }
809
+ return {
810
+ mode: 'shell',
811
+ machine,
812
+ machineExplicit: machineSet,
813
+ project,
814
+ mountParent,
815
+ keep: keepSeen,
816
+ };
817
+ }
818
+
819
+ if (project === undefined) {
820
+ // no project + no --shell: the menu (bare, or -m/--mount with no project).
821
+ if (rest.length > 0) fail(`unexpected argument: ${rest[0]}`);
822
+ return {
823
+ mode: 'menu',
824
+ machine,
825
+ machineExplicit: machineSet,
826
+ project: undefined,
827
+ mountParent,
828
+ keep: keepSeen,
829
+ };
830
+ }
831
+
832
+ // pi mode: every token after the project is forwarded to pi verbatim.
833
+ if (rest.length > 0) piArgs = rest.slice();
834
+ return {
835
+ mode: 'pi',
836
+ machine,
837
+ machineExplicit: machineSet,
838
+ project,
839
+ mountParent,
840
+ keep: keepSeen,
841
+ piArgs,
842
+ };
843
+ }
844
+
845
+ /**
846
+ * PURE: resolve a LaunchIntent into a LaunchPlan, composing the netcage argv for
847
+ * every mode. Never spawns, never touches the filesystem: `homeFresh` reports
848
+ * whether the machine home has been seeded (so `fresh` is known) and is the only
849
+ * capability injected.
303
850
  *
304
- * `modelsSeedExists` reports whether the canonical import models.json exists (so
305
- * it is mounted for the seed); `stateExists` reports whether this workdir's
306
- * state home already exists (so `fresh` is known).
851
+ * Invariants held on EVERY composed argv:
852
+ * - the two mounts <home>:/root and <projectsRoot>:/projects, always;
853
+ * - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
854
+ * - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
855
+ * - --rm by default, omitted only under --keep.
307
856
  *
308
- * --ephemeral mounts NO writable state: pi writes to the container's own
309
- * filesystem, which netcage runs with `--rm`, so it is destroyed when the
310
- * container exits. Nothing writable ever touches a host path; there is no
311
- * cleanup and no leftover-on-crash. (The read-only models.json seed is still
312
- * mounted; it is a single file anon-pi never writes to.)
857
+ * Throws AnonPiError (a plan is NEVER produced) when the image, the machine
858
+ * home, the proxy, or the direct-hole llm is missing.
313
859
  */
314
- export function buildRunPlan(
315
- env: AnonPiEnv,
316
- workdirArg: string | undefined,
317
- modelsSeedExists: (modelsJsonPath: string) => boolean,
318
- stateExists: (stateDir: string) => boolean,
319
- ): RunPlan {
320
- if (!env.image || env.image.trim() === '') {
321
- // dockerfilePath is injected (cli.ts resolves the shipped Dockerfile.pi via
322
- // import.meta.url; tests pass a fixed path). Every command is emitted
323
- // flush-left so it copy-pastes cleanly: an indented heredoc would bake
324
- // leading spaces into the Dockerfile and break the EOF terminator, so we
325
- // point at the shipped file instead of printing a heredoc.
326
- const df = env.dockerfilePath ?? 'Dockerfile.pi';
327
- const wv = env.webveilDockerfilePath ?? 'examples/Dockerfile.pi-webveil';
860
+ export function resolveRunPlan(
861
+ intent: LaunchIntent,
862
+ homeFresh: (machineHome: string) => boolean,
863
+ ): LaunchPlan {
864
+ const {machine, mode, projectsRoot, project, mountParent} = intent;
865
+
866
+ // Forced egress FIRST, on every path incl. the menu marker: a plan can never
867
+ // be produced without the proxy + the one direct hole (fail-closed).
868
+ const proxy = nonEmpty(intent.proxy);
869
+ if (proxy === undefined) throw new AnonPiError(PROXY_REQUIRED_MESSAGE);
870
+ const llm = nonEmpty(intent.llmDirect);
871
+ if (llm === undefined) {
328
872
  throw new AnonPiError(
329
- 'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
330
- '\n' +
331
- 'No image yet? A ready Dockerfile.pi ships with anon-pi (it installs the\n' +
332
- 'official @earendil-works/pi-coding-agent). Build it and point at it:\n' +
333
- '\n' +
334
- `podman build -t localhost/anon-pi-pi:latest -f "${df}" "$(dirname "${df}")"\n` +
335
- 'export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\n' +
336
- '\n' +
337
- 'Or the fuller example with the pi-webveil extension + a local SearXNG\n' +
338
- '(anonymized web search):\n' +
339
- '\n' +
340
- `podman build -t localhost/anon-pi-webveil:latest -f "${wv}" "$(dirname "${wv}")"\n` +
341
- 'export ANON_PI_IMAGE=localhost/anon-pi-webveil:latest\n' +
342
- '\n' +
343
- 'See the README (Providing a pi image) for details and a community-image note.',
873
+ 'anon-pi: no local-model direct target: set ANON_PI_LLM (or config.llm) to the ' +
874
+ 'RFC1918/link-local IP[:port] of the local model. It is the ONE direct hole; ' +
875
+ 'all other egress stays forced through the proxy.',
344
876
  );
345
877
  }
346
- if (!env.llmDirect || env.llmDirect.trim() === '') {
878
+ if (nonEmpty(machine.image) === undefined) {
347
879
  throw new AnonPiError(
348
- 'anon-pi: set ANON_PI_LLM to the RFC1918/link-local IP[:port] of the local model pi should reach directly (e.g. ANON_PI_LLM=192.168.1.150:8080). All other egress stays forced through the proxy.',
880
+ `anon-pi: machine ${JSON.stringify(machine.name)} has no image. Set one with ` +
881
+ '`anon-pi machine set-image` or in its machine.json.',
349
882
  );
350
883
  }
351
- if (!env.proxy || env.proxy.trim() === '') {
352
- // No default: this is an anonymity tool, so the proxy is REQUIRED and never
353
- // guessed (mirrors netcage, which fails closed without --proxy). A silent
354
- // default would anonymize through the wrong endpoint, or fail deep in the
355
- // jail with a confusing DNS error, if the guessed proxy is not actually up.
884
+ if (nonEmpty(machine.home) === undefined) {
356
885
  throw new AnonPiError(
357
- 'anon-pi: set ANON_PI_PROXY to your socks5h proxy. anon-pi has no default:\n' +
358
- 'the proxy is what makes the session anonymous, so it is never guessed.\n' +
359
- '\n' +
360
- 'Pick the one you run (copy-paste), then re-run anon-pi:\n' +
361
- '\n' +
362
- '# Tor (system tor / Tor Browser bundle default port)\n' +
363
- 'export ANON_PI_PROXY=socks5h://127.0.0.1:9050\n' +
364
- '\n' +
365
- '# wireproxy -> a WireGuard VPN (Mullvad, Proton, ...); use YOUR configured\n' +
366
- '# [Socks5] BindAddress port (1080 in wireproxy examples):\n' +
367
- 'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
368
- '\n' +
369
- '# an SSH dynamic-forward (ssh -D 1080 host) or any other socks5h endpoint\n' +
370
- 'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
371
- '\n' +
372
- 'Only socks5h:// is accepted (plain socks5:// resolves DNS locally and leaks).',
886
+ `anon-pi: machine ${JSON.stringify(machine.name)} has no resolved home dir.`,
373
887
  );
374
888
  }
375
889
 
376
- const home = env.home;
377
- if (!home || home.trim() === '') {
378
- throw new AnonPiError('anon-pi: could not resolve HOME.');
379
- }
380
-
381
- const raw =
382
- workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
383
- const workdir = isAbsolute(raw) ? raw : resolve(raw);
384
-
385
- // Persistent per-workdir state home, unless --ephemeral (no writable mount).
386
- const ephemeral = env.ephemeral === true;
387
- const stateDir = ephemeral ? '' : stateAgentDir(env, workdir);
388
- // Ephemeral home is always fresh (the container's throwaway layer); a
389
- // persistent home is fresh iff its dir is absent.
390
- const fresh = ephemeral ? true : !stateExists(stateDir);
391
-
392
- // The canonical imported models.json is mounted (read-only) for the seed only
393
- // when it exists; pi can also start with no models and you add them in-session.
394
- const modelsSeed = join(resolveConfigSeed(env), MODELS_FILE);
395
- const haveModelsSeed = modelsSeedExists(modelsSeed);
396
-
397
- const proxy = env.proxy.trim();
398
-
399
- // netcage's --allow-direct wants a bare IP[:port]/CIDR (no scheme/path), but a
400
- // user naturally sets ANON_PI_LLM to a URL (http://192.168.1.150:8080). Strip
401
- // it to host:port with the same helper `import` uses to match providers, so a
402
- // URL, an ip:port, or a bare ip all work.
403
- const directTarget = hostPortKey(env.llmDirect);
404
- const seedVersion = env.seedVersion ?? SEED_VERSION;
405
-
406
- const netcageArgs = [
407
- 'run',
408
- '--proxy',
409
- proxy,
410
- '--allow-direct',
411
- directTarget,
412
- '-it',
413
- '-v',
414
- workdir, // netcage defaults a target-less -v to /work and cwd to /work
415
- ];
416
- // Persistent mode ONLY: mount the per-workdir state home at ~/.pi/agent
417
- // (Model B). --ephemeral mounts nothing writable: pi writes to the container's
418
- // own --rm layer, gone on exit, no host state.
419
- if (!ephemeral) {
420
- netcageArgs.push('-v', `${stateDir}:${CONTAINER_AGENT_DIR}`);
421
- }
422
- // Mount the imported models.json read-only for the first-launch seed, if any.
423
- if (haveModelsSeed) {
890
+ // Bare launch: defer to the host-side menu; compose no argv yet (but the
891
+ // forced-egress checks above have already run, so a menu is never a way to
892
+ // slip past the proxy requirement).
893
+ if (mode === 'menu') {
894
+ return {kind: 'menu', machine};
895
+ }
896
+
897
+ const mounted = nonEmpty(mountParent) !== undefined;
898
+ // Which root the cwd resolves under: /work when --mount, else /projects.
899
+ const rootKind: RootKind = mounted ? 'mount' : 'projects';
900
+
901
+ // cwd: shell with no project sits at the machine home (/root); otherwise the
902
+ // project token (a name or `.`) resolves under the active root uniformly.
903
+ const cwd =
904
+ project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
905
+
906
+ const fresh = homeFresh(machine.home);
907
+ const seedVersion = intent.seedVersion ?? SEED_VERSION;
908
+ const directTarget = hostPortKey(llm);
909
+ const modelsSeed = nonEmpty(intent.modelsSeed);
910
+
911
+ // Interactive modes (interactive pi, shell) need a TTY; a HEADLESS pi run
912
+ // (`<project> <pi-args…>`) must work WITHOUT one, so `-it` is omitted there
913
+ // (podman fails to allocate a TTY on a non-tty stdin). The CLI's broader
914
+ // no-TTY discipline (erroring when an interactive mode has no TTY) is a later
915
+ // task; here the argv simply omits -it for the one headless shape.
916
+ const headless = mode === 'pi' && !!intent.piArgs && intent.piArgs.length > 0;
917
+
918
+ const netcageArgs: string[] = ['run'];
919
+ // --rm by DEFAULT (throwaway); --keep leaves the container kept.
920
+ if (intent.keep !== true) netcageArgs.push('--rm');
921
+ // Forced egress: the proxy + the ONE direct hole. Never omitted.
922
+ netcageArgs.push('--proxy', proxy, '--allow-direct', directTarget);
923
+ if (!headless) netcageArgs.push('-it');
924
+ // The TWO invariant mounts, ALWAYS.
925
+ netcageArgs.push('-v', `${machine.home}:${CONTAINER_HOME_ROOT}`);
926
+ netcageArgs.push('-v', `${projectsRoot}:${CONTAINER_PROJECTS_ROOT}`);
927
+ // --mount adds EXACTLY the one parent mount at /work (distinct from /projects,
928
+ // so the two roots never collide). Nothing else changes.
929
+ if (mounted) {
930
+ netcageArgs.push('-v', `${mountParent}:${CONTAINER_MOUNT_ROOT}`);
931
+ }
932
+ // The generated models.json read-only for the first-launch seed, when present.
933
+ if (modelsSeed !== undefined) {
424
934
  netcageArgs.push('-v', `${modelsSeed}:${CONTAINER_MODELS_SEED}:ro`);
425
935
  }
426
- netcageArgs.push(env.image, 'sh', '-c', containerRunCmd(seedVersion));
936
+ // The generated settings SEED (the local-model default selection) read-only,
937
+ // when present; the seed-if-fresh MERGES it into the home's settings.json.
938
+ const settingsSeed = nonEmpty(intent.settingsSeed);
939
+ if (settingsSeed !== undefined) {
940
+ netcageArgs.push('-v', `${settingsSeed}:${CONTAINER_SETTINGS_SEED}:ro`);
941
+ }
942
+ // The jail cwd.
943
+ netcageArgs.push('-w', cwd);
944
+ // The image, then the command: a marker-guarded seed-if-fresh then the tool.
945
+ // pi (with forwarded args) for pi mode; bash for a shell. The seed shape is
946
+ // containerRunCmd re-pointed at the machine home (/root), so a fresh machine
947
+ // home gets the image's staged defaults + models.json once.
948
+ netcageArgs.push(machine.image);
949
+ if (mode === 'shell') {
950
+ // A jailed bash: seed-if-fresh (so a fresh home still gets .bashrc etc.),
951
+ // then exec bash.
952
+ netcageArgs.push('sh', '-c', containerSeedThen(seedVersion, 'exec bash'));
953
+ } else if (intent.piArgs && intent.piArgs.length > 0) {
954
+ // Forward args: seed-if-fresh, then exec pi with the args. The args are the
955
+ // shell's positional argv ($@) so they are forwarded verbatim (no re-quote).
956
+ netcageArgs.push(
957
+ 'sh',
958
+ '-c',
959
+ containerSeedThen(seedVersion, 'exec pi "$@"'),
960
+ 'pi',
961
+ ...intent.piArgs,
962
+ );
963
+ } else {
964
+ // Interactive pi: seed-if-fresh, then exec pi.
965
+ netcageArgs.push('sh', '-c', containerSeedThen(seedVersion, 'exec pi'));
966
+ }
427
967
 
428
- return {
429
- workdir,
430
- stateDir,
431
- configSeed: haveModelsSeed ? modelsSeed : '',
432
- fresh,
433
- netcageArgs,
434
- };
968
+ return {kind: 'launch', machine, cwd, fresh, netcageArgs};
435
969
  }
436
970
 
437
971
  /**
438
- * Absolute path to the Dockerfile.pi that ships with anon-pi, resolved from this
439
- * module's location (package root, one level up from dist/anon-pi.js), or
440
- * undefined if it cannot be found. Used only to make the missing-image error's
441
- * build command concrete.
972
+ * The marker-guarded seed-if-fresh prefix (reused across pi/bash), followed by
973
+ * the given exec. On a FRESH machine home (no `.anon-pi-seed` marker under
974
+ * /root/.pi/agent) it promotes the image's staged pi defaults
975
+ * (/opt/anon-pi-seed/agent) + the mounted models.json into the home and stamps
976
+ * the marker; on a seeded home it does nothing. Then it runs `exec`. This is
977
+ * `containerRunCmd`'s shape (already /root-pointed), generalised over the tool.
442
978
  */
443
- export function shippedDockerfilePath(): string | undefined {
444
- return shippedFile('Dockerfile.pi');
979
+ function containerSeedThen(seedVersion: string, exec: string): string {
980
+ const agent = CONTAINER_AGENT_DIR;
981
+ const marker = `${agent}/${SEED_MARKER}`;
982
+ const settings = `${agent}/${SETTINGS_FILE}`;
983
+ // Merge the settings SEED (the local-model default selection) into the home's
984
+ // settings.json, overwriting ONLY the three selection keys so any staged
985
+ // packages/extensions survive. Done with a node one-liner (pi is a node app,
986
+ // so node is on PATH). The seed path + target are shell-quoted single args.
987
+ const mergeSettings =
988
+ `{ [ -f "${CONTAINER_SETTINGS_SEED}" ] && node -e '` +
989
+ `const fs=require("fs");` +
990
+ `const seed=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));` +
991
+ `let cur={};try{cur=JSON.parse(fs.readFileSync(process.argv[2],"utf8"))}catch(e){}` +
992
+ `cur.defaultProvider=seed.defaultProvider;cur.defaultModel=seed.defaultModel;cur.enabledModels=seed.enabledModels;` +
993
+ `fs.writeFileSync(process.argv[2],JSON.stringify(cur,null,"\\t")+"\\n")` +
994
+ `' "${CONTAINER_SETTINGS_SEED}" "${settings}" || true; }`;
995
+ return (
996
+ `mkdir -p "${agent}" && ` +
997
+ `if [ ! -f "${marker}" ]; then ` +
998
+ `{ [ -d "${CONTAINER_STAGE_DIR}" ] && cp -a "${CONTAINER_STAGE_DIR}/." "${agent}/" || true; } && ` +
999
+ `{ [ -f "${CONTAINER_MODELS_SEED}" ] && cp "${CONTAINER_MODELS_SEED}" "${agent}/${MODELS_FILE}" || true; } && ` +
1000
+ `${mergeSettings} && ` +
1001
+ `printf '%s\\n' "${seedVersion}" > "${marker}"; ` +
1002
+ `fi && ` +
1003
+ `${exec}`
1004
+ );
445
1005
  }
446
1006
 
1007
+ // --- The run-vs-start decision for kept (netcage.managed) containers ---------
1008
+ //
1009
+ // The exploratory `--keep` flow: run a container, tweak the system (apt install
1010
+ // ...), quit, then re-enter with the SAME launch and RESUME it via `netcage
1011
+ // start` (the container filesystem survives). Throwaway (`--rm`) is the default
1012
+ // and is ALWAYS a fresh `run`.
1013
+ //
1014
+ // This module owns only the PURE decision: given a resolved LaunchIntent and a
1015
+ // SUPPLIED listing of kept containers, decide `start` (a matching kept container
1016
+ // is present) vs `run` without `--rm` (absent). The netcage QUERY (how to ask
1017
+ // netcage for its labelled containers, e.g. `netcage ps` filtered by the
1018
+ // `netcage.managed` label) is the CLI's impure job; the pure rule receives its
1019
+ // RESULT (the listing) so the decision stays unit-testable. anon-pi invents NO
1020
+ // registry file: netcage's `netcage.managed` label IS the record.
1021
+
447
1022
  /**
448
- * Absolute path to the fuller pi-webveil + SearXNG example that ships with
449
- * anon-pi (examples/Dockerfile.pi-webveil), or undefined if not found.
1023
+ * A kept `netcage.managed` container, as the CLI's netcage query surfaces it to
1024
+ * the pure decision. Only the two fields the DECISION needs are typed:
1025
+ * - `key`: the anon-pi launch-identity key (keptContainerKey) the CLI stamped
1026
+ * onto the container at `run` time (a netcage label / container name) and
1027
+ * reads back from the label; this is what a launch matches against.
1028
+ * - `ref`: how to address the container for `netcage start` (its id or name).
1029
+ * The CLI is free to carry more; the pure rule reads only these.
450
1030
  */
451
- export function shippedWebveilDockerfilePath(): string | undefined {
452
- return shippedFile(join('examples', 'Dockerfile.pi-webveil'));
1031
+ export interface KeptContainer {
1032
+ /** The anon-pi launch-identity key stamped on the container (keptContainerKey). */
1033
+ key: string;
1034
+ /** The container ref (id or name) to pass to `netcage start`. */
1035
+ ref: string;
453
1036
  }
454
1037
 
455
1038
  /**
456
- * Resolve a file shipped in the package root, from this module's location
457
- * (package root is one level up from dist/anon-pi.js). Returns undefined if it
458
- * cannot be found or import.meta.url is unavailable.
1039
+ * The run-vs-start decision. `run` = `netcage run` a fresh container (WITHOUT
1040
+ * `--rm` under `--keep`, so it is left kept; the run argv itself is
1041
+ * resolveRunPlan's job). `start` = `netcage start <ref>` an existing kept
1042
+ * container whose identity matches this launch.
459
1043
  */
460
- function shippedFile(rel: string): string | undefined {
461
- try {
462
- const here = dirname(fileURLToPath(import.meta.url));
463
- for (const p of [join(here, '..', rel), join(here, rel)]) {
464
- if (existsSync(p)) return p;
1044
+ export type RunVsStart = {action: 'run'} | {action: 'start'; ref: string};
1045
+
1046
+ /**
1047
+ * PURE: the launch-identity match key for a kept container, derived ENTIRELY
1048
+ * from the (machine, projects-root, project) identity (ADR-0002). It is what
1049
+ * decides whether an existing kept `netcage.managed` container IS the one a
1050
+ * `--keep` launch should resume.
1051
+ *
1052
+ * The fields, and why each is load-bearing:
1053
+ * - `machine.name`: a kept container mounts THIS machine's home at /root; a
1054
+ * same-project container on another machine is a different environment.
1055
+ * - `projectsRoot`: the host dir mounted at /projects; two launches with the
1056
+ * same project name but different roots are different working trees.
1057
+ * - `mountParent` (or '' when absent): `--mount` re-roots into a DIFFERENT
1058
+ * host parent at /work, so a `--mount` launch is a distinct identity from
1059
+ * the projects-root launch of the same name.
1060
+ * - the resolved container `cwd`: this already encodes the project token
1061
+ * (`/projects/<p>`, `/work/<p>`, `.` -> a root, or /root for a bare shell)
1062
+ * AND which root it sits under, so it is pi's conversation key too. Using
1063
+ * the cwd keeps the container identity aligned with the conversation the
1064
+ * kept container hosts.
1065
+ *
1066
+ * DELIBERATELY EXCLUDED (not part of identity): `--keep`/`--rm` (the throwaway
1067
+ * choice for THIS run), the proxy + the direct-hole llm (forced-egress inputs),
1068
+ * forwarded pi args, and the seed. Two launches that differ only in those must
1069
+ * resolve to the SAME kept container.
1070
+ *
1071
+ * The key is a single opaque string (a `\n`-joined, field-tagged record) so the
1072
+ * CLI can stamp it verbatim onto a netcage label and match on string equality;
1073
+ * its internal shape is not a contract (compare only keys this function makes).
1074
+ */
1075
+ export function keptContainerKey(intent: LaunchIntent): string {
1076
+ const {machine, projectsRoot, project, mountParent} = intent;
1077
+ const mounted = nonEmpty(mountParent) !== undefined;
1078
+ const rootKind: RootKind = mounted ? 'mount' : 'projects';
1079
+ // The same cwd resolution resolveRunPlan uses, so the key names the exact
1080
+ // container a matching launch would run in (its conversation key).
1081
+ const cwd =
1082
+ project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
1083
+ return [
1084
+ `machine=${machine.name}`,
1085
+ `projectsRoot=${projectsRoot}`,
1086
+ `mountParent=${nonEmpty(mountParent) ?? ''}`,
1087
+ `cwd=${cwd}`,
1088
+ ].join('\n');
1089
+ }
1090
+
1091
+ /**
1092
+ * PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
1093
+ * `netcage.managed` containers (the CLI's netcage query result).
1094
+ *
1095
+ * - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
1096
+ * listing is NOT consulted (a throwaway launch never resumes a kept box).
1097
+ * - `--keep`: a kept container whose `key` equals this launch's
1098
+ * keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
1099
+ * (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
1100
+ *
1101
+ * Never spawns, never queries netcage: the listing is injected, so the whole
1102
+ * decision is a pure function of (intent, listing).
1103
+ */
1104
+ export function resolveRunVsStart(
1105
+ intent: LaunchIntent,
1106
+ kept: readonly KeptContainer[],
1107
+ ): RunVsStart {
1108
+ // Throwaway short-circuit: a `--rm` launch is always a fresh run and never
1109
+ // consults the listing (it must not resume a kept container).
1110
+ if (intent.keep !== true) return {action: 'run'};
1111
+
1112
+ const want = keptContainerKey(intent);
1113
+ const match = kept.find((c) => c.key === want);
1114
+ return match ? {action: 'start', ref: match.ref} : {action: 'run'};
1115
+ }
1116
+
1117
+ // --- The bare-launch menu: choice-list + per-machine project-usage record ----
1118
+ //
1119
+ // anon-pi's bare launch shows a HOST-side arrow-key menu of a machine's
1120
+ // projects BEFORE any jail runs. This module owns only the PURE data the menu
1121
+ // renders; the CLI reads the real dirs (the projects root + each machine home's
1122
+ // sessions dir) and renders the raw-mode TUI (the cli-bare-launch-menu-tui
1123
+ // task). Everything here takes SUPPLIED listings so it stays unit-testable.
1124
+ //
1125
+ // Conversations are per-machine (each machine's home keeps its own pi
1126
+ // sessions), but project FILES are global (the same folder is shared across
1127
+ // machines). pi keys a session by its launch cwd, so a project used on a machine
1128
+ // leaves a session dir at machines/<M>/home/.pi/agent/sessions/<slug>/, where
1129
+ // <slug> is pi's cwd convention over /projects/<name> (projectSessionSlug),
1130
+ // machine-invariant. "Used on" is therefore DERIVED from which machine homes
1131
+ // contain that session dir - no marker file.
1132
+
1133
+ /**
1134
+ * PURE: the pi session-dir slug for a project, i.e. pathSlug of its jail cwd
1135
+ * `/projects/<name>`. Because the cwd is the SAME on every machine (files are
1136
+ * global, the projects root is mounted at /projects everywhere), this slug is
1137
+ * MACHINE-INVARIANT: the same shared project is recognised in each machine's
1138
+ * sessions dir. Validates the name (rejecting traversal) as projectContainerCwd
1139
+ * does. e.g. `alpha` -> `--projects-alpha--`.
1140
+ */
1141
+ export function projectSessionSlug(name: string): string {
1142
+ return pathSlug(projectContainerCwd(name));
1143
+ }
1144
+
1145
+ /**
1146
+ * The pure choice-list the bare-launch menu renders. `projects` are the
1147
+ * folder-safe project names (sorted, case-insensitive) offered as pi launches;
1148
+ * `here` is the `.` root token (a scratch pi at the root itself); `canNew` /
1149
+ * `canShell` gate the `+ new project…` and `shell` affordances. It carries NO
1150
+ * usage annotation (that is deriveProjectUsage, keyed by project name), so a
1151
+ * caller can render the list alone or joined with usage.
1152
+ */
1153
+ export interface MenuChoiceList {
1154
+ /** The folder-safe project names, sorted case-insensitively for a stable menu. */
1155
+ projects: string[];
1156
+ /** The `.` "here" entry: a scratch pi at the root itself (ROOT_TOKEN). */
1157
+ here: string;
1158
+ /** Whether the `+ new project…` affordance is offered (always true today). */
1159
+ canNew: boolean;
1160
+ /** Whether the `shell` affordance is offered (always true today). */
1161
+ canShell: boolean;
1162
+ }
1163
+
1164
+ /**
1165
+ * PURE: build the menu choice-list from a SUPPLIED projects-root listing (the
1166
+ * CLI's real `readdir` of the projects root). Entries that are not folder-safe
1167
+ * project names (dotfiles like `.git`, `..`, path-separator names, whitespace,
1168
+ * reserved tokens) are DROPPED silently: they can never be a valid project
1169
+ * launch (validateName would reject them), and the `.` root is the separate
1170
+ * `here` entry, not a listed project. The surviving names are sorted
1171
+ * case-insensitively so the menu order is stable regardless of dir-read order.
1172
+ *
1173
+ * `canNew` / `canShell` default TRUE (both affordances are always offered
1174
+ * today); they are fields so a later policy can gate them without a signature
1175
+ * change. An empty projects root still offers here / new / shell.
1176
+ */
1177
+ export function buildMenuChoiceList(args: {
1178
+ projects: readonly string[];
1179
+ canNew?: boolean;
1180
+ canShell?: boolean;
1181
+ }): MenuChoiceList {
1182
+ const projects = args.projects.filter(isProjectName).sort((a, b) => {
1183
+ const la = a.toLowerCase();
1184
+ const lb = b.toLowerCase();
1185
+ if (la < lb) return -1;
1186
+ if (la > lb) return 1;
1187
+ // Case-insensitive ties keep a deterministic order via the raw compare.
1188
+ return a < b ? -1 : a > b ? 1 : 0;
1189
+ });
1190
+ return {
1191
+ projects,
1192
+ here: ROOT_TOKEN,
1193
+ canNew: args.canNew ?? true,
1194
+ canShell: args.canShell ?? true,
1195
+ };
1196
+ }
1197
+
1198
+ /** True iff `name` is a folder-safe project name (validateName would accept it). */
1199
+ function isProjectName(name: string): boolean {
1200
+ try {
1201
+ validateName(name, 'project');
1202
+ return true;
1203
+ } catch {
1204
+ return false;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * A per-machine session-dir listing: for each machine name, the slugs present
1210
+ * under machines/<M>/home/.pi/agent/sessions/. The CLI derives this by reading
1211
+ * each machine home's sessions dir; the pure derivation takes it as input. Only
1212
+ * the project session slugs (projectSessionSlug) are matched; any other slug
1213
+ * (e.g. a `.`/`~`/`--mount` scratch session) is simply not a project so it does
1214
+ * not appear in the usage record.
1215
+ */
1216
+ export type SessionDirListing = Record<string, readonly string[]>;
1217
+
1218
+ /** The usage record for ONE project: which machines used it + a current-new flag. */
1219
+ export interface ProjectUsage {
1220
+ /** The project name (as supplied; validated). */
1221
+ project: string;
1222
+ /**
1223
+ * The machine names whose home contains this project's session dir, sorted
1224
+ * (a stable, machine-invariant "used on" list derived from session presence).
1225
+ */
1226
+ machines: string[];
1227
+ /**
1228
+ * True when the CURRENT machine has NO session dir for this project yet (it is
1229
+ * new for this machine, even if other machines have used the shared files).
1230
+ */
1231
+ currentMachineIsNew: boolean;
1232
+ }
1233
+
1234
+ /**
1235
+ * PURE: derive the per-machine project-usage record from SUPPLIED session-dir
1236
+ * presence (no marker file). For each supplied project, in the SUPPLIED order,
1237
+ * it reports which machines' homes contain that project's (machine-invariant)
1238
+ * session slug, and whether the CURRENT machine is new for it.
1239
+ *
1240
+ * The project ORDER is preserved (the caller orders the menu, e.g. via
1241
+ * buildMenuChoiceList); only the per-project `machines` list is sorted, so the
1242
+ * "used on" annotation is stable. Validates each project name (rejecting
1243
+ * traversal) via projectSessionSlug.
1244
+ */
1245
+ export function deriveProjectUsage(args: {
1246
+ projects: readonly string[];
1247
+ currentMachine: string;
1248
+ sessions: SessionDirListing;
1249
+ }): ProjectUsage[] {
1250
+ const {projects, currentMachine, sessions} = args;
1251
+ const machineNames = Object.keys(sessions);
1252
+ return projects.map((project) => {
1253
+ const slug = projectSessionSlug(project);
1254
+ const machines = machineNames
1255
+ .filter((m) => (sessions[m] ?? []).includes(slug))
1256
+ .sort();
1257
+ const currentMachineIsNew = !(sessions[currentMachine] ?? []).includes(
1258
+ slug,
1259
+ );
1260
+ return {project, machines, currentMachineIsNew};
1261
+ });
1262
+ }
1263
+
1264
+ /**
1265
+ * What ONE selectable menu row launches, so the CLI can dispatch a chosen entry
1266
+ * without re-deriving anything:
1267
+ * - `project` -> pi in `/projects/<project>` (the `anon-pi <project>` launch);
1268
+ * - `here` -> a scratch pi at the root itself (the `.` root token launch);
1269
+ * - `new` -> prompt+validate a new project name, then launch it as pi;
1270
+ * - `shell` -> the `--shell` jailed-bash launch.
1271
+ */
1272
+ export type MenuEntryKind = 'project' | 'here' | 'new' | 'shell';
1273
+
1274
+ /** One rendered, selectable menu row: what it launches + its human label. */
1275
+ export interface MenuEntry {
1276
+ /** Which launch this row dispatches to (project | here | new | shell). */
1277
+ kind: MenuEntryKind;
1278
+ /**
1279
+ * The project token this row launches: a validated project name (`project`),
1280
+ * the root token `.` (`here`), or undefined (`new` prompts for it, `shell`
1281
+ * takes none). This is exactly the `project` field a launch dispatch feeds
1282
+ * back into the grammar, so no re-parsing is needed.
1283
+ */
1284
+ project?: string;
1285
+ /**
1286
+ * The rendered row text the selector prints: the project name plus its
1287
+ * used-on / new-here annotation (project rows), or the fixed affordance label
1288
+ * (here / new / shell). The annotation is the ONLY place the usage record
1289
+ * surfaces to the user, so the wording lives here (pure) not in the TUI.
1290
+ */
1291
+ label: string;
1292
+ }
1293
+
1294
+ /** The fixed labels for the non-project affordances (one source, so the TUI + its test agree). */
1295
+ export const MENU_HERE_LABEL = '. (here: a scratch pi at the root)';
1296
+ export const MENU_NEW_LABEL = '+ new project\u2026';
1297
+ export const MENU_SHELL_LABEL = 'shell (a jailed bash on this machine)';
1298
+
1299
+ /**
1300
+ * PURE: render ONE project row's annotation from its usage record. Files are
1301
+ * global but conversations are per-machine, so the row tells the user where a
1302
+ * conversation for this project already lives (`used on: <machines>`) and
1303
+ * whether the CURRENT machine has none yet (`new here`). An unused project on a
1304
+ * fresh machine is just `new here` (no machine list). This is the whole
1305
+ * user-visible surface of the derived usage record, kept pure + testable.
1306
+ */
1307
+ export function formatProjectAnnotation(usage: ProjectUsage): string {
1308
+ const parts: string[] = [];
1309
+ if (usage.machines.length > 0) {
1310
+ parts.push(`used on: ${usage.machines.join(', ')}`);
1311
+ }
1312
+ if (usage.currentMachineIsNew) parts.push('new here');
1313
+ return parts.length > 0 ? ` (${parts.join('; ')})` : '';
1314
+ }
1315
+
1316
+ /**
1317
+ * PURE: assemble the ordered, labelled, selectable menu rows from the choice-
1318
+ * list + the per-project usage record. The order is: the projects (in the
1319
+ * choice-list's stable sorted order), then the `.` "here" scratch entry, then
1320
+ * `+ new project\u2026` (when `canNew`), then `shell` (when `canShell`). Each
1321
+ * project row's label carries its used-on / new-here annotation
1322
+ * (formatProjectAnnotation). This holds ALL the menu's logic (order + wording)
1323
+ * so the raw-mode selector only renders these rows and dispatches the picked
1324
+ * one by its `kind`/`project`.
1325
+ *
1326
+ * The `usage` list is expected to be keyed to `choiceList.projects` (same order,
1327
+ * as deriveProjectUsage produces from the choice-list's projects); a project
1328
+ * with no matching usage entry gets a bare, unannotated row rather than erroring.
1329
+ */
1330
+ export function buildMenuEntries(args: {
1331
+ choiceList: MenuChoiceList;
1332
+ usage: readonly ProjectUsage[];
1333
+ }): MenuEntry[] {
1334
+ const {choiceList, usage} = args;
1335
+ const byProject = new Map(usage.map((u) => [u.project, u]));
1336
+ const entries: MenuEntry[] = choiceList.projects.map((project) => {
1337
+ const u = byProject.get(project);
1338
+ const annotation = u ? formatProjectAnnotation(u) : '';
1339
+ return {kind: 'project', project, label: `${project}${annotation}`};
1340
+ });
1341
+ entries.push({
1342
+ kind: 'here',
1343
+ project: choiceList.here,
1344
+ label: MENU_HERE_LABEL,
1345
+ });
1346
+ if (choiceList.canNew) entries.push({kind: 'new', label: MENU_NEW_LABEL});
1347
+ if (choiceList.canShell)
1348
+ entries.push({kind: 'shell', label: MENU_SHELL_LABEL});
1349
+ return entries;
1350
+ }
1351
+
1352
+ /**
1353
+ * Encode an absolute path into a directory name using pi's OWN convention (see
1354
+ * pi coding-agent session-manager: `--${cwd without leading slash, / \ : -> -}--`),
1355
+ * so an anon-pi state dir is readable and matches pi's mental model (no opaque
1356
+ * hash). e.g. /home/u/dev/x -> --home-u-dev-x--
1357
+ */
1358
+ export function pathSlug(absPath: string): string {
1359
+ return `--${absPath.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
1360
+ }
1361
+
1362
+ /**
1363
+ * Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
1364
+ * baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
1365
+ * Returns `host` (no port) or `host:port`, lowercased, scheme/path stripped.
1366
+ */
1367
+ export function hostPortKey(value: string): string {
1368
+ let v = value.trim();
1369
+ const scheme = v.indexOf('://');
1370
+ if (scheme >= 0) v = v.slice(scheme + 3);
1371
+ v = v.split('/')[0]; // drop path (/v1, ...)
1372
+ v = v.replace(/^[^@]*@/, ''); // drop any user:pass@
1373
+ return v.toLowerCase();
1374
+ }
1375
+
1376
+ /**
1377
+ * The provider key anon-pi gives the single local provider it generates. A
1378
+ * neutral, host-agnostic name (matches the CONTEXT glossary's "local model"):
1379
+ * it carries NO host identity, unlike the old `import` path which kept the
1380
+ * host's own provider key.
1381
+ */
1382
+ export const LOCAL_PROVIDER_NAME = 'local';
1383
+
1384
+ /**
1385
+ * The pi `api` dialect the generated local provider speaks. Local model servers
1386
+ * (llama.cpp, ollama, LM Studio, vLLM, ...) are overwhelmingly OpenAI-compatible
1387
+ * and serve the completions API under `/v1`, so this is the safe default for an
1388
+ * endpoint captured by `init` (there is no host models.json to copy a dialect
1389
+ * from anymore). See the ## Decisions note in the done record.
1390
+ */
1391
+ export const LOCAL_PROVIDER_API = 'openai-completions';
1392
+
1393
+ /**
1394
+ * A benign, non-secret apiKey for the local provider (a LAN model rarely needs a
1395
+ * real key). It is one of the values pi never flags as a real secret.
1396
+ */
1397
+ export const LOCAL_PROVIDER_API_KEY = 'none';
1398
+
1399
+ /**
1400
+ * apiKey values that are NOT real secrets (safe to carry into the anonymized
1401
+ * seed verbatim). Anything else is treated as a REAL secret: `init` refuses to
1402
+ * seed it (which would put a host credential into the anon home) unless the
1403
+ * operator passes `--force-allow-local-llm-api-key`.
1404
+ */
1405
+ export const BENIGN_API_KEYS: ReadonlySet<string> = new Set([
1406
+ '',
1407
+ 'none',
1408
+ 'ollama',
1409
+ 'no-key',
1410
+ 'nokey',
1411
+ 'local',
1412
+ 'dummy',
1413
+ 'sk-no-key-required',
1414
+ ]);
1415
+
1416
+ /** PURE: whether an apiKey looks like a REAL secret (i.e. not in the benign set). */
1417
+ export function apiKeyLooksReal(apiKey: string | undefined): boolean {
1418
+ if (apiKey === undefined) return false;
1419
+ return !BENIGN_API_KEYS.has(apiKey.trim().toLowerCase());
1420
+ }
1421
+
1422
+ /**
1423
+ * A pi model entry as anon-pi seeds it for the local provider. pi keys a model
1424
+ * by `id`; `name` is the display label and `cost` is all-zero (a LAN model is
1425
+ * free). A "server"-sourced entry is minimal (id/name/cost); a "configured"
1426
+ * entry (imported from the host models.json) preserves whatever extra fields it
1427
+ * carried (`contextWindow`, `maxTokens`, `reasoning`, `input`, ...) via the
1428
+ * index signature.
1429
+ */
1430
+ export interface GeneratedModel {
1431
+ id: string;
1432
+ name: string;
1433
+ cost?: {input: number; output: number; cacheRead: number; cacheWrite: number};
1434
+ [k: string]: unknown;
1435
+ }
1436
+
1437
+ /**
1438
+ * PURE: a candidate model for the `init` picker. `configured` means it came from
1439
+ * the host `~/.pi/agent/models.json` provider that matches the endpoint (a
1440
+ * well-tuned entry with its real config); otherwise it was only reported by the
1441
+ * endpoint's `/v1/models` (a bare id we synthesize a minimal entry for). The
1442
+ * picker marks configured ones so the user knows which are more likely correct.
1443
+ */
1444
+ export interface ModelCandidate {
1445
+ id: string;
1446
+ configured: boolean;
1447
+ /** The full pi model entry to seed (rich for configured, minimal otherwise). */
1448
+ entry: GeneratedModel;
1449
+ }
1450
+
1451
+ /**
1452
+ * PURE: turn a discovered model `id` into a minimal-but-valid pi model entry.
1453
+ * `name` defaults to the id; a LAN model is free, so every cost is 0.
1454
+ */
1455
+ export function localModelEntry(id: string): GeneratedModel {
1456
+ return {
1457
+ id,
1458
+ name: id,
1459
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
1460
+ };
1461
+ }
1462
+
1463
+ /**
1464
+ * PURE: extract the model ids from a parsed OpenAI-compatible `/v1/models`
1465
+ * response (`{ data: [{ id }, ...] }`, as llama.cpp / vLLM / LM Studio serve).
1466
+ * Tolerates a bare array, a `models` key, missing/garbage input (returns []), so
1467
+ * `init` can feed whatever the endpoint returned straight in.
1468
+ */
1469
+ export function parseModelsListing(raw: unknown): string[] {
1470
+ const rows: unknown[] = Array.isArray(raw)
1471
+ ? raw
1472
+ : raw && typeof raw === 'object'
1473
+ ? (((raw as Record<string, unknown>).data as unknown[]) ??
1474
+ ((raw as Record<string, unknown>).models as unknown[]) ??
1475
+ [])
1476
+ : [];
1477
+ if (!Array.isArray(rows)) return [];
1478
+ const ids: string[] = [];
1479
+ for (const r of rows) {
1480
+ if (typeof r === 'string') {
1481
+ if (r.trim() !== '') ids.push(r.trim());
1482
+ } else if (r && typeof r === 'object') {
1483
+ const id = (r as Record<string, unknown>).id;
1484
+ if (typeof id === 'string' && id.trim() !== '') ids.push(id.trim());
1485
+ }
1486
+ }
1487
+ return ids;
1488
+ }
1489
+
1490
+ /** The result of scanning a host models.json for the endpoint's provider. */
1491
+ export interface HostProviderMatch {
1492
+ /** The matching provider's models as full pi entries (verbatim host config). */
1493
+ models: GeneratedModel[];
1494
+ /** The matching provider's apiKey (verbatim), for the benign/real check. */
1495
+ apiKey?: string;
1496
+ /** True iff that apiKey looks like a REAL secret (init refuses without --force). */
1497
+ apiKeyLooksReal: boolean;
1498
+ }
1499
+
1500
+ /**
1501
+ * PURE: find, in a parsed host `~/.pi/agent/models.json`, the provider whose
1502
+ * `baseUrl` points at `llmEndpoint` (matched via hostPortKey), and return ONLY
1503
+ * that provider's models + apiKey. This is the anonymity-critical scoping: the
1504
+ * ONLY provider considered is the one served by the `--allow-direct` endpoint,
1505
+ * so no other provider (etherplay/google/a paid API) — and no other provider's
1506
+ * key — can ever enter the seed. Returns undefined when no provider matches.
1507
+ *
1508
+ * The `--allow-direct` target and this match both go through hostPortKey, so a
1509
+ * URL / ip:port / bare-ip host config all match the same endpoint.
1510
+ */
1511
+ export function pickLocalProviderModels(
1512
+ hostModels: PiModelsFile,
1513
+ llmEndpoint: string,
1514
+ ): HostProviderMatch | undefined {
1515
+ const providers = hostModels.providers ?? {};
1516
+ const want = hostPortKey(llmEndpoint);
1517
+ for (const p of Object.values(providers)) {
1518
+ if (!p || typeof p !== 'object' || !p.baseUrl) continue;
1519
+ if (hostPortKey(p.baseUrl) !== want) continue;
1520
+ const models: GeneratedModel[] = [];
1521
+ for (const m of p.models ?? []) {
1522
+ if (m && typeof m === 'object') {
1523
+ const id = (m as Record<string, unknown>).id;
1524
+ if (typeof id === 'string' && id.trim() !== '') {
1525
+ models.push({...(m as GeneratedModel), id: id.trim()});
1526
+ }
1527
+ } else if (typeof m === 'string' && m.trim() !== '') {
1528
+ models.push(localModelEntry(m.trim()));
1529
+ }
1530
+ }
1531
+ return {
1532
+ models,
1533
+ apiKey: p.apiKey,
1534
+ apiKeyLooksReal: apiKeyLooksReal(p.apiKey),
1535
+ };
1536
+ }
1537
+ return undefined;
1538
+ }
1539
+
1540
+ /**
1541
+ * PURE: merge the host-config models (rich, `configured: true`) with the
1542
+ * endpoint's live `/v1/models` ids (`configured: false` for any the host did not
1543
+ * already carry), into ONE deduped, sorted candidate list. Host config wins on
1544
+ * an id present in both (it has the real config). Every candidate here is served
1545
+ * by the endpoint, so every one is `--allow-direct`-reachable; the merge just
1546
+ * unions "what you already configured" with "what the server also offers".
1547
+ */
1548
+ export function mergeModelSources(
1549
+ hostModels: readonly GeneratedModel[],
1550
+ serverIds: readonly string[],
1551
+ ): ModelCandidate[] {
1552
+ const byId = new Map<string, ModelCandidate>();
1553
+ for (const m of hostModels) {
1554
+ const id = m.id.trim();
1555
+ if (id === '') continue;
1556
+ byId.set(id, {id, configured: true, entry: {...m, id}});
1557
+ }
1558
+ for (const raw of serverIds) {
1559
+ const id = raw.trim();
1560
+ if (id === '' || byId.has(id)) continue;
1561
+ byId.set(id, {id, configured: false, entry: localModelEntry(id)});
1562
+ }
1563
+ return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id));
1564
+ }
1565
+
1566
+ /**
1567
+ * PURE: synthesize a pi `models.json` for the local provider from an endpoint
1568
+ * and the CHOSEN model entries. It normalises the endpoint with hostPortKey and
1569
+ * returns a models.json carrying exactly ONE provider (named LOCAL_PROVIDER_NAME
1570
+ * — a neutral name, no host fingerprint) pointed at that endpoint.
1571
+ *
1572
+ * `apiKey` defaults to the benign LOCAL_PROVIDER_API_KEY. A caller may pass the
1573
+ * host provider's real key ONLY under an explicit force flag; the benign/real
1574
+ * decision (and the refusal) lives in `init`, not here — this pure function just
1575
+ * writes what it is given.
1576
+ *
1577
+ * Accepts either full model entries (from the host config) or bare id strings
1578
+ * (which it turns into minimal entries). Empty models => a provider pointed at
1579
+ * the endpoint with no pickable model (the degraded fallback).
1580
+ */
1581
+ export function generateModelsJson(
1582
+ llmEndpoint: string,
1583
+ models: readonly (GeneratedModel | string)[] = [],
1584
+ apiKey: string = LOCAL_PROVIDER_API_KEY,
1585
+ ): PiModelsFile {
1586
+ const hostPort = hostPortKey(llmEndpoint);
1587
+ const entries: GeneratedModel[] = [];
1588
+ const seen = new Set<string>();
1589
+ for (const m of models) {
1590
+ const entry = typeof m === 'string' ? localModelEntry(m.trim()) : m;
1591
+ const id = entry.id.trim();
1592
+ if (id === '' || seen.has(id)) continue;
1593
+ seen.add(id);
1594
+ entries.push({...entry, id});
1595
+ }
1596
+ entries.sort((a, b) => a.id.localeCompare(b.id));
1597
+ const provider: PiProvider = {
1598
+ api: LOCAL_PROVIDER_API,
1599
+ apiKey,
1600
+ baseUrl: `http://${hostPort}/v1`,
1601
+ models: entries,
1602
+ };
1603
+ return {providers: {[LOCAL_PROVIDER_NAME]: provider}};
1604
+ }
1605
+
1606
+ /** The pi settings.json keys anon-pi sets for the local-model default selection. */
1607
+ export interface ModelSelection {
1608
+ defaultProvider: string;
1609
+ defaultModel: string;
1610
+ enabledModels: string[];
1611
+ }
1612
+
1613
+ /**
1614
+ * PURE: the model-selection settings.json fragment for the seeded local
1615
+ * provider: `defaultProvider` = LOCAL_PROVIDER_NAME, `defaultModel` = the chosen
1616
+ * default id, `enabledModels` = `local/<id>` for each imported model (pi's
1617
+ * `<provider>/<id>` convention). The caller MERGES this into any existing
1618
+ * settings so image-staged settings (packages/extensions) are preserved.
1619
+ */
1620
+ export function generateModelSelection(
1621
+ modelIds: readonly string[],
1622
+ defaultId: string,
1623
+ ): ModelSelection {
1624
+ const ids = Array.from(
1625
+ new Set(modelIds.map((m) => m.trim()).filter((m) => m !== '')),
1626
+ ).sort((a, b) => a.localeCompare(b));
1627
+ return {
1628
+ defaultProvider: LOCAL_PROVIDER_NAME,
1629
+ defaultModel: defaultId.trim(),
1630
+ enabledModels: ids.map((id) => `${LOCAL_PROVIDER_NAME}/${id}`),
1631
+ };
1632
+ }
1633
+
1634
+ /**
1635
+ * PURE: shallow-merge the local-model selection into an existing (parsed)
1636
+ * settings.json object, returning the merged object. Only the three selection
1637
+ * keys are overwritten; every other key the user/image had (packages,
1638
+ * extensions, thinking level, ...) is preserved. `existing` undefined/garbage is
1639
+ * treated as `{}`.
1640
+ */
1641
+ export function mergeModelSelection(
1642
+ existing: unknown,
1643
+ selection: ModelSelection,
1644
+ ): Record<string, unknown> {
1645
+ const base: Record<string, unknown> =
1646
+ existing && typeof existing === 'object'
1647
+ ? {...(existing as Record<string, unknown>)}
1648
+ : {};
1649
+ base.defaultProvider = selection.defaultProvider;
1650
+ base.defaultModel = selection.defaultModel;
1651
+ base.enabledModels = selection.enabledModels;
1652
+ return base;
1653
+ }
1654
+
1655
+ /**
1656
+ * The host `~/.pi/agent/models.json` path `init` reads the matching local
1657
+ * provider from. Uses the container-less host HOME (or PI_CODING_AGENT_DIR when
1658
+ * the user relocated pi's agent dir). This is READ-ONLY (init copies only the
1659
+ * ONE matching provider's models); it is never written.
1660
+ */
1661
+ export function resolveHostModelsPath(env: AnonPiEnv): string {
1662
+ const agentDir =
1663
+ env.piAgentDir && env.piAgentDir.trim() !== ''
1664
+ ? env.piAgentDir
1665
+ : join(env.home, '.pi', 'agent');
1666
+ return join(agentDir, MODELS_FILE);
1667
+ }
1668
+
1669
+ /**
1670
+ * A pi provider entry (as it appears under models.json `providers[name]`). Only
1671
+ * the fields anon-pi reads are typed; the rest is preserved verbatim.
1672
+ */
1673
+ export interface PiProvider {
1674
+ baseUrl?: string;
1675
+ apiKey?: string;
1676
+ api?: string;
1677
+ models?: unknown[];
1678
+ [k: string]: unknown;
1679
+ }
1680
+
1681
+ /** Parsed shape of a pi models.json (only `providers` is required). */
1682
+ export interface PiModelsFile {
1683
+ providers?: Record<string, PiProvider>;
1684
+ [k: string]: unknown;
1685
+ }
1686
+
1687
+ // --- `anon-pi init` onboarding: the PURE proxy detect/verify DECISIONS --------
1688
+ //
1689
+ // `anon-pi init` onboards HONESTLY (this is an anonymity tool): its proxy step
1690
+ // presents EVIDENCE only (open ports, a real SOCKS5 handshake, a real `netcage
1691
+ // verify` exit IP) plus WEAK process hints. It MUST NEVER claim/label the exit
1692
+ // provider: a SOCKS proxy does not announce Mullvad/Proton/NordVPN/etc, so a
1693
+ // provider label would be a DANGEROUS LIE. This module owns the pure decisions
1694
+ // (handshake interpretation, the findings-without-labels formatter, the weak
1695
+ // hint wording, the verify exit-IP parse); the socket probes, the `netcage
1696
+ // verify` / `podman build` spawns, and the prompts are cli.ts's thin I/O.
1697
+
1698
+ /**
1699
+ * The default SOCKS ports `init` probes, each with a WEAK, structural hint (the
1700
+ * conventional tool that DEFAULTS to that port). The hint names a local tool a
1701
+ * port is CONVENTIONALLY used by, NOT the exit provider: `9050`/`9150` are Tor's
1702
+ * own listeners (Tor IS the tool, so naming it is honest), `1080` is the generic
1703
+ * SOCKS default (wireproxy / `ssh -D` / other), which is why its hint stays
1704
+ * provider-agnostic ("wireproxy / ssh -D / generic"): behind a `1080` wireproxy
1705
+ * could be ANY WireGuard VPN, and we never guess which. See the ADR / Decisions.
1706
+ */
1707
+ export const DEFAULT_SOCKS_PROBE_PORTS: readonly {
1708
+ port: number;
1709
+ hint: string;
1710
+ }[] = [
1711
+ {port: 9050, hint: 'Tor default (system tor)'},
1712
+ {port: 9150, hint: 'Tor Browser default'},
1713
+ {port: 1080, hint: 'generic SOCKS (wireproxy / ssh -D)'},
1714
+ ];
1715
+
1716
+ /**
1717
+ * The SOCKS5 method-selection greeting `init` sends to CONFIRM a port really
1718
+ * speaks SOCKS5 (RFC 1928 §3): version 5, one method offered, `0x00`
1719
+ * (no-authentication). A real SOCKS5 server replies with two bytes
1720
+ * `[0x05, <method>]`; anything else is not SOCKS5. Exposed as a constant so the
1721
+ * probe I/O and the handshake test send byte-identical bytes.
1722
+ */
1723
+ export const SOCKS5_METHOD_SELECTOR: readonly number[] = [0x05, 0x01, 0x00];
1724
+
1725
+ /** How a SOCKS5 handshake probe against a port came out (the pure verdict). */
1726
+ export type SocksHandshake =
1727
+ | {
1728
+ /** The server replied with a well-formed SOCKS5 method-selection reply. */
1729
+ socks5: true;
1730
+ /** The selected method byte the server chose (informational). */
1731
+ method: number;
1732
+ }
1733
+ | {
1734
+ /** The reply was absent, too short, or not a SOCKS5 version-5 reply. */
1735
+ socks5: false;
1736
+ /** A terse, provider-agnostic reason (for the findings line). */
1737
+ reason: string;
1738
+ };
1739
+
1740
+ /**
1741
+ * PURE: interpret a SOCKS5 method-selection REPLY (the bytes read back after
1742
+ * sending SOCKS5_METHOD_SELECTOR). A valid reply is EXACTLY the two bytes
1743
+ * `[0x05, <method>]` where `<method> != 0xff` (0xff = "no acceptable methods",
1744
+ * i.e. the server IS SOCKS5 but rejected no-auth; that is still a SOCKS5 server,
1745
+ * but for a bare no-auth probe we treat it as a soft failure so the finding does
1746
+ * not imply the port is usable no-auth). Any non-5 first byte, a short reply, or
1747
+ * an empty reply is NOT SOCKS5.
1748
+ *
1749
+ * Reply in -> verdict out; the socket read is cli.ts's job. The reason strings
1750
+ * are deliberately structural ("no reply", "not SOCKS5") and NEVER name a
1751
+ * provider.
1752
+ */
1753
+ export function interpretSocks5Handshake(
1754
+ reply: readonly number[] | Uint8Array | Buffer,
1755
+ ): SocksHandshake {
1756
+ const bytes = Array.from(reply as ArrayLike<number>);
1757
+ if (bytes.length === 0) return {socks5: false, reason: 'no reply'};
1758
+ if (bytes.length < 2) return {socks5: false, reason: 'short reply'};
1759
+ if (bytes[0] !== 0x05) return {socks5: false, reason: 'not SOCKS5'};
1760
+ const method = bytes[1];
1761
+ if (method === 0xff) {
1762
+ return {socks5: false, reason: 'SOCKS5 but no acceptable auth method'};
1763
+ }
1764
+ return {socks5: true, method};
1765
+ }
1766
+
1767
+ /**
1768
+ * A weak process hint: a LOCAL tool whose presence SUGGESTS what a port is
1769
+ * (e.g. a `tor` process -> likely Tor). It is a hint about the LOCAL software
1770
+ * only, never a claim about the EXIT provider. cli.ts supplies the observed
1771
+ * process name (e.g. from `ps`/`/proc`); the pure mapping stays testable.
1772
+ */
1773
+ export interface ProcessHint {
1774
+ /** The observed local process name (as cli.ts read it). */
1775
+ process: string;
1776
+ /** The weak, hedged hint text ("a `tor` process is running -> likely Tor"). */
1777
+ hint: string;
1778
+ }
1779
+
1780
+ /**
1781
+ * PURE: map an observed local process name to a WEAK, hedged hint, or undefined
1782
+ * when we have nothing honest to say. The ONLY confident mapping is `tor` ->
1783
+ * "likely Tor", because Tor is a LOCAL tool that runs its OWN SOCKS listener (so
1784
+ * seeing `tor` is real evidence the port is Tor). We do NOT map anything to an
1785
+ * EXIT provider (Mullvad/Proton/...): a `wireproxy` process only tells us the
1786
+ * SOCKS front-end, never which VPN sits behind it, so its hint stays
1787
+ * provider-agnostic. Every returned hint is HEDGED ("likely", "-> a SOCKS
1788
+ * front-end") and never states the exit provider.
1789
+ */
1790
+ export function processHint(processName: string): ProcessHint | undefined {
1791
+ const name = processName.trim().toLowerCase();
1792
+ if (name === '') return undefined;
1793
+ if (name === 'tor') {
1794
+ return {
1795
+ process: processName,
1796
+ hint: 'a `tor` process is running -> likely Tor',
1797
+ };
1798
+ }
1799
+ if (name === 'wireproxy') {
1800
+ return {
1801
+ process: processName,
1802
+ // A SOCKS front-end for SOME WireGuard VPN; we NEVER guess which one.
1803
+ hint:
1804
+ 'a `wireproxy` process is running -> a SOCKS front-end for a ' +
1805
+ 'WireGuard VPN (which one is not observable here)',
1806
+ };
1807
+ }
1808
+ return undefined;
1809
+ }
1810
+
1811
+ /**
1812
+ * One probed SOCKS candidate, as `init` gathers it for the findings display. All
1813
+ * fields are EVIDENCE the probe actually observed; there is DELIBERATELY no
1814
+ * "provider" field, so the type itself cannot carry a provider label.
1815
+ */
1816
+ export interface ProxyFinding {
1817
+ /** The host that was probed (usually 127.0.0.1). */
1818
+ host: string;
1819
+ /** The port that was probed. */
1820
+ port: number;
1821
+ /** Whether the TCP port was open (a connection succeeded). */
1822
+ open: boolean;
1823
+ /** The SOCKS5 handshake verdict (only meaningful when `open`). */
1824
+ handshake?: SocksHandshake;
1825
+ /** The port's structural hint (DEFAULT_SOCKS_PROBE_PORTS), if any. */
1826
+ portHint?: string;
1827
+ /** Any weak LOCAL process hint (processHint), if one was observed. */
1828
+ processHint?: string;
1829
+ }
1830
+
1831
+ /**
1832
+ * The set of substrings a findings line must NEVER contain: known exit-provider
1833
+ * / VPN brand names. This is the machine-checkable half of the never-label rule
1834
+ * (a test asserts formatProxyFindings' output contains NONE of these for any
1835
+ * input). It is not exhaustive of every brand, but it pins the obvious ones so a
1836
+ * regression that starts labelling providers is caught. `tor` is NOT here: Tor
1837
+ * is the LOCAL tool we legitimately hint at, not an opaque exit provider.
1838
+ */
1839
+ export const FORBIDDEN_PROVIDER_LABELS: readonly string[] = [
1840
+ 'mullvad',
1841
+ 'proton',
1842
+ 'nordvpn',
1843
+ 'nord vpn',
1844
+ 'expressvpn',
1845
+ 'express vpn',
1846
+ 'surfshark',
1847
+ 'ivpn',
1848
+ 'pia',
1849
+ 'private internet access',
1850
+ 'cyberghost',
1851
+ 'windscribe',
1852
+ ];
1853
+
1854
+ /**
1855
+ * PURE: format the probe findings into the human-readable block `init` shows
1856
+ * before asking the user to CHOOSE a proxy. It renders EVIDENCE ONLY: for each
1857
+ * candidate, the `host:port`, whether it is open, the SOCKS5 handshake verdict,
1858
+ * and the structural PORT hint. It NEVER emits an exit-provider label (a SOCKS
1859
+ * proxy does not announce its provider; a false label is a dangerous lie). The
1860
+ * `## Decisions` note + a test assert the output never contains a
1861
+ * FORBIDDEN_PROVIDER_LABELS substring for any input.
1862
+ *
1863
+ * `processNote` is the HOST-WIDE weak process hint (a running `tor`/`wireproxy`
1864
+ * LOCAL process), shown ONCE as a general note rather than glued onto every port
1865
+ * line: the observation is host-wide, not per-port, so repeating it on each
1866
+ * candidate (including closed ports the process is unrelated to) reads as noise.
1867
+ * A per-finding `processHint`, if still set, is also honoured inline for
1868
+ * backward compatibility, but `init` now passes the host-wide note instead.
1869
+ *
1870
+ * Findings in -> display string out; the socket probes are cli.ts's job.
1871
+ */
1872
+ export function formatProxyFindings(
1873
+ findings: readonly ProxyFinding[],
1874
+ processNote?: string,
1875
+ ): string {
1876
+ if (findings.length === 0) {
1877
+ return 'No SOCKS ports responded on the probed set. Enter your proxy as host:port.';
1878
+ }
1879
+ const lines: string[] = [];
1880
+ for (const f of findings) {
1881
+ const where = `${f.host}:${f.port}`;
1882
+ let status: string;
1883
+ if (!f.open) {
1884
+ status = 'closed (no TCP connection)';
1885
+ } else if (f.handshake && f.handshake.socks5) {
1886
+ status = 'open, SOCKS5 handshake OK';
1887
+ } else if (f.handshake && !f.handshake.socks5) {
1888
+ status = `open, but NOT SOCKS5 (${f.handshake.reason})`;
1889
+ } else {
1890
+ status = 'open';
1891
+ }
1892
+ const hints: string[] = [];
1893
+ if (f.portHint) hints.push(f.portHint);
1894
+ if (f.processHint) hints.push(f.processHint);
1895
+ const hintStr = hints.length > 0 ? ` [${hints.join('; ')}]` : '';
1896
+ lines.push(`${where}: ${status}${hintStr}`);
1897
+ }
1898
+ // The host-wide process observation, shown ONCE (not per port). It is a weak
1899
+ // LOCAL hint, never an exit-provider label.
1900
+ if (processNote && processNote.trim() !== '') {
1901
+ lines.push(`Note: ${processNote.trim()}`);
1902
+ }
1903
+ lines.push(
1904
+ 'These are EVIDENCE only (open ports + a real SOCKS5 handshake). A SOCKS ' +
1905
+ 'proxy does not announce its exit provider, so none is claimed here; the ' +
1906
+ '`netcage verify` step below shows the real exit IP as proof.',
1907
+ );
1908
+ return lines.join('\n');
1909
+ }
1910
+
1911
+ /**
1912
+ * PURE: the `socks5h://<host:port>` URL `init` hands to `netcage verify` and
1913
+ * writes into config.json. Only socks5h:// is accepted downstream (plain
1914
+ * socks5:// resolves DNS locally and leaks), so `init` always emits socks5h.
1915
+ * A value that already carries a scheme is normalised to its host:port first
1916
+ * (via hostPortKey) so `socks5h://socks5h://...` can never be produced.
1917
+ */
1918
+ export function socks5hUrl(hostPort: string): string {
1919
+ return `socks5h://${hostPortKey(hostPort)}`;
1920
+ }
1921
+
1922
+ /**
1923
+ * PURE: extract the exit IP `netcage verify` reported from its combined output.
1924
+ * `netcage verify` prints the jail's forced-egress exit IP (an IPv4/IPv6 line)
1925
+ * as PROOF the egress leaves via the proxy (not the host IP). We scan the output
1926
+ * for the first plausible IP literal and return it; undefined if none is found
1927
+ * (the caller then shows the raw output and lets the user judge). This is a
1928
+ * best-effort PARSE of another tool's text, kept pure + tested so a format tweak
1929
+ * is caught by a unit test, not only in the field.
1930
+ */
1931
+ export function parseVerifyExitIp(output: string): string | undefined {
1932
+ // IPv4 first (the common case: ipify returns an IPv4 for most exits).
1933
+ const v4 = output.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
1934
+ if (v4) {
1935
+ const ip = v4[0];
1936
+ if (ip.split('.').every((o) => Number(o) <= 255)) return ip;
1937
+ }
1938
+ // IPv6 (a loose match: at least two groups and a colon-run), best-effort.
1939
+ const v6 = output.match(/\b(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4}\b/);
1940
+ if (v6 && v6[0].includes('::')) return v6[0];
1941
+ if (v6 && v6[0].split(':').filter(Boolean).length >= 3) return v6[0];
1942
+ return undefined;
1943
+ }
1944
+
1945
+ /**
1946
+ * The image-menu choices `init` offers for the default machine's image. `[1]`
1947
+ * and `[2]` build a SHIPPED Dockerfile via `podman build`; `[3]` takes an
1948
+ * existing image ref verbatim; `[4]` skips (the machine is created imageless and
1949
+ * pinned later). The pure list keeps the menu wording testable; cli.ts renders
1950
+ * it, runs `podman build`, and writes the machine.
1951
+ */
1952
+ export type InitImageChoice = 'basic' | 'webveil' | 'existing' | 'skip';
1953
+
1954
+ /** One rendered image-menu entry: its choice tag + the human label. */
1955
+ export interface InitImageMenuEntry {
1956
+ choice: InitImageChoice;
1957
+ label: string;
1958
+ }
1959
+
1960
+ /**
1961
+ * PURE: the ordered image-menu entries `init` shows. `[1]` basic pi
1962
+ * (Dockerfile.pi), `[2]` pi + webveil/SearXNG (examples/Dockerfile.pi-webveil),
1963
+ * `[3]` an existing image ref, `[4]` skip. A single source so the prompt and its
1964
+ * test agree on the order + wording.
1965
+ */
1966
+ export function initImageMenu(): InitImageMenuEntry[] {
1967
+ return [
1968
+ {choice: 'basic', label: 'basic pi (build the shipped Dockerfile.pi)'},
1969
+ {
1970
+ choice: 'webveil',
1971
+ label:
1972
+ 'pi + webveil/SearXNG (build the shipped examples/Dockerfile.pi-webveil)',
1973
+ },
1974
+ {choice: 'existing', label: 'an existing image ref (I already have one)'},
1975
+ {
1976
+ choice: 'skip',
1977
+ label: 'skip (create the machine imageless; pin it later)',
1978
+ },
1979
+ ];
1980
+ }
1981
+
1982
+ /**
1983
+ * PURE: build the `config.json` body `init` writes, keeping only the non-empty
1984
+ * fields (a skipped image / llm is simply omitted, never written as ""). Emits
1985
+ * pretty-printed JSON (tab indent, trailing newline) matching
1986
+ * serializeMachineJson, so a browsed ~/.anon-pi/config.json reads cleanly. The
1987
+ * proxy is REQUIRED (init only reaches here after a verified proxy), so it is
1988
+ * always present; llm / defaultMachine / projects are included when set.
1989
+ */
1990
+ export function serializeConfigJson(config: AnonPiConfig): string {
1991
+ const out: AnonPiConfig = {};
1992
+ const proxy = nonEmpty(config.proxy);
1993
+ if (proxy !== undefined) out.proxy = proxy;
1994
+ const llm = nonEmpty(config.llm);
1995
+ if (llm !== undefined) out.llm = llm;
1996
+ const defaultMachine = nonEmpty(config.defaultMachine);
1997
+ if (defaultMachine !== undefined) out.defaultMachine = defaultMachine;
1998
+ const projects = nonEmpty(config.projects);
1999
+ if (projects !== undefined) out.projects = projects;
2000
+ return JSON.stringify(out, null, '\t') + '\n';
2001
+ }
2002
+
2003
+ /**
2004
+ * Absolute path to the Dockerfile.pi that ships with anon-pi, resolved from this
2005
+ * module's location (package root, one level up from dist/anon-pi.js), or
2006
+ * undefined if it cannot be found. Used only to make the missing-image error's
2007
+ * build command concrete.
2008
+ */
2009
+ export function shippedDockerfilePath(): string | undefined {
2010
+ return shippedFile('Dockerfile.pi');
2011
+ }
2012
+
2013
+ /**
2014
+ * Absolute path to the fuller pi-webveil + SearXNG example that ships with
2015
+ * anon-pi (examples/Dockerfile.pi-webveil), or undefined if not found.
2016
+ */
2017
+ export function shippedWebveilDockerfilePath(): string | undefined {
2018
+ return shippedFile(join('examples', 'Dockerfile.pi-webveil'));
2019
+ }
2020
+
2021
+ /**
2022
+ * Resolve a file shipped in the package root, from this module's location
2023
+ * (package root is one level up from dist/anon-pi.js). Returns undefined if it
2024
+ * cannot be found or import.meta.url is unavailable.
2025
+ */
2026
+ function shippedFile(rel: string): string | undefined {
2027
+ try {
2028
+ const here = dirname(fileURLToPath(import.meta.url));
2029
+ for (const p of [join(here, '..', rel), join(here, rel)]) {
2030
+ if (existsSync(p)) return p;
465
2031
  }
466
2032
  } catch {
467
2033
  // import.meta.url unavailable (e.g. some test bundlers): fall through.
@@ -469,6 +2035,167 @@ function shippedFile(rel: string): string | undefined {
469
2035
  return undefined;
470
2036
  }
471
2037
 
2038
+ // --- The `machine {create,list,set-image,rm}` verbs (pure parts) -------------
2039
+ //
2040
+ // Machines are first-class: an image + a persistent host home
2041
+ // (machines/<M>/{machine.json,home/}). These verbs manage them. The pure module
2042
+ // owns the argv parse (a testable `machine <verb> …` grammar), the machine.json
2043
+ // serialisation, and the set-image compatibility WARNING wording; the CLI does
2044
+ // the fs (mkdir/write/rm), the list read, and the rm confirm/`--yes`/non-TTY
2045
+ // discipline. Dispatch stays thin; every decision that CAN be pure IS.
2046
+
2047
+ /**
2048
+ * A parsed `machine <verb> …` command. A discriminated union so the CLI
2049
+ * dispatches on `verb` with the already-validated fields:
2050
+ * - `create <name> [--image <ref>]`: name validated; image optional here (the
2051
+ * CLI prompts for it when absent, on a TTY).
2052
+ * - `list`: no args.
2053
+ * - `set-image <name> <ref>`: name validated; the new image ref (non-empty).
2054
+ * - `rm <name> [--yes]`: name validated; `yes` skips the confirm (the CLI
2055
+ * still enforces the non-TTY abort when `yes` is false).
2056
+ */
2057
+ export type MachineCommand =
2058
+ | {verb: 'create'; name: string; image?: string}
2059
+ | {verb: 'list'}
2060
+ | {verb: 'set-image'; name: string; image: string}
2061
+ | {verb: 'rm'; name: string; yes: boolean};
2062
+
2063
+ /**
2064
+ * PURE: parse the tokens AFTER `machine` into a MachineCommand. Validates the
2065
+ * machine name via validateName (the reserved-name / traversal guard) so the CLI
2066
+ * only ever joins a safe segment under the machines dir. Throws AnonPiError
2067
+ * (printed verbatim, exit 1) for an unknown/missing verb, a missing or extra
2068
+ * positional, an unknown flag, or a bad name.
2069
+ *
2070
+ * The grammar is deliberately small and flag-light (mirrors the launch grammar's
2071
+ * `--yes` / `--image` shape): `--image <ref>` on create, `--yes` on rm; no other
2072
+ * flags. This keeps `machine` a thin, predictable dispatch surface.
2073
+ */
2074
+ export function parseMachineArgs(args: readonly string[]): MachineCommand {
2075
+ const fail = (msg: string): never => {
2076
+ throw new AnonPiError(
2077
+ `anon-pi: ${msg}\nRun \`anon-pi machine --help\` or \`anon-pi --help\`.`,
2078
+ );
2079
+ };
2080
+
2081
+ const verb = args[0];
2082
+ if (verb === undefined) {
2083
+ fail('`machine` needs a subcommand: create | list | set-image | rm');
2084
+ }
2085
+
2086
+ const rest = args.slice(1);
2087
+
2088
+ if (verb === 'list') {
2089
+ if (rest.length > 0)
2090
+ fail(`machine list takes no arguments, got: ${rest.join(' ')}`);
2091
+ return {verb: 'list'};
2092
+ }
2093
+
2094
+ if (verb === 'create') {
2095
+ let name: string | undefined;
2096
+ let image: string | undefined;
2097
+ for (let i = 0; i < rest.length; i++) {
2098
+ const a = rest[i];
2099
+ if (a === '--image') {
2100
+ const v = rest[++i];
2101
+ if (v === undefined) fail('--image needs an image ref');
2102
+ image = v as string;
2103
+ continue;
2104
+ }
2105
+ if (a.startsWith('-')) fail(`unknown option: ${a}`);
2106
+ if (name !== undefined)
2107
+ fail(`machine create takes one name, got extra: ${a}`);
2108
+ name = validateName(a, 'machine');
2109
+ }
2110
+ if (name === undefined) fail('machine create needs a <name>');
2111
+ return {verb: 'create', name: name as string, image: nonEmpty(image)};
2112
+ }
2113
+
2114
+ if (verb === 'set-image') {
2115
+ let name: string | undefined;
2116
+ let image: string | undefined;
2117
+ for (const a of rest) {
2118
+ if (a.startsWith('-')) fail(`unknown option: ${a}`);
2119
+ if (name === undefined) {
2120
+ name = validateName(a, 'machine');
2121
+ } else if (image === undefined) {
2122
+ image = a;
2123
+ } else {
2124
+ fail(`machine set-image takes <name> <ref>, got extra: ${a}`);
2125
+ }
2126
+ }
2127
+ if (name === undefined)
2128
+ fail('machine set-image needs a <name> and an <image-ref>');
2129
+ if (nonEmpty(image) === undefined)
2130
+ fail('machine set-image needs an <image-ref>');
2131
+ return {
2132
+ verb: 'set-image',
2133
+ name: name as string,
2134
+ image: (image as string).trim(),
2135
+ };
2136
+ }
2137
+
2138
+ if (verb === 'rm') {
2139
+ let name: string | undefined;
2140
+ let yes = false;
2141
+ for (const a of rest) {
2142
+ if (a === '--yes' || a === '-y') {
2143
+ yes = true;
2144
+ continue;
2145
+ }
2146
+ if (a.startsWith('-')) fail(`unknown option: ${a}`);
2147
+ if (name !== undefined)
2148
+ fail(`machine rm takes one name, got extra: ${a}`);
2149
+ name = validateName(a, 'machine');
2150
+ }
2151
+ if (name === undefined) fail('machine rm needs a <name>');
2152
+ return {verb: 'rm', name: name as string, yes};
2153
+ }
2154
+
2155
+ return fail(
2156
+ `unknown machine subcommand: ${verb} (create | list | set-image | rm)`,
2157
+ );
2158
+ }
2159
+
2160
+ /**
2161
+ * PURE: the JSON body a machine.json carries, given the pinned image (and an
2162
+ * optional per-machine projects override, preserved on a re-pin). A single
2163
+ * source so create + set-image write byte-identical, pretty-printed JSON (tab
2164
+ * indent, trailing newline) that reads cleanly when the user browses
2165
+ * ~/.anon-pi/machines/<M>/machine.json.
2166
+ */
2167
+ export function serializeMachineJson(config: MachineConfig): string {
2168
+ const out: MachineConfig = {};
2169
+ if (nonEmpty(config.image) !== undefined)
2170
+ out.image = (config.image as string).trim();
2171
+ if (nonEmpty(config.projects) !== undefined)
2172
+ out.projects = (config.projects as string).trim();
2173
+ return JSON.stringify(out, null, '\t') + '\n';
2174
+ }
2175
+
2176
+ /**
2177
+ * PURE: the compatibility WARNING `machine set-image` prints after re-pinning
2178
+ * the image. Re-pinning does NOT reseed or touch the home: the home's pi
2179
+ * extensions / downloaded bin were built against the OLD image, so a mismatched
2180
+ * new image may misbehave. The message tells the user the two remedies (re-run
2181
+ * `pi install` inside the machine, or delete the home to reseed) WITHOUT doing
2182
+ * either automatically. See the ## Decisions note (set-image warning wording).
2183
+ */
2184
+ export function setImageWarning(
2185
+ name: string,
2186
+ oldImage: string | undefined,
2187
+ newImage: string,
2188
+ ): string {
2189
+ const from = oldImage === undefined ? '(none)' : oldImage;
2190
+ return (
2191
+ `anon-pi: re-pinned machine ${JSON.stringify(name)} image ${from} -> ${newImage}.\n` +
2192
+ 'WARNING: the home was NOT reseeded. Its pi extensions and downloaded tools\n' +
2193
+ 'were built for the old image; if they misbehave on the new one, re-run\n' +
2194
+ '`pi install` inside the machine, or delete + reseed the home with\n' +
2195
+ `\`anon-pi --delete-home ${name}\` (then relaunch to seed fresh).`
2196
+ );
2197
+ }
2198
+
472
2199
  /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
473
2200
  export function envFromProcess(
474
2201
  penv: Record<string, string | undefined>,
@@ -477,77 +2204,56 @@ export function envFromProcess(
477
2204
  home: penv.HOME ?? homedir(),
478
2205
  proxy: penv.ANON_PI_PROXY,
479
2206
  anonPiHome: penv.ANON_PI_HOME,
480
- configSeed: penv.ANON_PI_CONFIG,
2207
+ projects: penv.ANON_PI_PROJECTS,
481
2208
  image: penv.ANON_PI_IMAGE,
482
2209
  llmDirect: penv.ANON_PI_LLM,
483
2210
  xdgConfigHome: penv.XDG_CONFIG_HOME,
2211
+ piAgentDir: penv.PI_CODING_AGENT_DIR,
484
2212
  dockerfilePath: shippedDockerfilePath(),
485
2213
  webveilDockerfilePath: shippedWebveilDockerfilePath(),
486
- sourceModels: penv.ANON_PI_SOURCE_MODELS,
487
- piAgentDir: penv.PI_CODING_AGENT_DIR,
488
- ephemeral: isTruthy(penv.ANON_PI_EPHEMERAL),
489
2214
  };
490
2215
  }
491
2216
 
492
- /** Whether an env-var string is set to a truthy value (1/true/yes, any case). */
493
- function isTruthy(v: string | undefined): boolean {
494
- if (!v) return false;
495
- const s = v.trim().toLowerCase();
496
- return s === '1' || s === 'true' || s === 'yes' || s === 'on';
497
- }
498
-
499
2217
  /** The --help text (kept here so it is covered by the same module). */
500
- export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
2218
+ export const HELP = `anon-pi - run pi on anonymized, jailed machines (netcage: forced egress + one direct local model)
501
2219
 
502
2220
  USAGE
503
- anon-pi [WORKDIR] launch pi jailed, working in WORKDIR (default: cwd)
504
- anon-pi import seed models.json from your local model
2221
+ anon-pi MENU: pick a project (pi), a shell, or a new project
2222
+ anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
2223
+ anon-pi <project> <pi-args…> forward args to pi (headless/one-shot; no TTY needed)
2224
+ anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
2225
+ anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
2226
+ anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
2227
+ anon-pi init onboard: verify your proxy, capture your local model, pick an image
2228
+ anon-pi machine … manage machines (create / list / set-image / rm)
2229
+ anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
2230
+ anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
2231
+
2232
+ <project> a folder under the projects root (mounted at ${CONTAINER_PROJECTS_ROOT}; pi's cwd). \`.\` means
2233
+ the root itself (a scratch pi at ${CONTAINER_PROJECTS_ROOT}, ${CONTAINER_MOUNT_ROOT} for --mount, or ~).
505
2234
 
506
- WORKDIR the host folder pi works in (mounted at ${CONTAINER_WORKDIR}; pi's cwd). Files pi
507
- writes there land on the host.
2235
+ [--rm] throwaway container this run (the DEFAULT; deleted on exit).
2236
+ [--keep] leave the container KEPT so its filesystem survives (apt install,
2237
+ quit, re-enter). anon-pi finds it by netcage's managed label and
2238
+ \`netcage start\`s it on re-entry.
508
2239
 
509
2240
  WHAT IT DOES
510
- Runs pi inside netcage with all web/DNS egress forced through the socks5h
511
- proxy (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM).
512
-
513
- STATEFUL by default: a persistent per-workdir home
514
- (<ANON_PI_HOME>/state/<workdir>/agent) is mounted at the container's
515
- ~/.pi/agent, so your conversations, history, settings (model choice), and any
516
- extensions you \`pi install\` persist across launches. Re-running in the same
517
- folder resumes it. On a FRESH home, the image's staged defaults (extensions,
518
- trust) and your imported models.json are seeded in once; after that pi owns the
519
- home and nothing is overwritten. Requires \`netcage\`.
520
-
521
- --ephemeral (or ANON_PI_EPHEMERAL=1): mount NO writable state; pi writes to the
522
- container's own --rm layer, gone on exit. Nothing writable touches the host,
523
- no cleanup, no leftover-on-crash.
524
-
525
- --fresh: delete this workdir's persistent state home first, so the (possibly
526
- rebuilt) image's defaults + your imported models.json are re-seeded. Use it
527
- after rebuilding your image to pick up new extensions/config.
528
-
529
- import
530
- Reads your host ~/.pi/agent/models.json, picks the provider whose baseUrl
531
- serves ANON_PI_LLM, and writes JUST that provider to the canonical seed
532
- (<ANON_PI_CONFIG>/models.json). No other provider's API keys, no sessions, no
533
- identity. It SEEDS a fresh home; models you later add inside pi persist and are
534
- never clobbered. Re-run with --force to overwrite the canonical seed.
2241
+ Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy
2242
+ (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE
2243
+ is an image + a persistent HOST home (bind-mounted at ${CONTAINER_HOME_ROOT}) holding your pi
2244
+ config, extensions, and conversations; the container is disposable, so \`--rm\`
2245
+ loses nothing. Files (projects) are global by default; conversations are
2246
+ per-machine. On a FRESH machine home the image's staged defaults + your
2247
+ models.json are seeded in once; after that pi owns the home. Requires \`netcage\`.
535
2248
 
536
2249
  ENVIRONMENT
537
- ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
538
- Running anon-pi without it prints a ready-to-build
539
- Dockerfile.pi recipe; see the README (Providing a pi image).
540
- ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
541
2250
  ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).
542
2251
  No default: the proxy is what anonymizes, so it is never guessed.
543
- ANON_PI_EPHEMERAL set to 1 for a throwaway (non-persistent) session
544
- ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
545
- ANON_PI_CONFIG canonical seed dir holding models.json (default <ANON_PI_HOME>/agent)
546
- ANON_PI_SOURCE_MODELS (import) host models.json to read (default ~/.pi/agent/models.json)
547
-
548
- RESET A SESSION
549
- anon-pi --fresh [WORKDIR] drop the session home and re-seed on this launch.
550
- Or delete it by hand: rm -rf <ANON_PI_HOME>/state/<workdir-slug>/agent
2252
+ ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
2253
+ ANON_PI_IMAGE image with \`pi\` on PATH, used when a machine has no image set.
2254
+ No image yet? See the README (Providing a pi image).
2255
+ ANON_PI_HOME anon-pi workspace dir (default ~/.anon-pi; NOT under ~/.config)
2256
+ ANON_PI_PROJECTS projects root override (host dir mounted at ${CONTAINER_PROJECTS_ROOT})
551
2257
 
552
2258
  PLATFORM
553
2259
  Linux only (via netcage's netns/nft jail). On macOS/Windows it works only