anon-pi 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anon-pi.ts CHANGED
@@ -18,30 +18,78 @@
18
18
  // - Session identity = the ABSOLUTE workdir path (hashed). Same folder resumes
19
19
  // the same session config+state; reseed is manual (delete the session dir).
20
20
 
21
- import {createHash} from 'node:crypto';
21
+ import {existsSync} from 'node:fs';
22
22
  import {homedir} from 'node:os';
23
- import {isAbsolute, join, resolve} from 'node:path';
23
+ import {dirname, isAbsolute, join, resolve} from 'node:path';
24
+ import {fileURLToPath} from 'node:url';
24
25
 
25
26
  /** The container path the workdir is mounted at (pi's cwd). */
26
27
  export const CONTAINER_WORKDIR = '/work';
27
28
 
28
29
  /**
29
- * The DEFAULT container path the seeded pi config is mounted at (the pi global).
30
- * Absolute and image-independent: both podman (the -v target) and pi (the
31
- * PI_CODING_AGENT_DIR env) agree on it with no home-resolution guessing. A user
32
- * who knows their image's home can override it (ANON_PI_AGENT_MOUNT) to the
33
- * standard `~/.pi/agent` location, e.g. /root/.pi/agent for a root image.
30
+ * The container path pi uses as its config+state home. anon-pi mounts a
31
+ * PERSISTENT host dir here (Model B), so everything pi writes, sessions,
32
+ * 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.
34
35
  */
35
- export const DEFAULT_CONTAINER_AGENT_DIR = '/opt/pi-agent';
36
+ export const CONTAINER_AGENT_DIR = '/root/.pi/agent';
36
37
 
37
- /** The pi env var that overrides its config dir (see pi config.ts getAgentDir). */
38
- export const PI_AGENT_DIR_ENV = 'PI_CODING_AGENT_DIR';
38
+ /**
39
+ * Where the image STAGES its first-launch defaults (extensions + trust.json).
40
+ * NOT the agent dir, so it never conflicts with the persistent mount. The
41
+ * entrypoint promotes these into the mounted agent dir only when the home is
42
+ * FRESH (Model C seed-if-fresh).
43
+ */
44
+ export const CONTAINER_STAGE_DIR = '/opt/anon-pi-seed/agent';
45
+
46
+ /**
47
+ * Where anon-pi mounts the canonical models.json (from `import`) read-only, so
48
+ * the first-launch seed can copy it into the fresh home alongside the image's
49
+ * staged defaults. Read-only: the container never writes back to the host seed.
50
+ */
51
+ export const CONTAINER_MODELS_SEED = '/anon-pi-seed/models.json';
52
+
53
+ /** Marker file written into the agent dir after seeding; holds the seed version. */
54
+ export const SEED_MARKER = '.anon-pi-seed';
55
+
56
+ /** The single file the host-side seed carries: pi's model/provider registry. */
57
+ export const MODELS_FILE = 'models.json';
58
+
59
+ /**
60
+ * containerRunCmd builds the container command: on a FRESH home (no seed
61
+ * marker), promote the image's staged defaults + the mounted models.json into
62
+ * the persistent agent dir and stamp the marker; then exec pi. On a seeded home
63
+ * it does nothing but exec pi, so pi's persisted state (incl. anything you
64
+ * `pi install`ed or models pi added) is used as-is and NEVER clobbered.
65
+ *
66
+ * seedVersion is written into the marker so a future image can re-seed changed
67
+ * defaults on a version bump; v1 only seeds when the marker is absent.
68
+ */
69
+ export function containerRunCmd(seedVersion: string): string {
70
+ const agent = CONTAINER_AGENT_DIR;
71
+ const marker = `${agent}/${SEED_MARKER}`;
72
+ return (
73
+ `mkdir -p "${agent}" && ` +
74
+ `if [ ! -f "${marker}" ]; then ` +
75
+ // image-staged defaults (extensions, trust.json), if the image provides them
76
+ `{ [ -d "${CONTAINER_STAGE_DIR}" ] && cp -a "${CONTAINER_STAGE_DIR}/." "${agent}/" || true; } && ` +
77
+ // the host-imported models.json, if mounted
78
+ `{ [ -f "${CONTAINER_MODELS_SEED}" ] && cp "${CONTAINER_MODELS_SEED}" "${agent}/${MODELS_FILE}" || true; } && ` +
79
+ `printf '%s\\n' "${seedVersion}" > "${marker}"; ` +
80
+ `fi && ` +
81
+ `exec pi`
82
+ );
83
+ }
84
+
85
+ /** The seed version anon-pi stamps when it seeds a fresh home (bump to re-seed). */
86
+ export const SEED_VERSION = '1';
39
87
 
