anon-pi 0.1.0 → 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/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,48 +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.
133
130
  */
134
- export function sessionId(absWorkdir: string): string {
135
- return createHash('sha256').update(absWorkdir).digest('hex').slice(0, 16);
131
+ export interface PiProvider {
132
+ baseUrl?: string;
133
+ apiKey?: string;
134
+ api?: string;
135
+ models?: unknown[];
136
+ [k: string]: unknown;
136
137
  }
137
138
 
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
- );
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).
164
+ */
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
+ };
202
+ }
203
+
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
- 'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH (e.g. ANON_PI_IMAGE=your/pi-image:tag).',
244
+ 'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
245
+ '\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' +
248
+ '\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' +
251
+ '\n' +
252
+ 'See the README (Providing a pi image) for details and a community-image note.',
165
253
  );
166
254
  }
167
255
  if (!env.llmDirect || env.llmDirect.trim() === '') {
@@ -180,21 +268,21 @@ export function buildRunPlan(
180
268
  const workdir = isAbsolute(raw) ? raw : resolve(raw);
181
269
 
182
270
  const configSeed = resolveConfigSeed(env);
183
- if (!seedExists(configSeed)) {
271
+ const modelsJson = join(configSeed, MODELS_FILE);
272
+ if (!seedModelsExists(modelsJson)) {
184
273
  throw new AnonPiError(
185
- `anon-pi: canonical config not found at ${configSeed}.\n` +
186
- 'anon-pi never populates it for you. Create it yourself with the pi config you want\n' +
187
- '(anon accounts, chosen models/skills, and a trust.json that trusts /work), e.g.:\n' +
188
- ` mkdir -p ${configSeed}\n` +
189
- ` cp -a ~/.pi/agent/. ${configSeed}/ # then remove any identity you do not want anonymized\n` +
190
- '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).',
191
283
  );
192
284
  }
193
285
 
194
- const sessionDir = sessionAgentDir(env, workdir);
195
- const needsSeed = !sessionExists(sessionDir);
196
-
197
- const agentMount = resolveAgentMount(env);
198
286
  const proxy =
199
287
  env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
200
288
 
@@ -208,23 +296,45 @@ export function buildRunPlan(
208
296
  '-v',
209
297
  workdir, // netcage defaults a target-less -v to /work and cwd to /work
210
298
  '-v',
211
- `${sessionDir}:${agentMount}`,
212
- '-e',
213
- `${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`,
214
303
  env.image,
215
- 'pi',
304
+ 'sh',
305
+ '-c',
306
+ CONTAINER_RUN_CMD,
216
307
  ];
217
308
 
218
309
  return {
219
310
  workdir,
220
- sessionAgentDir: sessionDir,
221
311
  configSeed,
222
- agentMount,
223
- needsSeed,
224
312
  netcageArgs,
225
313
  };
226
314
  }
227
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
+
228
338
  /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
229
339
  export function envFromProcess(
230
340
  penv: Record<string, string | undefined>,
@@ -237,7 +347,9 @@ export function envFromProcess(
237
347
  image: penv.ANON_PI_IMAGE,
238
348
  llmDirect: penv.ANON_PI_LLM,
239
349
  xdgConfigHome: penv.XDG_CONFIG_HOME,
240
- agentMount: penv.ANON_PI_AGENT_MOUNT,
350
+ dockerfilePath: shippedDockerfilePath(),
351
+ sourceModels: penv.ANON_PI_SOURCE_MODELS,
352
+ piAgentDir: penv.PI_CODING_AGENT_DIR,
241
353
  };
242
354
  }
243
355
 
@@ -245,31 +357,37 @@ export function envFromProcess(
245
357
  export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
246
358
 
247
359
  USAGE
248
- 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
249
362
 
250
- WORKDIR the host folder pi works in (mounted at /work). Defaults to the
251
- 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.
252
365
 
253
366
  WHAT IT DOES
254
- Seeds a per-workdir writable copy of your canonical anon-pi config into
255
- ~/.config/anon-pi/sessions/<hash>/agent, mounts it as pi's global config
256
- (${PI_AGENT_DIR_ENV}=<mount>, default ${DEFAULT_CONTAINER_AGENT_DIR}), mounts WORKDIR at
257
- ${CONTAINER_WORKDIR}, opens ONE direct hole to your local model, and runs pi with all other
258
- 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.
259
378
 
260
379
  ENVIRONMENT
261
- ANON_PI_IMAGE (required) image with \`pi\` on PATH
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).
262
383
  ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
263
384
  ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
264
385
  ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
265
- ANON_PI_CONFIG canonical seed dir (default <ANON_PI_HOME>/agent)
266
- ANON_PI_AGENT_MOUNT absolute container path for pi's config (default
267
- ${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)
268
388
 
269
389
  RESEED
270
- Reseed is manual: delete the session dir, e.g.
271
- rm -rf ~/.config/anon-pi/sessions/<hash>/agent
272
- and the next run re-seeds it from the canonical config.
390
+ anon-pi import --force regenerates the seed models.json.
273
391
 
274
392
  PLATFORM
275
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',