anon-pi 0.2.0 → 0.4.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
@@ -27,34 +27,69 @@ import {fileURLToPath} from 'node:url';
27
27
  export const CONTAINER_WORKDIR = '/work';
28
28
 
29
29
  /**
30
- * Where the seed (just models.json) is mounted read-only in the container. The
31
- * run command copies models.json FROM here INTO the container's own
32
- * ~/.pi/agent, so it LAYERS onto the image's config (extensions/skills the image
33
- * installed survive) instead of replacing it. This is why we mount+copy rather
34
- * than mount-as-agent-dir: mounting over ~/.pi/agent would shadow the image's
35
- * extensions.
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.
36
35
  */
37
- export const CONTAINER_SEED_DIR = '/anon-pi-seed';
36
+ export const CONTAINER_AGENT_DIR = '/root/.pi/agent';
38
37
 
39
38
  /**
40
- * The container command: copy the seeded models.json into pi's own config dir
41
- * (creating it if absent), then exec pi. `$HOME/.pi/agent` is pi's default
42
- * config dir when PI_CODING_AGENT_DIR is unset, i.e. exactly where the image
43
- * installed pi + any extensions, so the copy augments rather than shadows.
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).
44
43
  */
45
- export const CONTAINER_RUN_CMD =
46
- `mkdir -p "$HOME/.pi/agent" && ` +
47
- `cp ${CONTAINER_SEED_DIR}/models.json "$HOME/.pi/agent/models.json" && ` +
48
- `exec pi`;
44
+ export const CONTAINER_STAGE_DIR = '/opt/anon-pi-seed/agent';
49
45
 
50
- /** The single file the seed carries: pi's model/provider registry. */
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. */
51
57
  export const MODELS_FILE = 'models.json';
52
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';
87
+
53
88
  /** Inputs resolved from the environment + argv, injected so this stays pure. */