40
88
  /** Inputs resolved from the environment + argv, injected so this stays pure. */
41
89
  export interface AnonPiEnv {
42
90
  /** $HOME (or an override) used to derive default paths. */
43
91
  home: string;
44
- /** socks5h proxy URL. Default socks5h://127.0.0.1:9050. */
92
+ /** socks5h proxy URL. REQUIRED (no default: the proxy is what anonymizes). */
45
93
  proxy?: string;
46
94
  /** The anon-pi home dir. Default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi. */
47
95
  anonPiHome?: string;
@@ -54,63 +102,49 @@ export interface AnonPiEnv {
54
102
  /** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
55
103
  xdgConfigHome?: string;
56
104
  /**
57
- * The ABSOLUTE container path to mount the seeded config at (and point pi's
58
- * PI_CODING_AGENT_DIR at). Default /opt/pi-agent. Set it to your image's real
59
- * `~/.pi/agent` (e.g. /root/.pi/agent) if you want the standard location.
60
- * MUST be absolute: podman does not expand `~`, so a `~`-relative value would
61
- * be mounted literally. Rejected if it starts with `~` or is not absolute.
105
+ * Absolute path to the Dockerfile.pi that ships with anon-pi, used only to
106
+ * make the missing-image error's build command concrete. cli.ts resolves it
107
+ * from import.meta.url; when absent the message falls back to a bare
108
+ * `Dockerfile.pi`.
62
109
  */
63
- agentMount?: string;
110
+ dockerfilePath?: string;
111
+ /**
112
+ * Absolute path to the shipped examples/Dockerfile.pi-webveil (pi + pi-webveil
113
+ * + SearXNG), used to make the missing-image error mention the fuller build.
114
+ */
115
+ 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
+ /** The seed version anon-pi stamps into a fresh home. Default SEED_VERSION. */
123
+ seedVersion?: string;
64
124
  }
65
125
 
66
126
  /** The fully-resolved run plan cli.ts executes. */
67
127
  export interface RunPlan {
68
128
  /** Absolute workdir on the host (mounted at /work). */
69
129
  workdir: string;
70
- /** The per-session seeded config dir on the host (mounted as the pi global). */
71
- sessionAgentDir: string;
72
- /** The canonical seed dir to copy FROM (read-only by convention). */
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. */
73
137
  configSeed: string;
74
- /** The absolute container path the session config is mounted at (== pi's config dir). */
75
- agentMount: string;
76
- /** True iff the session dir does not exist yet and must be seeded from configSeed. */
77
- needsSeed: boolean;
138
+ /** True when this workdir has no state yet (fresh home; the seed will run). */
139
+ fresh: boolean;
78
140
  /** The argv passed to `netcage` (after the `netcage` program name). */
79
141
  netcageArgs: string[];
80
142
  }
81
143
 
82
- const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
83
-
84
144
  /** A user-facing error whose message is meant to be printed verbatim (no stack). */
85
145
  export class AnonPiError extends Error {}
86
146
 
87
- /**
88
- * Resolve the container agent-mount path: ANON_PI_AGENT_MOUNT or the default.
89
- * It MUST be an absolute container path, because it is BOTH the podman `-v`
90
- * target AND pi's PI_CODING_AGENT_DIR, and podman (unlike pi) does not expand
91
- * `~`. A `~`-relative or relative value is rejected loudly rather than silently
92
- * mounted at a literal `~` directory or a cwd-relative path.
93
- */
94
- export function resolveAgentMount(env: AnonPiEnv): string {
95
- const raw =
96
- env.agentMount && env.agentMount.trim() !== ''
97
- ? env.agentMount.trim()
98
- : DEFAULT_CONTAINER_AGENT_DIR;
99
- if (raw.startsWith('~')) {
100
- throw new AnonPiError(
101
- `anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\`. ` +
102
- 'podman does not expand `~`; use the concrete home, e.g. /root/.pi/agent.',
103
- );
104
- }
105
- if (!raw.startsWith('/')) {
106
- throw new AnonPiError(
107
- `anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\` (it is both the mount target and pi's config dir).`,
108
- );
109
- }
110
- return raw;
111
- }
112
-
113
- /** Resolve the anon-pi home dir (holds the canonical seed + per-session state). */
147
+ /** Resolve the anon-pi home dir (holds the seed). */
114
148
  export function resolveAnonPiHome(env: AnonPiEnv): string {
115
149
  if (env.anonPiHome) return resolve(env.anonPiHome);
116
150
  const base =
@@ -120,64 +154,193 @@ export function resolveAnonPiHome(env: AnonPiEnv): string {
120
154
  return join(base, 'anon-pi');
121
155
  }
122
156
 
123
- /** The canonical seed dir (copied FROM, never mounted). */
157
+ /**
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).
161
+ */
124
162
  export function resolveConfigSeed(env: AnonPiEnv): string {
125
163
  if (env.configSeed) return resolve(env.configSeed);
126
164
  return join(resolveAnonPiHome(env), 'agent');
127
165
  }
128
166
 
129
167
  /**
130
- * Session id = a stable short hash of the ABSOLUTE workdir path, so re-running
131
- * anon-pi on the same folder resumes the same session config+state, and moving
132
- * the folder starts a new session (documented, accepted).
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--
133
172
  */
134
- export function sessionId(absWorkdir: string): string {
135
- return createHash('sha256').update(absWorkdir).digest('hex').slice(0, 16);
173
+ export function pathSlug(absPath: string): string {
174
+ return `--${absPath.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
136
175
  }
137
176
 
138
- /** The per-session seeded config dir on the host for a given workdir. */
139
- export function sessionAgentDir(env: AnonPiEnv, absWorkdir: string): string {
140
- return join(
141
- resolveAnonPiHome(env),
142
- 'sessions',
143
- sessionId(absWorkdir),
144
- 'agent',
145
- );
177
+ /**
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
181
+ */
182
+ export function stateAgentDir(env: AnonPiEnv, absWorkdir: string): string {
183
+ return join(resolveAnonPiHome(env), 'state', pathSlug(absWorkdir), 'agent');
184
+ }
185
+
186
+ /**
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.
190
+ */
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();
198
+ }
199
+
200
+ /**
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.
203
+ */
204
+ export interface PiProvider {
205
+ baseUrl?: string;
206
+ apiKey?: string;
207
+ api?: string;
208
+ models?: unknown[];
209
+ [k: string]: unknown;
210
+ }
211
+
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
+ }
217
+
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;
226
+ }
227
+
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']);
230
+
231
+ /**
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).
237
+ */
238
+ export function pickProviderForLlm(
239
+ hostModels: PiModelsFile,
240
+ llmDirect: string,
241
+ ): ImportResult {
242
+ const providers = hostModels.providers ?? {};
243
+ const want = hostPortKey(llmDirect);
244
+
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);
249
+ }
250
+
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');
256
+ 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.',
262
+ );
263
+ }
264
+
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);
269
+
270
+ return {
271
+ name,
272
+ models: {providers: {[name]: provider}},
273
+ apiKeyLooksReal,
274
+ };
275
+ }
276
+
277
+ /**
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).
281
+ */
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);
146
291
  }
147
292
 
148
293
  /**
149
294
  * Build the run plan from the environment + the (optional) workdir arg. PURE: it
150
- * resolves paths and composes the netcage argv, and reports whether a seed copy
151
- * is needed, but performs NO filesystem writes or spawns. It THROWS AnonPiError
152
- * for the two hard preconditions (missing image, missing llm) so the required
153
- * inputs fail loud; the missing-SEED check is left to cli.ts (it needs a real
154
- * `existsSync`), but `needsSeed` is derived from the injected `seedExists`.
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).
297
+ *
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.
303
+ *
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).
307
+ *
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.)
155
313
  */
156
314
  export function buildRunPlan(
157
315
  env: AnonPiEnv,
158
316
  workdirArg: string | undefined,
159
- seedExists: (dir: string) => boolean,
160
- sessionExists: (dir: string) => boolean,
317
+ modelsSeedExists: (modelsJsonPath: string) => boolean,
318
+ stateExists: (stateDir: string) => boolean,
161
319
  ): RunPlan {
162
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';
163
328
  throw new AnonPiError(
164
329
  'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
165
330
  '\n' +
166
- 'No such image yet? Build a small one from the upstream-documented recipe\n' +
167
- '(it installs the official @earendil-works/pi-coding-agent npm package):\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' +
168
339
  '\n' +
169
- " cat > Dockerfile.pi <<'EOF'\n" +
170
- ' FROM node:24-bookworm-slim\n' +
171
- ' RUN apt-get update && apt-get install -y --no-install-recommends \\\n' +
172
- ' bash ca-certificates git ripgrep && rm -rf /var/lib/apt/lists/*\n' +
173
- ' RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent\n' +
174
- ' WORKDIR /work\n' +
175
- ' EOF\n' +
176
- ' podman build -t localhost/anon-pi-pi:latest -f Dockerfile.pi .\n' +
177
- ' export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\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' +
178
342
  '\n' +
179
- 'A ready Dockerfile.pi also ships with this package. See the README\n' +
180
- '(Providing a pi image) for details and a community-image note.',
343
+ 'See the README (Providing a pi image) for details and a community-image note.',
181
344
  );
182
345
  }
183
346
  if (!env.llmDirect || env.llmDirect.trim() === '') {
@@ -185,6 +348,30 @@ export function buildRunPlan(
185
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.',
186
349
  );
187
350
  }
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.
356
+ 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).',
373
+ );
374
+ }
188
375
 
189
376
  const home = env.home;
190
377
  if (!home || home.trim() === '') {
@@ -195,52 +382,93 @@ export function buildRunPlan(
195
382
  workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
196
383
  const workdir = isAbsolute(raw) ? raw : resolve(raw);
197
384
 
198
- const configSeed = resolveConfigSeed(env);
199
- if (!seedExists(configSeed)) {
200
- throw new AnonPiError(
201
- `anon-pi: canonical config not found at ${configSeed}.\n` +
202
- 'anon-pi never populates it for you. Create it yourself with the pi config you want\n' +
203
- '(anon accounts, chosen models/skills, and a trust.json that trusts /work), e.g.:\n' +
204
- ` mkdir -p ${configSeed}\n` +
205
- ` cp -a ~/.pi/agent/. ${configSeed}/ # then remove any identity you do not want anonymized\n` +
206
- 'See the README (Populating the seed) for the trust.json requirement.',
207
- );
208
- }
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);
209
391
 
210
- const sessionDir = sessionAgentDir(env, workdir);
211
- const needsSeed = !sessionExists(sessionDir);
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);
212
396
 
