anon-pi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -21,16 +21,18 @@ import {
21
21
  } from 'node:fs';
22
22
  import {readSync} from 'node:fs';
23
23
  import {spawnSync, execFileSync} from 'node:child_process';
24
- import {join, dirname} from 'node:path';
24
+ import {join, dirname, resolve} from 'node:path';
25
25
  import {
26
26
  AnonPiError,
27
27
  HELP,
28
28
  MODELS_FILE,
29
+ SETTINGS_SEED_FILE,
29
30
  SEED_MARKER,
30
31
  DEFAULT_MACHINE,
31
32
  envFromProcess,
32
33
  buildMenuChoiceList,
33
34
  buildMenuEntries,
35
+ builtinProjectsRoot,
34
36
  deriveProjectUsage,
35
37
  machineDir,
36
38
  machineHomeDir,
@@ -60,6 +62,12 @@ import {
60
62
  interpretSocks5Handshake,
61
63
  initImageMenu,
62
64
  generateModelsJson,
65
+ generateModelSelection,
66
+ pickLocalProviderModels,
67
+ parseModelsListing,
68
+ mergeModelSources,
69
+ resolveHostModelsPath,
70
+ LOCAL_PROVIDER_API_KEY,
63
71
  parseVerifyExitIp,
64
72
  processHint,
65
73
  socks5hUrl,
@@ -73,12 +81,15 @@ import {
73
81
  type InitImageChoice,
74
82
  type AnonPiConfig,
75
83
  type AnonPiEnv,
84
+ type GeneratedModel,
85
+ type ModelCandidate,
76
86
  type KeptContainer,
77
87
  type LaunchIntent,
78
88
  type Machine,
79
89
  type MachineConfig,
80
90
  type MachineCommand,
81
91
  type ParsedLaunch,
92
+ type PiModelsFile,
82
93
  } from './anon-pi.js';
83
94
 
84
95
  // The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
@@ -136,9 +147,57 @@ function main(argv: string[]): number {
136
147
  return reportAnonPiError(e);
137
148
  }
138
149
 
150
+ // FIRST RUN: no config.json yet. Rather than fail deep in the launch with the
151
+ // bare "set ANON_PI_PROXY" wall (which reads like a doc dump the first time),
152
+ // welcome the user and run `init` automatically, then continue into the
153
+ // launch they asked for. Needs a TTY (init is interactive); without one we
154
+ // fall through to the launch path, whose fail-closed proxy error is the right
155
+ // signal for a script. An explicit ANON_PI_PROXY/ANON_PI_LLM env pair also
156
+ // skips this (the user is driving config via env, not the file).
157
+ if (isFirstRun()) {
158
+ const code = runFirstRunInit();
159
+ if (code !== 0) return code; // init aborted / failed: do not launch.
160
+ }
161
+
139
162
  return runLaunch(parsed);
140
163
  }
141
164
 
165
+ /**
166
+ * First run = no config.json in the anon-pi home AND the user has not supplied
167
+ * the forced-egress inputs via env (ANON_PI_PROXY is what the launch fails
168
+ * closed on; if it is set the user is configuring via env and we do not
169
+ * onboard). We only auto-onboard on an interactive terminal.
170
+ */
171
+ function isFirstRun(): boolean {
172
+ const env = envFromProcess(process.env);
173
+ if (nonEmptyEnv(env.proxy)) return false; // env-driven config; no onboarding.
174
+ if (!process.stdin.isTTY) return false; // scripts get the fail-closed error.
175
+ const configPath = join(resolveAnonPiHome(env), 'config.json');
176
+ return !existsSync(configPath);
177
+ }
178
+
179
+ /** Show a first-time welcome, then run `init`. Returns init's exit code. */
180
+ function runFirstRunInit(): number {
181
+ process.stdout.write(
182
+ '\n' +
183
+ "Welcome to anon-pi. It looks like this is your first run (there's no\n" +
184
+ 'config yet), so let us set things up before launching.\n' +
185
+ '\n' +
186
+ 'anon-pi runs pi on anonymized, jailed MACHINES: all of pi\u2019s web/DNS egress\n' +
187
+ 'is forced through your socks5h proxy (fail-closed), with ONE direct hole to\n' +
188
+ 'a local model. Your machines + conversations live in ~/.anon-pi/.\n' +
189
+ '\n' +
190
+ 'Running `anon-pi init` now (re-runnable any time; nothing is destroyed).\n' +
191
+ '\n',
192
+ );
193
+ return runInit([]);
194
+ }
195
+
196
+ /** Whether an env value is present + non-blank. */
197
+ function nonEmptyEnv(v: string | undefined): boolean {
198
+ return typeof v === 'string' && v.trim() !== '';
199
+ }
200
+
142
201
  // --- the launch path --------------------------------------------------------
143
202
  function runLaunch(parsed: ParsedLaunch): number {
144
203
  const env = envFromProcess(process.env);
@@ -185,9 +244,10 @@ function runLaunch(parsed: ParsedLaunch): number {
185
244
  const home = machineHomeDir(env, machineName);
186
245
  const machine: Machine = {name: machineName, home, image};
187
246
 
188
- // The generated models.json for this machine (mounted read-only for the
189
- // first-launch seed) when present. Keyed per machine, not per import.
247
+ // The generated models.json + settings seed for this machine (mounted
248
+ // read-only for the first-launch seed) when present. Keyed per machine.
190
249
  const modelsSeed = join(machineDir(env, machineName), MODELS_FILE);
250
+ const settingsSeed = join(machineDir(env, machineName), SETTINGS_SEED_FILE);
191
251
 
192
252
  intent = {
193
253
  machine,
@@ -200,6 +260,7 @@ function runLaunch(parsed: ParsedLaunch): number {
200
260
  proxy,
201
261
  llmDirect: llm,
202
262
  modelsSeed: existsSync(modelsSeed) ? modelsSeed : undefined,
263
+ settingsSeed: existsSync(settingsSeed) ? settingsSeed : undefined,
203
264
  };
204
265
  } catch (e) {
205
266
  return reportAnonPiError(e);
@@ -900,9 +961,23 @@ WHAT IT DOES
900
961
  1. PROXY: probes common SOCKS ports, confirms SOCKS5 via a real handshake,
901
962
  shows the findings (EVIDENCE only, never a provider label), then runs
902
963
  \`netcage verify\` and shows the real EXIT IP as proof. You confirm.
903
- 2. LOCAL MODEL: captures host:port, probes reachability, generates models.json.
964
+ 2. LOCAL MODEL: captures host:port, probes it, then IMPORTS models. It merges
965
+ your pi config's matching provider ([configured], well-tuned) with the
966
+ endpoint's live /v1/models ([server]); you pick which to import + the
967
+ default. Only the provider served by this endpoint (the one --allow-direct
968
+ hole) is ever read, so no other provider or key can enter the seed. Writes
969
+ models.json + a settings seed (the default-model selection).
904
970
  3. IMAGE: pick a shipped Dockerfile (built via podman), an existing ref, or skip.
971
+ 4. PROJECTS ROOT: the host folder mounted at /projects (default ~/.anon-pi/
972
+ projects); point it at your own dev folder, or keep the default.
905
973
  Then writes ~/.anon-pi/config.json + the \`default\` machine. Never destroys homes.
974
+
975
+ Runs AUTOMATICALLY the first time you launch anon-pi with no config yet.
976
+
977
+ FLAGS
978
+ --force-allow-local-llm-api-key carry a REAL apiKey from the matching host
979
+ provider into the seed (init refuses by default: a host credential should
980
+ not enter the anonymized machine home unless you say so).
906
981
  `;
907
982
 
908
983
  function runInit(args: string[]): number {
@@ -910,9 +985,12 @@ function runInit(args: string[]): number {
910
985
  process.stdout.write(INIT_HELP);
911
986
  return 0;
912
987
  }
913
- if (args.length > 0) {
988
+ const FORCE_KEY_FLAG = '--force-allow-local-llm-api-key';
989
+ const forceLocalApiKey = args.includes(FORCE_KEY_FLAG);
990
+ const extra = args.filter((a) => a !== FORCE_KEY_FLAG);
991
+ if (extra.length > 0) {
914
992
  process.stderr.write(
915
- `anon-pi: init takes no arguments, got: ${args.join(' ')}. Run \`anon-pi init --help\`.\n`,
993
+ `anon-pi: init takes no arguments (except ${FORCE_KEY_FLAG}), got: ${extra.join(' ')}. Run \`anon-pi init --help\`.\n`,
916
994
  );
917
995
  return 1;
918
996
  }
@@ -940,16 +1018,23 @@ function runInit(args: string[]): number {
940
1018
  if (proxyHostPort === undefined) return 1;
941
1019
  const proxyUrl = socks5hUrl(proxyHostPort);
942
1020
 
943
- // 2) LOCAL MODEL endpoint: capture + probe + generate models.json.
944
- const llm = initLlmStep(current.llm);
945
- // llm may be undefined if the user skipped it (the launch path still errors
946
- // without one, but init lets you set it later; we do not force it here).
1021
+ // 2) LOCAL MODEL endpoint + model import: capture the endpoint, then merge the
1022
+ // host config's matching provider (well-tuned) with the endpoint's live
1023
+ // /v1/models, let the user pick which to import + the default. May ABORT
1024
+ // (a real host apiKey without --force-allow-local-llm-api-key).
1025
+ const llmResult = initLlmStep(env, current.llm, forceLocalApiKey);
1026
+ if (llmResult === ABORT) return 1;
1027
+ const llm = llmResult.endpoint;
947
1028
 
948
1029
  // 3) DEFAULT MACHINE IMAGE: menu (shipped Dockerfiles / existing ref / skip).
949
1030
  const image = initImageStep();
950
1031
  if (image === ABORT) return 1;
951
1032
 
952
- // 4) WRITE config.json + the `default` machine (never destroying an existing
1033
+ // 4) PROJECTS ROOT: the host dir mounted at /projects (default ~/.anon-pi/
1034
+ // projects). Overridable per-launch with `--mount`; this sets the default.
1035
+ const projects = initProjectsStep(env, current.projects);
1036
+
1037
+ // 5) WRITE config.json + the `default` machine (never destroying an existing
953
1038
  // home). The proxy is always present (we only reach here on a chosen proxy).
954
1039
  const anonHome = resolveAnonPiHome(env);
955
1040
  mkdirSync(anonHome, {recursive: true});
@@ -958,7 +1043,7 @@ function runInit(args: string[]): number {
958
1043
  proxy: proxyUrl,
959
1044
  llm: llm ?? current.llm,
960
1045
  defaultMachine: current.defaultMachine ?? DEFAULT_MACHINE,
961
- projects: current.projects,
1046
+ projects: projects ?? current.projects,
962
1047
  };
963
1048
  writeFileSync(configPath, serializeConfigJson(nextConfig));
964
1049
  process.stdout.write(`\nanon-pi: wrote ${configPath}.\n`);
@@ -967,20 +1052,45 @@ function runInit(args: string[]): number {
967
1052
  // pin/re-pin its image when one was chosen. Its home seeds on first launch.
968
1053
  initWriteDefaultMachine(env, image);
969
1054
 
970
- // The per-machine models.json seed for the default machine, generated from the
971
- // captured endpoint (this is the `import` replacement). Only when we have an
972
- // endpoint; written next to the machine so the first-launch seed mounts it.
973
- if ((llm ?? current.llm) !== undefined) {
1055
+ // The per-machine models.json + settings.json seed for the default machine,
1056
+ // generated from the captured endpoint + the CHOSEN models (this is the
1057
+ // `import` replacement). Written next to the machine so the first-launch seed
1058
+ // promotes them into the fresh home.
1059
+ const endpoint = llm ?? current.llm;
1060
+ if (endpoint !== undefined) {
974
1061
  const mdir = machineDir(env, DEFAULT_MACHINE);
975
1062
  mkdirSync(mdir, {recursive: true});
976
- const models = generateModelsJson((llm ?? current.llm) as string);
1063
+ const models = generateModelsJson(
1064
+ endpoint,
1065
+ llmResult.models,
1066
+ llmResult.apiKey,
1067
+ );
977
1068
  writeFileSync(
978
1069
  join(mdir, MODELS_FILE),
979
1070
  JSON.stringify(models, null, '\t') + '\n',
980
1071
  );
981
1072
  process.stdout.write(
982
- `anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}".\n`,
1073
+ `anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}" ` +
1074
+ `(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}).\n`,
983
1075
  );
1076
+
1077
+ // settings.json: set the default model + enabledModels for the imported
1078
+ // set. Written as a SEED that the first-launch promotion merges into the
1079
+ // home's settings (so image-staged packages/extensions survive). Only when
1080
+ // the user picked at least one model + a default.
1081
+ if (llmResult.defaultId !== undefined && llmResult.models.length > 0) {
1082
+ const selection = generateModelSelection(
1083
+ llmResult.models.map((m) => m.id),
1084
+ llmResult.defaultId,
1085
+ );
1086
+ writeFileSync(
1087
+ join(mdir, SETTINGS_SEED_FILE),
1088
+ JSON.stringify(selection, null, '\t') + '\n',
1089
+ );
1090
+ process.stdout.write(
1091
+ `anon-pi: default model set to "${llmResult.defaultId}".\n`,
1092
+ );
1093
+ }
984
1094
  }
985
1095
 
986
1096
  process.stdout.write(
@@ -1003,7 +1113,7 @@ const ABORT = Symbol('abort');
1003
1113
  */
1004
1114
  function initProxyStep(currentProxy: string | undefined): string | undefined {
1005
1115
  process.stdout.write(
1006
- 'Step 1/3 - proxy (the socks5h endpoint that anonymizes egress)\n',
1116
+ 'Step 1/4 - proxy (the socks5h endpoint that anonymizes egress)\n',
1007
1117
  );
1008
1118
  if (currentProxy) {
1009
1119
  process.stdout.write(` current: ${currentProxy}\n`);
@@ -1122,15 +1232,31 @@ function initProxyStep(currentProxy: string | undefined): string | undefined {
1122
1232
  }
1123
1233
  }
1124
1234
 
1235
+ /** What initLlmStep resolves: the endpoint, the chosen model entries, the apiKey to seed, and the default id. */
1236
+ interface LlmStepResult {
1237
+ endpoint: string | undefined;
1238
+ models: GeneratedModel[];
1239
+ apiKey: string;
1240
+ defaultId: string | undefined;
1241
+ }
1242
+
1125
1243
  /**
1126
1244
  * The LOCAL MODEL step: capture host:port (pre-filled from config), probe TCP
1127
- * reachability (evidence, not a gate), and return the endpoint (undefined if the
1128
- * user skips it). models.json is generated from it by runInit. The one direct
1129
- * hole; all other egress stays proxied.
1245
+ * reachability, then IMPORT models. It merges TWO sources, both scoped to the
1246
+ * endpoint (the one `--allow-direct` hole, so no other provider can enter the
1247
+ * seed): the host `~/.pi/agent/models.json` provider whose baseUrl matches the
1248
+ * endpoint (well-tuned entries, marked [configured]) and the endpoint's live
1249
+ * `/v1/models` (bare ids, marked [server]). The user picks which to import and
1250
+ * the default. Returns the endpoint + chosen entries + apiKey + default, or
1251
+ * ABORT (a real host apiKey without --force-allow-local-llm-api-key).
1130
1252
  */
1131
- function initLlmStep(currentLlm: string | undefined): string | undefined {
1253
+ function initLlmStep(
1254
+ env: AnonPiEnv,
1255
+ currentLlm: string | undefined,
1256
+ forceLocalApiKey: boolean,
1257
+ ): LlmStepResult | typeof ABORT {
1132
1258
  process.stdout.write(
1133
- '\nStep 2/3 - local model endpoint (the ONE direct hole)\n',
1259
+ '\nStep 2/4 - local model endpoint (the ONE direct hole)\n',
1134
1260
  );
1135
1261
  if (currentLlm) process.stdout.write(` current: ${currentLlm}\n`);
1136
1262
  const prefill = currentLlm
@@ -1139,10 +1265,17 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1139
1265
  const ans = promptLine(
1140
1266
  ` Local model host:port, e.g. 192.168.1.150:8080${prefill}: `,
1141
1267
  );
1142
- if (ans === undefined) return currentLlm;
1143
- const trimmed = ans.trim();
1144
- if (trimmed === '') return currentLlm;
1145
- const endpoint = trimmed;
1268
+ const raw = ans === undefined ? '' : ans.trim();
1269
+ const endpoint = raw === '' ? currentLlm : raw;
1270
+ if (endpoint === undefined) {
1271
+ // No endpoint at all: nothing to import.
1272
+ return {
1273
+ endpoint: undefined,
1274
+ models: [],
1275
+ apiKey: LOCAL_PROVIDER_API_KEY,
1276
+ defaultId: undefined,
1277
+ };
1278
+ }
1146
1279
 
1147
1280
  // Probe reachability: evidence only. A closed port is not fatal (the model may
1148
1281
  // start later); we just report it.
@@ -1150,15 +1283,152 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1150
1283
  const colon = key.lastIndexOf(':');
1151
1284
  const host = colon > 0 ? key.slice(0, colon) : key;
1152
1285
  const port = colon > 0 ? Number(key.slice(colon + 1)) : 80;
1286
+ const reachable = Number.isFinite(port) ? probeTcp(host, port) : false;
1287
+ process.stdout.write(
1288
+ reachable
1289
+ ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
1290
+ : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
1291
+ );
1292
+
1293
+ // SOURCE A: the host models.json provider matching this endpoint (only that
1294
+ // one — the anonymity scoping). Its apiKey is checked: a REAL key is refused
1295
+ // unless --force-allow-local-llm-api-key.
1296
+ const hostModels = readJsonFile(resolveHostModelsPath(env));
1297
+ const match = pickLocalProviderModels(
1298
+ (hostModels as PiModelsFile) ?? {},
1299
+ endpoint,
1300
+ );
1301
+ let apiKey = LOCAL_PROVIDER_API_KEY;
1302
+ if (match && match.apiKeyLooksReal) {
1303
+ if (!forceLocalApiKey) {
1304
+ process.stderr.write(
1305
+ `\n anon-pi: the matching provider in your pi config carries a real-looking\n` +
1306
+ ` apiKey. Seeding it would put a host credential into the anonymized machine\n` +
1307
+ ` home. Refusing. If this key is genuinely safe for the local model, re-run\n` +
1308
+ ` \`anon-pi init --force-allow-local-llm-api-key\` to carry it through.\n`,
1309
+ );
1310
+ return ABORT;
1311
+ }
1312
+ apiKey = (match.apiKey ?? LOCAL_PROVIDER_API_KEY).trim();
1313
+ process.stdout.write(
1314
+ ' WARNING: carrying the host provider apiKey into the seed (--force-allow-local-llm-api-key).\n',
1315
+ );
1316
+ }
1317
+ const hostModelEntries = match?.models ?? [];
1318
+
1319
+ // SOURCE B: the endpoint's live /v1/models (bare ids).
1320
+ let serverIds: string[] = [];
1153
1321
  if (Number.isFinite(port)) {
1154
- const reachable = probeTcp(host, port);
1322
+ const listing = fetchModelsListing(host, port);
1323
+ serverIds = parseModelsListing(listing);
1324
+ }
1325
+
1326
+ if (hostModelEntries.length === 0 && serverIds.length === 0) {
1327
+ process.stdout.write(
1328
+ ' No models found (no matching provider in your pi config, and the server\n' +
1329
+ ' returned none). The provider is still seeded; add models in pi later.\n',
1330
+ );
1331
+ return {endpoint, models: [], apiKey, defaultId: undefined};
1332
+ }
1333
+
1334
+ const candidates = mergeModelSources(hostModelEntries, serverIds);
1335
+ const chosen = initModelPicker(candidates);
1336
+ if (chosen === undefined) {
1337
+ // Skip: seed the provider with no models.
1338
+ return {endpoint, models: [], apiKey, defaultId: undefined};
1339
+ }
1340
+ const defaultId = initDefaultModelPicker(chosen);
1341
+ return {endpoint, models: chosen, apiKey, defaultId};
1342
+ }
1343
+
1344
+ /**
1345
+ * Present the merged candidate list and let the user choose which to import.
1346
+ * Options: Enter/`c` = all CONFIGURED (host-tuned; the safe default), `a` = ALL
1347
+ * (server + configured), space/comma-separated NUMBERS = those, `s` = skip.
1348
+ * Returns the chosen entries, or undefined to skip (seed no models).
1349
+ */
1350
+ function initModelPicker(
1351
+ candidates: readonly ModelCandidate[],
1352
+ ): GeneratedModel[] | undefined {
1353
+ const configured = candidates.filter((c) => c.configured);
1354
+ process.stdout.write('\n Models served by this endpoint:\n');
1355
+ candidates.forEach((c, i) => {
1356
+ const tag = c.configured ? '[configured]' : '[server]';
1357
+ process.stdout.write(` [${i + 1}] ${c.id} ${tag}\n`);
1358
+ });
1359
+ process.stdout.write(
1360
+ ' [configured] = from your pi config (well-tuned); [server] = the server\n' +
1361
+ ' also reports it (a minimal entry is synthesized).\n',
1362
+ );
1363
+ const hasConfigured = configured.length > 0;
1364
+ const defaultHint = hasConfigured
1365
+ ? 'Enter/c = all configured, a = all, numbers = pick, s = skip'
1366
+ : 'Enter/a = all, numbers = pick, s = skip';
1367
+ for (;;) {
1368
+ const ans = promptLine(` Import which? (${defaultHint}): `);
1369
+ const v = (ans ?? '').trim().toLowerCase();
1370
+ if (v === 's') return undefined;
1371
+ if (v === '' && hasConfigured) return configured.map((c) => c.entry);
1372
+ if (v === 'c') {
1373
+ if (!hasConfigured) {
1374
+ process.stdout.write(
1375
+ ' No [configured] models; pick numbers or `a` for all.\n',
1376
+ );
1377
+ continue;
1378
+ }
1379
+ return configured.map((c) => c.entry);
1380
+ }
1381
+ if (v === 'a' || (v === '' && !hasConfigured)) {
1382
+ return candidates.map((c) => c.entry);
1383
+ }
1384
+ // Numbers (space/comma separated).
1385
+ const picks = v
1386
+ .split(/[\s,]+/)
1387
+ .filter((t) => t !== '')
1388
+ .map((t) => Number(t) - 1);
1389
+ if (
1390
+ picks.length > 0 &&
1391
+ picks.every((i) => i >= 0 && i < candidates.length)
1392
+ ) {
1393
+ // De-dup, keep list order.
1394
+ const seen = new Set<number>();
1395
+ const out: GeneratedModel[] = [];
1396
+ for (const i of picks) {
1397
+ if (!seen.has(i)) {
1398
+ seen.add(i);
1399
+ out.push(candidates[i].entry);
1400
+ }
1401
+ }
1402
+ return out;
1403
+ }
1155
1404
  process.stdout.write(
1156
- reachable
1157
- ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
1158
- : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
1405
+ ` Please enter Enter/c/a/s or numbers 1-${candidates.length}.\n`,
1406
+ );
1407
+ }
1408
+ }
1409
+
1410
+ /**
1411
+ * Pick the DEFAULT model among the chosen ones. Defaults to the first (Enter);
1412
+ * accepts a number. Returns the chosen id.
1413
+ */
1414
+ function initDefaultModelPicker(chosen: readonly GeneratedModel[]): string {
1415
+ if (chosen.length === 1) return chosen[0].id;
1416
+ process.stdout.write('\n Which is the DEFAULT model?\n');
1417
+ chosen.forEach((m, i) => {
1418
+ process.stdout.write(` [${i + 1}] ${m.id}\n`);
1419
+ });
1420
+ for (;;) {
1421
+ const ans = promptLine(
1422
+ ` Default [1-${chosen.length}] (Enter = ${chosen[0].id}): `,
1159
1423
  );
1424
+ const v = (ans ?? '').trim();
1425
+ if (v === '') return chosen[0].id;
1426
+ const idx = Number(v) - 1;
1427
+ if (Number.isInteger(idx) && idx >= 0 && idx < chosen.length) {
1428
+ return chosen[idx].id;
1429
+ }
1430
+ process.stdout.write(` Please pick a number 1-${chosen.length}.\n`);
1160
1431
  }
1161
- return endpoint;
1162
1432
  }
1163
1433
 
1164
1434
  /**
@@ -1168,7 +1438,7 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1168
1438
  */
1169
1439
  function initImageStep(): string | undefined | typeof ABORT {
1170
1440
  process.stdout.write(
1171
- '\nStep 3/3 - default machine image (an image with `pi` on PATH)\n',
1441
+ '\nStep 3/4 - default machine image (an image with `pi` on PATH)\n',
1172
1442
  );
1173
1443
  const menu = initImageMenu();
1174
1444
  menu.forEach((e, i) => {
@@ -1220,6 +1490,44 @@ function initImageStep(): string | undefined | typeof ABORT {
1220
1490
  }
1221
1491
  }
1222
1492
 
1493
+ /**
1494
+ * The PROJECTS-ROOT step: the host directory mounted into the jail at /projects
1495
+ * (pi's cwd; a project is /projects/<name>). It defaults to the built-in
1496
+ * `~/.anon-pi/projects/`; the user may point it at their own dev folder so bare
1497
+ * `anon-pi` works there without passing `--mount` every time. `--mount <parent>`
1498
+ * still overrides it per-launch. Returns the chosen root, or undefined to keep
1499
+ * the current/default (so an omitted `projects` in config.json means the
1500
+ * built-in default). Enter accepts the shown default.
1501
+ */
1502
+ function initProjectsStep(
1503
+ env: AnonPiEnv,
1504
+ currentProjects: string | undefined,
1505
+ ): string | undefined {
1506
+ process.stdout.write(
1507
+ '\nStep 4/4 - projects root (the host folder mounted at /projects)\n',
1508
+ );
1509
+ const builtin = builtinProjectsRoot(env);
1510
+ const shown = currentProjects ?? builtin;
1511
+ if (currentProjects) {
1512
+ process.stdout.write(` current: ${currentProjects}\n`);
1513
+ }
1514
+ process.stdout.write(
1515
+ ' This is where bare `anon-pi` looks for projects. Point it at your own\n' +
1516
+ ' dev folder to jail pi into files you edit with host tools; `--mount\n' +
1517
+ ' <parent>` still overrides it per-launch. Leave it at the default if\n' +
1518
+ " you're unsure.\n",
1519
+ );
1520
+ const ans = promptLine(` Projects root (Enter to keep ${shown}): `);
1521
+ if (ans === undefined) return currentProjects;
1522
+ const trimmed = ans.trim();
1523
+ if (trimmed === '') return currentProjects;
1524
+ // Store the built-in as "unset" (undefined) so config.json stays clean when
1525
+ // the user just accepts the default path explicitly.
1526
+ const chosen = resolve(trimmed);
1527
+ if (chosen === builtin) return undefined;
1528
+ return chosen;
1529
+ }
1530
+
1223
1531
  /**
1224
1532
  * Create or update the `default` machine: create it (dir + machine.json) if
1225
1533
  * absent, pinning the chosen image; if it already exists, re-pin the image only
@@ -1334,6 +1642,37 @@ function probeTcp(host: string, port: number): boolean {
1334
1642
  return socks5Handshake(host, port, [], 500) !== undefined;
1335
1643
  }
1336
1644
 
1645
+ /**
1646
+ * Best-effort SYNCHRONOUS HTTP GET of `http://<host:port>/v1/models` (the
1647
+ * OpenAI-compatible model listing llama.cpp / vLLM / LM Studio serve). Runs a
1648
+ * tiny worker on the same node binary (node has no sync HTTP), which fetches +
1649
+ * prints the body, so init stays synchronous. Returns the PARSED JSON body, or
1650
+ * undefined on any failure (unreachable / timeout / non-JSON) — init then falls
1651
+ * back to manual entry. This is a DIRECT LAN fetch on the operator's host at
1652
+ * init time (not inside the jail); it only ever touches the local-model
1653
+ * endpoint, the same host:port that becomes the one `--allow-direct` hole.
1654
+ */
1655
+ function fetchModelsListing(host: string, port: number): unknown {
1656
+ const timeoutMs = 3000;
1657
+ const script =
1658
+ `const http=require('http');` +
1659
+ `const req=http.get({host:${JSON.stringify(host)},port:${port},path:'/v1/models',timeout:${timeoutMs}},(res)=>{` +
1660
+ `let b='';res.on('data',(c)=>{b+=c;if(b.length>1_000_000)req.destroy()});` +
1661
+ `res.on('end',()=>{process.stdout.write(b);process.exit(0)})});` +
1662
+ `req.on('timeout',()=>{req.destroy();process.exit(1)});` +
1663
+ `req.on('error',()=>process.exit(1));`;
1664
+ try {
1665
+ const out = execFileSync(process.execPath, ['-e', script], {
1666
+ encoding: 'utf8',
1667
+ timeout: timeoutMs + 1500,
1668
+ maxBuffer: 4 * 1024 * 1024,
1669
+ });
1670
+ return JSON.parse(out);
1671
+ } catch {
1672
+ return undefined;
1673
+ }
1674
+ }
1675
+
1337
1676
  /**
1338
1677
  * Observe LOCAL process names (best-effort) so init can offer WEAK hints (a
1339
1678
  * running `tor` -> likely Tor). Returns the lowercased process names seen, or []