54
89
  export interface AnonPiEnv {
55
90
  /** $HOME (or an override) used to derive default paths. */
56
91
  home: string;
57
- /** socks5h proxy URL. Default socks5h://127.0.0.1:9050. */
92
+ /** socks5h proxy URL. REQUIRED (no default: the proxy is what anonymizes). */
58
93
  proxy?: string;
59
94
  /** The anon-pi home dir. Default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi. */
60
95
  anonPiHome?: string;
@@ -73,24 +108,39 @@ export interface AnonPiEnv {
73
108
  * `Dockerfile.pi`.
74
109
  */
75
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;
76
116
  /** `import` source models.json override (ANON_PI_SOURCE_MODELS). */
77
117
  sourceModels?: string;
78
118
  /** The host pi agent dir override (PI_CODING_AGENT_DIR), used to find models.json. */
79
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;
80
124
  }
81
125
 
82
126
  /** The fully-resolved run plan cli.ts executes. */
83
127
  export interface RunPlan {
84
128
  /** Absolute workdir on the host (mounted at /work). */
85
129
  workdir: string;
86
- /** The seed dir on the host (holds models.json), mounted read-only at /anon-pi-seed. */
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. */
87
137
  configSeed: string;
138
+ /** True when this workdir has no state yet (fresh home; the seed will run). */
139
+ fresh: boolean;
88
140
  /** The argv passed to `netcage` (after the `netcage` program name). */
89
141
  netcageArgs: string[];
90
142
  }
91
143
 
92
- const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
93
-
94
144
  /** A user-facing error whose message is meant to be printed verbatim (no stack). */
95
145
  export class AnonPiError extends Error {}
96
146
 
@@ -104,12 +154,35 @@ export function resolveAnonPiHome(env: AnonPiEnv): string {
104
154
  return join(base, 'anon-pi');
105
155
  }
106
156
 
107
- /** The seed dir (holds models.json), mounted read-only into the container. */
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
+ */
108
162
  export function resolveConfigSeed(env: AnonPiEnv): string {
109
163
  if (env.configSeed) return resolve(env.configSeed);
110
164
  return join(resolveAnonPiHome(env), 'agent');
111
165
  }
112
166
 
167
+ /**
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--
172
+ */
173
+ export function pathSlug(absPath: string): string {
174
+ return `--${absPath.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
175
+ }
176
+
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
+
113
186
  /**
114
187
  * Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
115
188
  * baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
@@ -220,18 +293,29 @@ export function resolveSourceModelsPath(env: AnonPiEnv): string {
220
293
  /**
221
294
  * Build the run plan from the environment + the (optional) workdir arg. PURE: it
222
295
  * resolves paths and composes the netcage argv, performing NO filesystem writes
223
- * or spawns. It THROWS AnonPiError for the hard preconditions (missing image,
224
- * missing llm, missing seed models.json) so the required inputs fail loud.
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.
225
303
  *
226
- * The seed (models.json) is mounted READ-ONLY at /anon-pi-seed and copied into
227
- * the container's own ~/.pi/agent by the run command, so it LAYERS onto the
228
- * image's config (image-installed extensions/skills survive) rather than
229
- * shadowing it.
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.)
230
313
  */
231
314
  export function buildRunPlan(
232
315
  env: AnonPiEnv,
233
316
  workdirArg: string | undefined,
234
- seedModelsExists: (modelsJsonPath: string) => boolean,
317
+ modelsSeedExists: (modelsJsonPath: string) => boolean,
318
+ stateExists: (stateDir: string) => boolean,
235
319
  ): RunPlan {
236
320
  if (!env.image || env.image.trim() === '') {
237
321
  // dockerfilePath is injected (cli.ts resolves the shipped Dockerfile.pi via
@@ -240,6 +324,7 @@ export function buildRunPlan(
240
324
  // leading spaces into the Dockerfile and break the EOF terminator, so we
241
325
  // point at the shipped file instead of printing a heredoc.
242
326
  const df = env.dockerfilePath ?? 'Dockerfile.pi';
327
+ const wv = env.webveilDockerfilePath ?? 'examples/Dockerfile.pi-webveil';
243
328
  throw new AnonPiError(
244
329
  'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
245
330
  '\n' +
@@ -249,6 +334,12 @@ export function buildRunPlan(
249
334
  `podman build -t localhost/anon-pi-pi:latest -f "${df}" "$(dirname "${df}")"\n` +
250
335
  'export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\n' +
251
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' +
252
343
  'See the README (Providing a pi image) for details and a community-image note.',
253
344
  );
254
345
  }
@@ -257,6 +348,30 @@ export function buildRunPlan(
257
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.',
258
349
  );
259
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
+ }
260
375
 
261
376
  const home = env.home;
262
377
  if (!home || home.trim() === '') {
@@ -267,48 +382,54 @@ export function buildRunPlan(
267
382
  workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
268
383
  const workdir = isAbsolute(raw) ? raw : resolve(raw);
269
384
 
270
- const configSeed = resolveConfigSeed(env);
271
- const modelsJson = join(configSeed, MODELS_FILE);
272
- if (!seedModelsExists(modelsJson)) {
273
- throw new AnonPiError(
274
- `anon-pi: no seed models.json at ${modelsJson}.\n` +
275
- '\n' +
276
- 'anon-pi never populates it for you. Generate it from your local model:\n' +
277
- '\n' +
278
- 'anon-pi import\n' +
279
- '\n' +
280
- '`import` reads your host ~/.pi/agent/models.json, picks the provider that\n' +
281
- 'serves ANON_PI_LLM, and writes just that provider here (no auth for other\n' +
282
- 'providers, no sessions, no identity). See the README (Populating the seed).',
283
- );
284
- }
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);
285
391
 
286
- const proxy =
287
- env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
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;
288
405
 
289
406
  const netcageArgs = [
290
407
  'run',
291
408
  '--proxy',
292
409
  proxy,
293
410
  '--allow-direct',
294
- env.llmDirect,
411
+ directTarget,
295
412
  '-it',
296
413
  '-v',
297
414
  workdir, // netcage defaults a target-less -v to /work and cwd to /work
298
- '-v',
299
- // Mount the seed READ-ONLY at a neutral path; the run command copies
300
- // models.json into the container's own ~/.pi/agent so image extensions
301
- // survive (see CONTAINER_RUN_CMD).
302
- `${configSeed}:${CONTAINER_SEED_DIR}:ro`,
303
- env.image,
304
- 'sh',
305
- '-c',
306
- CONTAINER_RUN_CMD,
307
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));
308
427
 
309
428
  return {
310
429
  workdir,
311
- configSeed,
430
+ stateDir,
431
+ configSeed: haveModelsSeed ? modelsSeed : '',
432
+ fresh,
312
433
  netcageArgs,
313
434
  };
314
435
  }
@@ -320,13 +441,26 @@ export function buildRunPlan(
320
441
  * build command concrete.
321
442
  */
322
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 {
323
461
  try {
324
462
  const here = dirname(fileURLToPath(import.meta.url));
325
- // dist/anon-pi.js -> ../Dockerfile.pi; also try alongside for safety.
326
- for (const p of [
327
- join(here, '..', 'Dockerfile.pi'),
328
- join(here, 'Dockerfile.pi'),
329
- ]) {
463
+ for (const p of [join(here, '..', rel), join(here, rel)]) {
330
464
  if (existsSync(p)) return p;
331
465
  }
332
466
  } catch {
@@ -348,17 +482,26 @@ export function envFromProcess(
348
482
  llmDirect: penv.ANON_PI_LLM,
349
483
  xdgConfigHome: penv.XDG_CONFIG_HOME,
350
484
  dockerfilePath: shippedDockerfilePath(),
485
+ webveilDockerfilePath: shippedWebveilDockerfilePath(),
351
486
  sourceModels: penv.ANON_PI_SOURCE_MODELS,
352
487
  piAgentDir: penv.PI_CODING_AGENT_DIR,
488
+ ephemeral: isTruthy(penv.ANON_PI_EPHEMERAL),
353
489
  };
354
490
  }
355
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
+
356
499
  /** The --help text (kept here so it is covered by the same module). */
357
500
  export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
358
501
 
359
502
  USAGE
360
503
  anon-pi [WORKDIR] launch pi jailed, working in WORKDIR (default: cwd)
361
- anon-pi import write the seed models.json from your local model
504
+ anon-pi import seed models.json from your local model
362
505
 
363
506
  WORKDIR the host folder pi works in (mounted at ${CONTAINER_WORKDIR}; pi's cwd). Files pi
364
507
  writes there land on the host.
@@ -366,28 +509,45 @@ USAGE
366
509
  WHAT IT DOES
367
510
  Runs pi inside netcage with all web/DNS egress forced through the socks5h
368
511
  proxy (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM).
369
- The seed models.json is mounted read-only and COPIED into the container's own
370
- ~/.pi/agent at start, so it layers onto the image's config: extensions and
371
- skills you baked into the image survive. Requires \`netcage\`.
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.
372
528
 
373
529
  import
374
530
  Reads your host ~/.pi/agent/models.json, picks the provider whose baseUrl
375
- serves ANON_PI_LLM, and writes JUST that provider to the seed
531
+ serves ANON_PI_LLM, and writes JUST that provider to the canonical seed
376
532
  (<ANON_PI_CONFIG>/models.json). No other provider's API keys, no sessions, no
377
- identity. Re-run with --force to overwrite an existing seed.
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.
378
535
 
379
536
  ENVIRONMENT
380
537
  ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
381
538
  Running anon-pi without it prints a ready-to-build
382
539
  Dockerfile.pi recipe; see the README (Providing a pi image).
383
540
  ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
384
- ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
541
+ ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).
542
+ 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
385
544
  ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
386
- ANON_PI_CONFIG seed dir holding models.json (default <ANON_PI_HOME>/agent)
545
+ ANON_PI_CONFIG canonical seed dir holding models.json (default <ANON_PI_HOME>/agent)
387
546
  ANON_PI_SOURCE_MODELS (import) host models.json to read (default ~/.pi/agent/models.json)
388
547
 
389
- RESEED
390
- anon-pi import --force regenerates the seed models.json.
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
391
551
 
392
552
  PLATFORM
393
553
  Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
package/src/cli.ts CHANGED
@@ -8,9 +8,15 @@
8
8
  // anon-pi import generate the seed models.json from the host models.json,
9
9
  // carrying only the provider that serves ANON_PI_LLM.
10
10
 
11
- import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
12
18
  import {spawnSync} from 'node:child_process';
13
- import {join} from 'node:path';
19
+ import {isAbsolute, join, resolve} from 'node:path';
14
20
  import {
15
21
  AnonPiError,
16
22
  buildRunPlan,
@@ -20,6 +26,7 @@ import {
20
26
  pickProviderForLlm,
21
27
  resolveConfigSeed,
22
28
  resolveSourceModelsPath,
29
+ stateAgentDir,
23
30
  type PiModelsFile,
24
31
  } from './anon-pi.js';
25
32
 
@@ -41,11 +48,14 @@ function main(argv: string[]): number {
41
48
 
42
49
  // --- anon-pi [WORKDIR] : launch pi jailed -----------------------------------
43
50
  function runLaunch(args: string[]): number {
44
- // The only positional is the optional workdir. Reject stray flags so a typo
45
- // (e.g. --allow-direct) is not silently swallowed: anon-pi owns the netcage
46
- // argv, extra flags are not passed through.
51
+ // One optional positional (the workdir) + the --ephemeral / --fresh flags.
52
+ // Reject other flags so a typo is not silently swallowed: anon-pi owns the
53
+ // netcage argv.
54
+ const known = new Set(['--ephemeral', '--eph', '--fresh']);
55
+ const ephemeralFlag = args.includes('--ephemeral') || args.includes('--eph');
56
+ const freshFlag = args.includes('--fresh');
47
57
  const positionals = args.filter((a) => !a.startsWith('-'));
48
- const flags = args.filter((a) => a.startsWith('-'));
58
+ const flags = args.filter((a) => a.startsWith('-') && !known.has(a));
49
59
  if (flags.length > 0) {
50
60
  process.stderr.write(
51
61
  `anon-pi: unknown option(s): ${flags.join(' ')}\nRun \`anon-pi --help\`.\n`,
@@ -59,11 +69,35 @@ function runLaunch(args: string[]): number {
59
69
  return 2;
60
70
  }
61
71
 
72
+ if (freshFlag && ephemeralFlag) {
73
+ process.stderr.write(
74
+ 'anon-pi: --fresh has no effect with --ephemeral (an ephemeral session is always fresh and never persisted).\nRun `anon-pi --help`.\n',
75
+ );
76
+ return 2;
77
+ }
78
+
62
79
  const env = envFromProcess(process.env);
80
+ if (ephemeralFlag) env.ephemeral = true;
81
+
82
+ // --fresh: delete this workdir's persistent state home BEFORE planning, so the
83
+ // home is fresh and the image's (possibly rebuilt) defaults + models.json are
84
+ // re-seeded on this launch. No-op for --ephemeral (handled above).
85
+ if (freshFlag && !env.ephemeral) {
86
+ const raw =
87
+ positionals[0] && positionals[0].trim() !== ''
88
+ ? positionals[0]
89
+ : process.cwd();
90
+ const wd = isAbsolute(raw) ? raw : resolve(raw);
91
+ const stateDir = stateAgentDir(env, wd);
92
+ if (existsSync(stateDir)) {
93
+ rmSync(stateDir, {recursive: true, force: true});
94
+ process.stderr.write(`anon-pi: --fresh removed ${stateDir}\n`);
95
+ }
96
+ }
63
97
 
64
98
  let plan;
65
99
  try {
66
- plan = buildRunPlan(env, positionals[0], existsSync);
100
+ plan = buildRunPlan(env, positionals[0], existsSync, existsSync);
67
101
  } catch (e) {
68
102
  if (e instanceof AnonPiError) {
69
103
  process.stderr.write(e.message + '\n');
@@ -81,8 +115,22 @@ function runLaunch(args: string[]): number {
81
115
  return 1;
82
116
  }
83
117
 
84
- // Ensure the workdir exists (a fresh named folder is fine).
85
118
  mkdirSync(plan.workdir, {recursive: true});
119
+ if (env.ephemeral) {
120
+ // No host state dir: pi writes to the container's own --rm layer, so the
121
+ // session leaves NO trace on the host and there is nothing to clean up.
122
+ process.stderr.write(
123
+ 'anon-pi: ephemeral session (nothing persisted; no host state)\n',
124
+ );
125
+ } else {
126
+ // Persistent mode: create the per-workdir state home to mount.
127
+ mkdirSync(plan.stateDir, {recursive: true});
128
+ if (plan.fresh) {
129
+ process.stderr.write(
130
+ `anon-pi: new session home ${plan.stateDir} (seeding on first launch)\n`,
131
+ );
132
+ }
133
+ }
86
134
 
87
135
  // Hand off to netcage with inherited stdio so -it is a real interactive TTY.
88
136
  const res = spawnSync('netcage', plan.netcageArgs, {stdio: 'inherit'});