213
- const agentMount = resolveAgentMount(env);
214
- const proxy =
215
- env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
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;
216
405
 
217
406
  const netcageArgs = [
218
407
  'run',
219
408
  '--proxy',
220
409
  proxy,
221
410
  '--allow-direct',
222
- env.llmDirect,
411
+ directTarget,
223
412
  '-it',
224
413
  '-v',
225
414
  workdir, // netcage defaults a target-less -v to /work and cwd to /work
226
- '-v',
227
- `${sessionDir}:${agentMount}`,
228
- '-e',
229
- `${PI_AGENT_DIR_ENV}=${agentMount}`,
230
- env.image,
231
- 'pi',
232
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) {
424
+ netcageArgs.push('-v', `${modelsSeed}:${CONTAINER_MODELS_SEED}:ro`);
425
+ }
426
+ netcageArgs.push(env.image, 'sh', '-c', containerRunCmd(seedVersion));
233
427
 
234
428
  return {
235
429
  workdir,
236
- sessionAgentDir: sessionDir,
237
- configSeed,
238
- agentMount,
239
- needsSeed,
430
+ stateDir,
431
+ configSeed: haveModelsSeed ? modelsSeed : '',
432
+ fresh,
240
433
  netcageArgs,
241
434
  };
242
435
  }
