anon-pi 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anon-pi",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Launch pi inside a netcage: anonymized web egress through a socks5h proxy, one direct hole for a local model, seeded pi config on the host.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "keywords": [
@@ -40,7 +40,8 @@
40
40
  "dist",
41
41
  "src",
42
42
  "LICENSE",
43
- "Dockerfile.pi"
43
+ "Dockerfile.pi",
44
+ "examples"
44
45
  ],
45
46
  "engines": {
46
47
  "node": ">=20"
package/src/anon-pi.ts CHANGED
@@ -18,24 +18,37 @@
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
+ * 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.
34
36
  */
35
- export const DEFAULT_CONTAINER_AGENT_DIR = '/opt/pi-agent';
37
+ export const CONTAINER_SEED_DIR = '/anon-pi-seed';
36
38
 
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';
39
+ /**
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.
44
+ */
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`;
49
+
50
+ /** The single file the seed carries: pi's model/provider registry. */
51
+ export const MODELS_FILE = 'models.json';
39
52
 
40
53
  /** Inputs resolved from the environment + argv, injected so this stays pure. */
41
54
  export interface AnonPiEnv {
@@ -54,27 +67,24 @@ export interface AnonPiEnv {
54
67
  /** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
55
68
  xdgConfigHome?: string;
56
69
  /**
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.
70
+ * Absolute path to the Dockerfile.pi that ships with anon-pi, used only to
71
+ * make the missing-image error's build command concrete. cli.ts resolves it
72
+ * from import.meta.url; when absent the message falls back to a bare
73
+ * `Dockerfile.pi`.
62
74
  */
63
- agentMount?: string;
75
+ dockerfilePath?: string;
76
+ /** `import` source models.json override (ANON_PI_SOURCE_MODELS). */
77
+ sourceModels?: string;
78
+ /** The host pi agent dir override (PI_CODING_AGENT_DIR), used to find models.json. */
79
+ piAgentDir?: string;
64
80
  }
65
81
 
66
82
  /** The fully-resolved run plan cli.ts executes. */
67
83
  export interface RunPlan {
68
84
  /** Absolute workdir on the host (mounted at /work). */
69
85
  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). */
86
+ /** The seed dir on the host (holds models.json), mounted read-only at /anon-pi-seed. */
73
87
  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;
78
88
  /** The argv passed to `netcage` (after the `netcage` program name). */
79
89
  netcageArgs: string[];
80
90
  }
@@ -84,33 +94,7 @@ const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
84
94
  /** A user-facing error whose message is meant to be printed verbatim (no stack). */
85
95
  export class AnonPiError extends Error {}
86
96
 
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). */
97
+ /** Resolve the anon-pi home dir (holds the seed). */
114
98
  export function resolveAnonPiHome(env: AnonPiEnv): string {
115
99
  if (env.anonPiHome) return resolve(env.anonPiHome);
116
100
  const base =
@@ -120,64 +104,152 @@ export function resolveAnonPiHome(env: AnonPiEnv): string {
120
104
  return join(base, 'anon-pi');
121
105
  }
122
106
 
123
- /** The canonical seed dir (copied FROM, never mounted). */
107
+ /** The seed dir (holds models.json), mounted read-only into the container. */
124
108
  export function resolveConfigSeed(env: AnonPiEnv): string {
125
109
  if (env.configSeed) return resolve(env.configSeed);
126
110
  return join(resolveAnonPiHome(env), 'agent');
127
111
  }
128
112
 
129
113
  /**
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).
114
+ * Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
115
+ * baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
116
+ * Returns `host` (no port) or `host:port`, lowercased, scheme/path stripped.
117
+ */
118
+ export function hostPortKey(value: string): string {
119
+ let v = value.trim();
120
+ const scheme = v.indexOf('://');
121
+ if (scheme >= 0) v = v.slice(scheme + 3);
122
+ v = v.split('/')[0]; // drop path (/v1, ...)
123
+ v = v.replace(/^[^@]*@/, ''); // drop any user:pass@
124
+ return v.toLowerCase();
125
+ }
126
+
127
+ /**
128
+ * A pi provider entry (as it appears under models.json `providers[name]`). Only
129
+ * the fields anon-pi reads are typed; the rest is preserved verbatim.
130
+ */
131
+ export interface PiProvider {
132
+ baseUrl?: string;
133
+ apiKey?: string;
134
+ api?: string;
135
+ models?: unknown[];
136
+ [k: string]: unknown;
137
+ }
138
+
139
+ /** Parsed shape of a pi models.json (only `providers` is required). */
140
+ export interface PiModelsFile {
141
+ providers?: Record<string, PiProvider>;
142
+ [k: string]: unknown;
143
+ }
144
+
145
+ /** The result of picking the ANON_PI_LLM provider out of a host models.json. */
146
+ export interface ImportResult {
147
+ /** The provider key (e.g. "llamacpp-router"). */
148
+ name: string;
149
+ /** The barebones models.json to write (just the matched provider). */
150
+ models: PiModelsFile;
151
+ /** True if the matched provider's apiKey looks like a REAL secret (warn). */
152
+ apiKeyLooksReal: boolean;
153
+ }
154
+
155
+ /** apiKey values that are NOT real secrets (safe to carry into the seed). */
156
+ const BENIGN_API_KEYS = new Set(['', 'none', 'ollama', 'no-key', 'local']);
157
+
158
+ /**
159
+ * PURE: given a parsed host models.json and the ANON_PI_LLM value, select the
160
+ * provider whose baseUrl points at that host:port and return a barebones
161
+ * models.json carrying ONLY that provider (verbatim, with its models). Throws
162
+ * AnonPiError if nothing matches. Carries no other provider (so etherplay /
163
+ * google / paid API keys never enter the seed).
133
164
  */
134
- export function sessionId(absWorkdir: string): string {
135
- return createHash('sha256').update(absWorkdir).digest('hex').slice(0, 16);
165
+ export function pickProviderForLlm(
166
+ hostModels: PiModelsFile,
167
+ llmDirect: string,
168
+ ): ImportResult {
169
+ const providers = hostModels.providers ?? {};
170
+ const want = hostPortKey(llmDirect);
171
+
172
+ const matches: string[] = [];
173
+ for (const [name, p] of Object.entries(providers)) {
174
+ if (!p || typeof p !== 'object' || !p.baseUrl) continue;
175
+ if (hostPortKey(p.baseUrl) === want) matches.push(name);
176
+ }
177
+
178
+ if (matches.length === 0) {
179
+ const known = Object.entries(providers)
180
+ .filter(([, p]) => p && p.baseUrl)
181
+ .map(([n, p]) => ` ${n}: ${p.baseUrl}`)
182
+ .join('\n');
183
+ throw new AnonPiError(
184
+ `anon-pi import: no provider in your host models.json points at ANON_PI_LLM (${want}).\n` +
185
+ (known
186
+ ? `Providers found:\n${known}\n`
187
+ : 'No providers with a baseUrl were found.\n') +
188
+ 'Set ANON_PI_LLM to the host:port of a provider above, or add that provider to pi first.',
189
+ );
190
+ }
191
+
192
+ const name = matches[0];
193
+ const provider = providers[name];
194
+ const key = (provider.apiKey ?? '').trim().toLowerCase();
195
+ const apiKeyLooksReal = !BENIGN_API_KEYS.has(key);
196
+
197
+ return {
198
+ name,
199
+ models: {providers: {[name]: provider}},
200
+ apiKeyLooksReal,
201
+ };
136
202
  }
137
203
 
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
- );
204
+ /**
205
+ * The default host models.json path `import` reads FROM. Overridable via
206
+ * ANON_PI_SOURCE_MODELS; defaults to the real pi config (~/.pi/agent/models.json
207
+ * under the container-less host HOME, or PI_CODING_AGENT_DIR if the user set it).
208
+ */
209
+ export function resolveSourceModelsPath(env: AnonPiEnv): string {
210
+ if (env.sourceModels && env.sourceModels.trim() !== '') {
211
+ return resolve(env.sourceModels);
212
+ }
213
+ const agentDir =
214
+ env.piAgentDir && env.piAgentDir.trim() !== ''
215
+ ? env.piAgentDir
216
+ : join(env.home, '.pi', 'agent');
217
+ return join(agentDir, MODELS_FILE);
146
218
  }
147
219
 
148
220
  /**
149
221
  * 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`.
222
+ * 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.
225
+ *
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.
155
230
  */
156
231
  export function buildRunPlan(
157
232
  env: AnonPiEnv,
158
233
  workdirArg: string | undefined,
159
- seedExists: (dir: string) => boolean,
160
- sessionExists: (dir: string) => boolean,
234
+ seedModelsExists: (modelsJsonPath: string) => boolean,
161
235
  ): RunPlan {
162
236
  if (!env.image || env.image.trim() === '') {
237
+ // dockerfilePath is injected (cli.ts resolves the shipped Dockerfile.pi via
238
+ // import.meta.url; tests pass a fixed path). Every command is emitted
239
+ // flush-left so it copy-pastes cleanly: an indented heredoc would bake
240
+ // leading spaces into the Dockerfile and break the EOF terminator, so we
241
+ // point at the shipped file instead of printing a heredoc.
242
+ const df = env.dockerfilePath ?? 'Dockerfile.pi';
163
243
  throw new AnonPiError(
164
244
  'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
165
245
  '\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' +
246
+ 'No image yet? A ready Dockerfile.pi ships with anon-pi (it installs the\n' +
247
+ 'official @earendil-works/pi-coding-agent). Build it and point at it:\n' +
168
248
  '\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' +
249
+ `podman build -t localhost/anon-pi-pi:latest -f "${df}" "$(dirname "${df}")"\n` +
250
+ 'export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\n' +
178
251
  '\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.',
252
+ 'See the README (Providing a pi image) for details and a community-image note.',
181
253
  );
182
254
  }
183
255
  if (!env.llmDirect || env.llmDirect.trim() === '') {
@@ -196,21 +268,21 @@ export function buildRunPlan(
196
268
  const workdir = isAbsolute(raw) ? raw : resolve(raw);
197
269
 
198
270
  const configSeed = resolveConfigSeed(env);
199
- if (!seedExists(configSeed)) {
271
+ const modelsJson = join(configSeed, MODELS_FILE);
272
+ if (!seedModelsExists(modelsJson)) {
200
273
  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.',
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).',
207
283
  );
208
284
  }
209
285
 
210
- const sessionDir = sessionAgentDir(env, workdir);
211
- const needsSeed = !sessionExists(sessionDir);
212
-
213
- const agentMount = resolveAgentMount(env);
214
286
  const proxy =
215
287
  env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
216
288
 
@@ -224,23 +296,45 @@ export function buildRunPlan(
224
296
  '-v',
225
297
  workdir, // netcage defaults a target-less -v to /work and cwd to /work
226
298
  '-v',
227
- `${sessionDir}:${agentMount}`,
228
- '-e',
229
- `${PI_AGENT_DIR_ENV}=${agentMount}`,
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`,
230
303
  env.image,
231
- 'pi',
304
+ 'sh',
305
+ '-c',
306
+ CONTAINER_RUN_CMD,
232
307
  ];
233
308
 
234
309
  return {
235
310
  workdir,
236
- sessionAgentDir: sessionDir,
237
311
  configSeed,
238
- agentMount,
239
- needsSeed,
240
312
  netcageArgs,
241
313
  };
242
314
  }
243
315
 
316
+ /**
317
+ * Absolute path to the Dockerfile.pi that ships with anon-pi, resolved from this
318
+ * module's location (package root, one level up from dist/anon-pi.js), or
319
+ * undefined if it cannot be found. Used only to make the missing-image error's
320
+ * build command concrete.
321
+ */
322
+ export function shippedDockerfilePath(): string | undefined {
323
+ try {
324
+ 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
+ ]) {
330
+ if (existsSync(p)) return p;
331
+ }
332
+ } catch {
333
+ // import.meta.url unavailable (e.g. some test bundlers): fall through.
334
+ }
335
+ return undefined;
336
+ }
337
+
244
338
  /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
245
339
  export function envFromProcess(
246
340
  penv: Record<string, string | undefined>,
@@ -253,7 +347,9 @@ export function envFromProcess(
253
347
  image: penv.ANON_PI_IMAGE,
254
348
  llmDirect: penv.ANON_PI_LLM,
255
349
  xdgConfigHome: penv.XDG_CONFIG_HOME,
256
- agentMount: penv.ANON_PI_AGENT_MOUNT,
350
+ dockerfilePath: shippedDockerfilePath(),
351
+ sourceModels: penv.ANON_PI_SOURCE_MODELS,
352
+ piAgentDir: penv.PI_CODING_AGENT_DIR,
257
353
  };
258
354
  }
259
355
 
@@ -261,33 +357,37 @@ export function envFromProcess(
261
357
  export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
262
358
 
263
359
  USAGE
264
- anon-pi [WORKDIR]
360
+ anon-pi [WORKDIR] launch pi jailed, working in WORKDIR (default: cwd)
361
+ anon-pi import write the seed models.json from your local model
265
362
 
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.
363
+ WORKDIR the host folder pi works in (mounted at ${CONTAINER_WORKDIR}; pi's cwd). Files pi
364
+ writes there land on the host.
268
365
 
269
366
  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\`.
367
+ Runs pi inside netcage with all web/DNS egress forced through the socks5h
368
+ 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\`.
372
+
373
+ import
374
+ 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
376
+ (<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.
275
378
 
276
379
  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).
380
+ ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
381
+ Running anon-pi without it prints a ready-to-build
382
+ Dockerfile.pi recipe; see the README (Providing a pi image).
280
383
  ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
281
384
  ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
282
385
  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)
386
+ ANON_PI_CONFIG seed dir holding models.json (default <ANON_PI_HOME>/agent)
387
+ ANON_PI_SOURCE_MODELS (import) host models.json to read (default ~/.pi/agent/models.json)
286
388
 
287
389
  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.
390
+ anon-pi import --force regenerates the seed models.json.
291
391
 
292
392
  PLATFORM
293
393
  Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
package/src/cli.ts CHANGED
@@ -1,12 +1,27 @@
1
1
  #!/usr/bin/env node
2
- // anon-pi CLI: resolve the run plan (pure), do the one filesystem side-effect
3
- // (seed the session config if absent), then exec `netcage run ...` with inherited
4
- // stdio so the interactive pi session (-it) passes through the terminal cleanly.
2
+ // anon-pi CLI. Two commands:
3
+ // anon-pi [WORKDIR] resolve the run plan (pure) and exec `netcage run ...`
4
+ // with inherited stdio (so -it is a real interactive TTY).
5
+ // The seed models.json is mounted read-only and copied
6
+ // into the container's ~/.pi/agent by the run command, so
7
+ // it layers onto the image's config (extensions survive).
8
+ // anon-pi import generate the seed models.json from the host models.json,
9
+ // carrying only the provider that serves ANON_PI_LLM.
5
10
 
6
- import {cpSync, existsSync, mkdirSync} from 'node:fs';
11
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
7
12
  import {spawnSync} from 'node:child_process';
8
- import {dirname} from 'node:path';
9
- import {AnonPiError, buildRunPlan, envFromProcess, HELP} from './anon-pi.js';
13
+ import {join} from 'node:path';
14
+ import {
15
+ AnonPiError,
16
+ buildRunPlan,
17
+ envFromProcess,
18
+ HELP,
19
+ MODELS_FILE,
20
+ pickProviderForLlm,
21
+ resolveConfigSeed,
22
+ resolveSourceModelsPath,
23
+ type PiModelsFile,
24
+ } from './anon-pi.js';
10
25
 
11
26
  function main(argv: string[]): number {
12
27
  const args = argv.slice(2);
@@ -16,6 +31,16 @@ function main(argv: string[]): number {
16
31
  return 0;
17
32
  }
18
33
 
34
+ // Subcommand dispatch: the first bare token may be `import`.
35
+ if (args[0] === 'import') {
36
+ return runImport(args.slice(1));
37
+ }
38
+
39
+ return runLaunch(args);
40
+ }
41
+
42
+ // --- anon-pi [WORKDIR] : launch pi jailed -----------------------------------
43
+ function runLaunch(args: string[]): number {
19
44
  // The only positional is the optional workdir. Reject stray flags so a typo
20
45
  // (e.g. --allow-direct) is not silently swallowed: anon-pi owns the netcage
21
46
  // argv, extra flags are not passed through.
@@ -38,7 +63,7 @@ function main(argv: string[]): number {
38
63
 
39
64
  let plan;
40
65
  try {
41
- plan = buildRunPlan(env, positionals[0], existsSync, existsSync);
66
+ plan = buildRunPlan(env, positionals[0], existsSync);
42
67
  } catch (e) {
43
68
  if (e instanceof AnonPiError) {
44
69
  process.stderr.write(e.message + '\n');
@@ -56,16 +81,6 @@ function main(argv: string[]): number {
56
81
  return 1;
57
82
  }
58
83
 
59
- // The one side-effect: seed the per-session config from the canonical seed the
60
- // FIRST time this workdir is used. Reuse-if-present, seed-if-absent.
61
- if (plan.needsSeed) {
62
- mkdirSync(dirname(plan.sessionAgentDir), {recursive: true});
63
- cpSync(plan.configSeed, plan.sessionAgentDir, {recursive: true});
64
- process.stderr.write(
65
- `anon-pi: seeded session config -> ${plan.sessionAgentDir}\n`,
66
- );
67
- }
68
-
69
84
  // Ensure the workdir exists (a fresh named folder is fine).
70
85
  mkdirSync(plan.workdir, {recursive: true});
71
86
 
@@ -81,6 +96,84 @@ function main(argv: string[]): number {
81
96
  return res.status ?? 1;
82
97
  }
83
98
 
99
+ // --- anon-pi import : write the seed models.json ----------------------------
100
+ function runImport(args: string[]): number {
101
+ const force = args.includes('--force') || args.includes('-f');
102
+ const stray = args.filter(
103
+ (a) => a.startsWith('-') && a !== '--force' && a !== '-f',
104
+ );
105
+ if (stray.length > 0) {
106
+ process.stderr.write(
107
+ `anon-pi import: unknown option(s): ${stray.join(' ')}\nRun \`anon-pi --help\`.\n`,
108
+ );
109
+ return 2;
110
+ }
111
+
112
+ const env = envFromProcess(process.env);
113
+
114
+ if (!env.llmDirect || env.llmDirect.trim() === '') {
115
+ process.stderr.write(
116
+ 'anon-pi import: set ANON_PI_LLM to the RFC1918/link-local IP[:port] of the local\n' +
117
+ 'model whose provider should be imported (e.g. ANON_PI_LLM=192.168.1.150:8080).\n',
118
+ );
119
+ return 1;
120
+ }
121
+
122
+ const source = resolveSourceModelsPath(env);
123
+ if (!existsSync(source)) {
124
+ process.stderr.write(
125
+ `anon-pi import: host models.json not found at ${source}.\n` +
126
+ 'Set ANON_PI_SOURCE_MODELS to your pi models.json, or run pi once to create it.\n',
127
+ );
128
+ return 1;
129
+ }
130
+
131
+ let hostModels: PiModelsFile;
132
+ try {
133
+ hostModels = JSON.parse(readFileSync(source, 'utf8')) as PiModelsFile;
134
+ } catch (e) {
135
+ process.stderr.write(
136
+ `anon-pi import: could not parse ${source}: ${(e as Error).message}\n`,
137
+ );
138
+ return 1;
139
+ }
140
+
141
+ let result;
142
+ try {
143
+ result = pickProviderForLlm(hostModels, env.llmDirect);
144
+ } catch (e) {
145
+ if (e instanceof AnonPiError) {
146
+ process.stderr.write(e.message + '\n');
147
+ return 1;
148
+ }
149
+ throw e;
150
+ }
151
+
152
+ const seedDir = resolveConfigSeed(env);
153
+ const dest = join(seedDir, MODELS_FILE);
154
+ if (existsSync(dest) && !force) {
155
+ process.stderr.write(
156
+ `anon-pi import: ${dest} already exists. Re-run with --force to overwrite.\n`,
157
+ );
158
+ return 1;
159
+ }
160
+
161
+ if (result.apiKeyLooksReal) {
162
+ process.stderr.write(
163
+ `anon-pi import: WARNING: provider "${result.name}" carries a real-looking apiKey; it\n` +
164
+ 'will be written into the seed. For a local model this is usually fine, but review\n' +
165
+ `${dest} if that key identifies you.\n`,
166
+ );
167
+ }
168
+
169
+ mkdirSync(seedDir, {recursive: true});
170
+ writeFileSync(dest, JSON.stringify(result.models, null, 2) + '\n');
171
+ process.stderr.write(
172
+ `anon-pi import: wrote ${dest} (provider "${result.name}"). Run \`anon-pi\` to launch.\n`,
173
+ );
174
+ return 0;
175
+ }
176
+
84
177
  function hasNetcage(): boolean {
85
178
  const which = spawnSync(
86
179
  process.platform === 'win32' ? 'where' : 'command',