243
436
 
437
+ /**
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.
442
+ */
443
+ export function shippedDockerfilePath(): string | undefined {
444
+ return shippedFile('Dockerfile.pi');
445
+ }
446
+
447
+ /**
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.
450
+ */
451
+ export function shippedWebveilDockerfilePath(): string | undefined {
452
+ return shippedFile(join('examples', 'Dockerfile.pi-webveil'));
453
+ }
454
+
455
+ /**
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.
459
+ */
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;
465
+ }
466
+ } catch {
467
+ // import.meta.url unavailable (e.g. some test bundlers): fall through.
468
+ }
469
+ return undefined;
470
+ }
471
+
244
472
  /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
245
473
  export function envFromProcess(
246
474
  penv: Record<string, string | undefined>,
@@ -253,41 +481,69 @@ export function envFromProcess(
253
481
  image: penv.ANON_PI_IMAGE,
254
482
  llmDirect: penv.ANON_PI_LLM,
255
483
  xdgConfigHome: penv.XDG_CONFIG_HOME,
256
- agentMount: penv.ANON_PI_AGENT_MOUNT,
484
+ dockerfilePath: shippedDockerfilePath(),
485
+ webveilDockerfilePath: shippedWebveilDockerfilePath(),
486
+ sourceModels: penv.ANON_PI_SOURCE_MODELS,
487
+ piAgentDir: penv.PI_CODING_AGENT_DIR,
488
+ ephemeral: isTruthy(penv.ANON_PI_EPHEMERAL),
257
489
  };
258
490
  }
259
491
 
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
+
260
499
  /** The --help text (kept here so it is covered by the same module). */
261
500
  export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
262
501
 
263
502
  USAGE
264
- anon-pi [WORKDIR]
503
+ anon-pi [WORKDIR] launch pi jailed, working in WORKDIR (default: cwd)
504
+ anon-pi import seed models.json from your local model
265
505
 
266
- WORKDIR the host folder pi works in (mounted at /work). Defaults to the
267
- current directory. The session config+state is keyed to this folder.
506
+ WORKDIR the host folder pi works in (mounted at ${CONTAINER_WORKDIR}; pi's cwd). Files pi
507
+ writes there land on the host.
268
508
 
269
509
  WHAT IT DOES
270
- Seeds a per-workdir writable copy of your canonical anon-pi config into
271
- ~/.config/anon-pi/sessions/<hash>/agent, mounts it as pi's global config
272
- (${PI_AGENT_DIR_ENV}=<mount>, default ${DEFAULT_CONTAINER_AGENT_DIR}), mounts WORKDIR at
273
- ${CONTAINER_WORKDIR}, opens ONE direct hole to your local model, and runs pi with all other
274
- egress forced through the socks5h proxy, fail-closed. Requires \`netcage\`.
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
+ import
526
+ Reads your host ~/.pi/agent/models.json, picks the provider whose baseUrl
527
+ serves ANON_PI_LLM, and writes JUST that provider to the canonical seed
528
+ (<ANON_PI_CONFIG>/models.json). No other provider's API keys, no sessions, no
529
+ identity. It SEEDS a fresh home; models you later add inside pi persist and are
530
+ never clobbered. Re-run with --force to overwrite the canonical seed.
275
531
 
276
532
  ENVIRONMENT
277
- ANON_PI_IMAGE (required) image with \`pi\` on PATH. No image yet? Running
278
- anon-pi without it prints a ready-to-build Dockerfile.pi
279
- recipe; see the README (Providing a pi image).
533
+ ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
534
+ Running anon-pi without it prints a ready-to-build
535
+ Dockerfile.pi recipe; see the README (Providing a pi image).
280
536
  ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
281
- ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
537
+ ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).
538
+ No default: the proxy is what anonymizes, so it is never guessed.
539
+ ANON_PI_EPHEMERAL set to 1 for a throwaway (non-persistent) session
282
540
  ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
283
- ANON_PI_CONFIG canonical seed dir (default <ANON_PI_HOME>/agent)
284
- ANON_PI_AGENT_MOUNT absolute container path for pi's config (default
285
- ${DEFAULT_CONTAINER_AGENT_DIR}; set to your image's ~/.pi/agent, e.g. /root/.pi/agent)
286
-
287
- RESEED
288
- Reseed is manual: delete the session dir, e.g.
289
- rm -rf ~/.config/anon-pi/sessions/<hash>/agent
290
- and the next run re-seeds it from the canonical config.
541
+ ANON_PI_CONFIG canonical seed dir holding models.json (default <ANON_PI_HOME>/agent)
542
+ ANON_PI_SOURCE_MODELS (import) host models.json to read (default ~/.pi/agent/models.json)
543
+
544
+ RESET A SESSION
545
+ Delete its state home to start fresh (re-seeds next launch):
546
+ rm -rf <ANON_PI_HOME>/state/<workdir-slug>/agent
291
547
 
292
548
  PLATFORM
293
549
  Linux only (via netcage's netns/nft jail). On macOS/Windows it works only