anon-pi 0.5.0 → 0.7.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/dist/cli.js CHANGED
@@ -13,8 +13,8 @@
13
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
14
14
  import { readSync } from 'node:fs';
15
15
  import { spawnSync, execFileSync } from 'node:child_process';
16
- import { join, dirname } from 'node:path';
17
- import { AnonPiError, HELP, MODELS_FILE, SEED_MARKER, DEFAULT_MACHINE, envFromProcess, buildMenuChoiceList, buildMenuEntries, deriveProjectUsage, machineDir, machineHomeDir, machineJsonPath, machineSessionsDir, validateName, resolveDeleteHome, resolveDeleteProject, parseConfigJson, parseLaunchArgs, parseMachineArgs, parseMachineJson, projectHostDir, resolveAnonPiHome, resolveLlm, resolveProjectsRoot, resolveProxy, resolveRunPlan, resolveRunVsStart, serializeMachineJson, serializeConfigJson, setImageWarning, keptContainerKey, DEFAULT_SOCKS_PROBE_PORTS, SOCKS5_METHOD_SELECTOR, formatProxyFindings, interpretSocks5Handshake, initImageMenu, generateModelsJson, parseVerifyExitIp, processHint, socks5hUrl, hostPortKey, shippedDockerfilePath, shippedWebveilDockerfilePath, } from './anon-pi.js';
16
+ import { join, dirname, resolve } from 'node:path';
17
+ import { AnonPiError, HELP, MODELS_FILE, SETTINGS_FILE, SETTINGS_SEED_FILE, SEED_MARKER, DEFAULT_MACHINE, envFromProcess, buildMenuChoiceList, buildMenuEntries, builtinProjectsRoot, deriveProjectUsage, globalModelsSeedPath, globalSettingsSeedPath, machineAgentDir, machineDir, machineHomeDir, machineJsonPath, machineModelsSeedPath, machineSessionsDir, mergeModelSelection, resolveModelsSeedPath, resolveSettingsSeedPath, validateName, resolveDeleteHome, resolveDeleteProject, parseConfigJson, parseLaunchArgs, parseMachineArgs, parseMachineJson, projectHostDir, resolveAnonPiHome, resolveLlm, resolveProjectsRoot, resolveProxy, resolveRunPlan, resolveRunVsStart, serializeMachineJson, serializeConfigJson, setImageWarning, keptContainerKey, DEFAULT_SOCKS_PROBE_PORTS, SOCKS5_METHOD_SELECTOR, formatProxyFindings, interpretSocks5Handshake, initImageMenu, generateModelsJson, generateModelSelection, pickLocalProviderModels, parseModelsListing, mergeModelSources, resolveHostModelsPath, LOCAL_PROVIDER_API_KEY, parseVerifyExitIp, processHint, socks5hUrl, hostPortKey, shippedDockerfilePath, shippedWebveilDockerfilePath, } from './anon-pi.js';
18
18
  // The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
19
19
  // so a `--keep` re-entry can find and `netcage start` the same kept container.
20
20
  // netcage's `netcage.managed` label marks it a managed container; this adds the
@@ -62,8 +62,53 @@ function main(argv) {
62
62
  catch (e) {
63
63
  return reportAnonPiError(e);
64
64
  }
65
+ // FIRST RUN: no config.json yet. Rather than fail deep in the launch with the
66
+ // bare "set ANON_PI_PROXY" wall (which reads like a doc dump the first time),
67
+ // welcome the user and run `init` automatically, then continue into the
68
+ // launch they asked for. Needs a TTY (init is interactive); without one we
69
+ // fall through to the launch path, whose fail-closed proxy error is the right
70
+ // signal for a script. An explicit ANON_PI_PROXY/ANON_PI_LLM env pair also
71
+ // skips this (the user is driving config via env, not the file).
72
+ if (isFirstRun()) {
73
+ const code = runFirstRunInit();
74
+ if (code !== 0)
75
+ return code; // init aborted / failed: do not launch.
76
+ }
65
77
  return runLaunch(parsed);
66
78
  }
79
+ /**
80
+ * First run = no config.json in the anon-pi home AND the user has not supplied
81
+ * the forced-egress inputs via env (ANON_PI_PROXY is what the launch fails
82
+ * closed on; if it is set the user is configuring via env and we do not
83
+ * onboard). We only auto-onboard on an interactive terminal.
84
+ */
85
+ function isFirstRun() {
86
+ const env = envFromProcess(process.env);
87
+ if (nonEmptyEnv(env.proxy))
88
+ return false; // env-driven config; no onboarding.
89
+ if (!process.stdin.isTTY)
90
+ return false; // scripts get the fail-closed error.
91
+ const configPath = join(resolveAnonPiHome(env), 'config.json');
92
+ return !existsSync(configPath);
93
+ }
94
+ /** Show a first-time welcome, then run `init`. Returns init's exit code. */
95
+ function runFirstRunInit() {
96
+ process.stdout.write('\n' +
97
+ "Welcome to anon-pi. It looks like this is your first run (there's no\n" +
98
+ 'config yet), so let us set things up before launching.\n' +
99
+ '\n' +
100
+ 'anon-pi runs pi on anonymized, jailed MACHINES: all of pi\u2019s web/DNS egress\n' +
101
+ 'is forced through your socks5h proxy (fail-closed), with ONE direct hole to\n' +
102
+ 'a local model. Your machines + conversations live in ~/.anon-pi/.\n' +
103
+ '\n' +
104
+ 'Running `anon-pi init` now (re-runnable any time; nothing is destroyed).\n' +
105
+ '\n');
106
+ return runInit([]);
107
+ }
108
+ /** Whether an env value is present + non-blank. */
109
+ function nonEmptyEnv(v) {
110
+ return typeof v === 'string' && v.trim() !== '';
111
+ }
67
112
  // --- the launch path --------------------------------------------------------
68
113
  function runLaunch(parsed) {
69
114
  const env = envFromProcess(process.env);
@@ -100,9 +145,12 @@ function runLaunch(parsed) {
100
145
  });
101
146
  const home = machineHomeDir(env, machineName);
102
147
  const machine = { name: machineName, home, image };
103
- // The generated models.json for this machine (mounted read-only for the
104
- // first-launch seed) when present. Keyed per machine, not per import.
105
- const modelsSeed = join(machineDir(env, machineName), MODELS_FILE);
148
+ // The local-model models.json + settings seed for this machine's FRESH-home
149
+ // promotion. GLOBAL by default (<home>/models.json, shared across every
150
+ // machine because the `llm` endpoint is global), with an optional
151
+ // per-machine override (machines/<M>/models.json). Mounted read-only.
152
+ const modelsSeed = resolveModelsSeedPath(env, machineName, existsSync);
153
+ const settingsSeed = resolveSettingsSeedPath(env, machineName, existsSync);
106
154
  intent = {
107
155
  machine,
108
156
  mode: parsed.mode,
@@ -113,7 +161,8 @@ function runLaunch(parsed) {
113
161
  keep: parsed.keep,
114
162
  proxy,
115
163
  llmDirect: llm,
116
- modelsSeed: existsSync(modelsSeed) ? modelsSeed : undefined,
164
+ modelsSeed,
165
+ settingsSeed,
117
166
  };
118
167
  }
119
168
  catch (e) {
@@ -712,17 +761,34 @@ WHAT IT DOES
712
761
  1. PROXY: probes common SOCKS ports, confirms SOCKS5 via a real handshake,
713
762
  shows the findings (EVIDENCE only, never a provider label), then runs
714
763
  \`netcage verify\` and shows the real EXIT IP as proof. You confirm.
715
- 2. LOCAL MODEL: captures host:port, probes reachability, generates models.json.
764
+ 2. LOCAL MODEL: captures host:port, probes it, then IMPORTS models. It merges
765
+ your pi config's matching provider ([configured], well-tuned) with the
766
+ endpoint's live /v1/models ([server]); you pick which to import + the
767
+ default. Only the provider served by this endpoint (the one --allow-direct
768
+ hole) is ever read, so no other provider or key can enter the seed. Writes
769
+ models.json + a settings seed (the default-model selection).
716
770
  3. IMAGE: pick a shipped Dockerfile (built via podman), an existing ref, or skip.
771
+ 4. PROJECTS ROOT: the host folder mounted at /projects (default ~/.anon-pi/
772
+ projects); point it at your own dev folder, or keep the default.
717
773
  Then writes ~/.anon-pi/config.json + the \`default\` machine. Never destroys homes.
774
+
775
+ Runs AUTOMATICALLY the first time you launch anon-pi with no config yet.
776
+
777
+ FLAGS
778
+ --force-allow-local-llm-api-key carry a REAL apiKey from the matching host
779
+ provider into the seed (init refuses by default: a host credential should
780
+ not enter the anonymized machine home unless you say so).
718
781
  `;
719
782
  function runInit(args) {
720
783
  if (args.includes('--help') || args.includes('-h')) {
721
784
  process.stdout.write(INIT_HELP);
722
785
  return 0;
723
786
  }
724
- if (args.length > 0) {
725
- process.stderr.write(`anon-pi: init takes no arguments, got: ${args.join(' ')}. Run \`anon-pi init --help\`.\n`);
787
+ const FORCE_KEY_FLAG = '--force-allow-local-llm-api-key';
788
+ const forceLocalApiKey = args.includes(FORCE_KEY_FLAG);
789
+ const extra = args.filter((a) => a !== FORCE_KEY_FLAG);
790
+ if (extra.length > 0) {
791
+ process.stderr.write(`anon-pi: init takes no arguments (except ${FORCE_KEY_FLAG}), got: ${extra.join(' ')}. Run \`anon-pi init --help\`.\n`);
726
792
  return 1;
727
793
  }
728
794
  if (!process.stdin.isTTY) {
@@ -741,15 +807,22 @@ function runInit(args) {
741
807
  if (proxyHostPort === undefined)
742
808
  return 1;
743
809
  const proxyUrl = socks5hUrl(proxyHostPort);
744
- // 2) LOCAL MODEL endpoint: capture + probe + generate models.json.
745
- const llm = initLlmStep(current.llm);
746
- // llm may be undefined if the user skipped it (the launch path still errors
747
- // without one, but init lets you set it later; we do not force it here).
810
+ // 2) LOCAL MODEL endpoint + model import: capture the endpoint, then merge the
811
+ // host config's matching provider (well-tuned) with the endpoint's live
812
+ // /v1/models, let the user pick which to import + the default. May ABORT
813
+ // (a real host apiKey without --force-allow-local-llm-api-key).
814
+ const llmResult = initLlmStep(env, current.llm, forceLocalApiKey);
815
+ if (llmResult === ABORT)
816
+ return 1;
817
+ const llm = llmResult.endpoint;
748
818
  // 3) DEFAULT MACHINE IMAGE: menu (shipped Dockerfiles / existing ref / skip).
749
819
  const image = initImageStep();
750
820
  if (image === ABORT)
751
821
  return 1;
752
- // 4) WRITE config.json + the `default` machine (never destroying an existing
822
+ // 4) PROJECTS ROOT: the host dir mounted at /projects (default ~/.anon-pi/
823
+ // projects). Overridable per-launch with `--mount`; this sets the default.
824
+ const projects = initProjectsStep(env, current.projects);
825
+ // 5) WRITE config.json + the `default` machine (never destroying an existing
753
826
  // home). The proxy is always present (we only reach here on a chosen proxy).
754
827
  const anonHome = resolveAnonPiHome(env);
755
828
  mkdirSync(anonHome, { recursive: true });
@@ -758,27 +831,100 @@ function runInit(args) {
758
831
  proxy: proxyUrl,
759
832
  llm: llm ?? current.llm,
760
833
  defaultMachine: current.defaultMachine ?? DEFAULT_MACHINE,
761
- projects: current.projects,
834
+ projects: projects ?? current.projects,
762
835
  };
763
836
  writeFileSync(configPath, serializeConfigJson(nextConfig));
764
837
  process.stdout.write(`\nanon-pi: wrote ${configPath}.\n`);
765
838
  // The `default` machine: create it if absent (NEVER wipe an existing home),
766
839
  // pin/re-pin its image when one was chosen. Its home seeds on first launch.
767
840
  initWriteDefaultMachine(env, image);
768
- // The per-machine models.json seed for the default machine, generated from the
769
- // captured endpoint (this is the `import` replacement). Only when we have an
770
- // endpoint; written next to the machine so the first-launch seed mounts it.
771
- if ((llm ?? current.llm) !== undefined) {
772
- const mdir = machineDir(env, DEFAULT_MACHINE);
773
- mkdirSync(mdir, { recursive: true });
774
- const models = generateModelsJson((llm ?? current.llm));
775
- writeFileSync(join(mdir, MODELS_FILE), JSON.stringify(models, null, '\t') + '\n');
776
- process.stdout.write(`anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}".\n`);
841
+ // The GLOBAL local-model models.json + settings seed, generated from the
842
+ // captured endpoint + the CHOSEN models (this is the `import` replacement).
843
+ const endpoint = llm ?? current.llm;
844
+ if (endpoint !== undefined) {
845
+ const models = generateModelsJson(endpoint, llmResult.models, llmResult.apiKey);
846
+ const modelsBody = JSON.stringify(models, null, '\t') + '\n';
847
+ // GLOBAL seed: the local model is a workspace-level thing (config.llm is
848
+ // global), so its models.json lives once at the workspace root and seeds
849
+ // EVERY machine's fresh home. A machine may still override with its own
850
+ // machines/<M>/models.json.
851
+ mkdirSync(resolveAnonPiHome(env), { recursive: true });
852
+ writeFileSync(globalModelsSeedPath(env), modelsBody);
853
+ // Migration: earlier versions wrote this seed under machines/default/. Now
854
+ // that it is global, remove the old default-machine copy so `default`
855
+ // picks up the global seed like every other machine (leaving it would look
856
+ // like a deliberate per-machine override and shadow the global one). Only
857
+ // the `default` machine's init-generated copy is migrated; a per-machine
858
+ // override you created for ANY OTHER machine is left untouched.
859
+ for (const stale of [
860
+ machineModelsSeedPath(env, DEFAULT_MACHINE),
861
+ join(machineDir(env, DEFAULT_MACHINE), SETTINGS_SEED_FILE),
862
+ ]) {
863
+ if (existsSync(stale))
864
+ rmSync(stale, { force: true });
865
+ }
866
+ process.stdout.write(`anon-pi: wrote the global local-model models.json ` +
867
+ `(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}; shared by all machines).\n`);
868
+ // settings.json seed: the default model + enabledModels for the imported
869
+ // set. The first-launch promotion merges it into each home's settings (so
870
+ // image-staged packages/extensions survive). Only when the user picked at
871
+ // least one model + a default.
872
+ const selection = llmResult.defaultId !== undefined && llmResult.models.length > 0
873
+ ? generateModelSelection(llmResult.models.map((m) => m.id), llmResult.defaultId)
874
+ : undefined;
875
+ if (selection) {
876
+ writeFileSync(globalSettingsSeedPath(env), JSON.stringify(selection, null, '\t') + '\n');
877
+ process.stdout.write(`anon-pi: default model set to "${llmResult.defaultId}".\n`);
878
+ }
879
+ // Re-run reconfigure: the seed above only takes effect on a FRESH home
880
+ // (the first-launch promotion is marker-guarded). Apply the new
881
+ // models.json + settings selection DIRECTLY to EVERY already-seeded machine
882
+ // home now, so re-running `init` updates existing environments without
883
+ // wiping conversations (init runs on the host; homes are host dirs). Fresh
884
+ // (unseeded) homes are left to the launch-time seed. A machine with its OWN
885
+ // per-machine models.json override is skipped (its local model differs).
886
+ const updated = applyModelsToSeededHomes(env, modelsBody, selection);
887
+ if (updated.length > 0) {
888
+ process.stdout.write(`anon-pi: updated ${updated.length} existing machine home${updated.length === 1 ? '' : 's'} ` +
889
+ `(${updated.join(', ')}); conversations untouched.\n`);
890
+ }
777
891
  }
778
892
  process.stdout.write('\nanon-pi: onboarding complete. Launch with `anon-pi <project>` or ' +
779
893
  '`anon-pi --shell`.\n');
780
894
  return 0;
781
895
  }
896
+ /**
897
+ * Apply the freshly-generated GLOBAL local-model config to EVERY already-seeded
898
+ * machine home directly (init runs on the host; homes are host dirs). The
899
+ * launch-time seed only promotes on a FRESH home (marker-guarded), so without
900
+ * this a re-run of `init` would update the global seed but never reach existing
901
+ * homes. For each machine: skip a FRESH home (no marker — the launch seed will
902
+ * pick up the new global seed), skip a machine with its OWN per-machine
903
+ * models.json override (its local model differs on purpose), else overwrite the
904
+ * home's models.json and merge the settings selection. Conversations/sessions
905
+ * are untouched. Returns the machine names updated.
906
+ */
907
+ function applyModelsToSeededHomes(env, modelsBody, selection) {
908
+ const updated = [];
909
+ for (const machine of listMachineNames(env).sort()) {
910
+ const agentDir = machineAgentDir(env, machine);
911
+ // Only an already-seeded home (marker present).
912
+ if (!existsSync(join(agentDir, SEED_MARKER)))
913
+ continue;
914
+ // A machine that deliberately overrides the global models.json keeps its
915
+ // own local model; do not clobber its home with the global one.
916
+ if (existsSync(machineModelsSeedPath(env, machine)))
917
+ continue;
918
+ writeFileSync(join(agentDir, MODELS_FILE), modelsBody);
919
+ if (selection) {
920
+ const settingsPath = join(agentDir, SETTINGS_FILE);
921
+ const merged = mergeModelSelection(readJsonFile(settingsPath), selection);
922
+ writeFileSync(settingsPath, JSON.stringify(merged, null, '\t') + '\n');
923
+ }
924
+ updated.push(machine);
925
+ }
926
+ return updated;
927
+ }
782
928
  /** A sentinel a step returns when the user aborted (distinct from "skipped"). */
783
929
  const ABORT = Symbol('abort');
784
930
  /**
@@ -790,7 +936,7 @@ const ABORT = Symbol('abort');
790
936
  * + the handshake verdict come from the pure module.
791
937
  */
792
938
  function initProxyStep(currentProxy) {
793
- process.stdout.write('Step 1/3 - proxy (the socks5h endpoint that anonymizes egress)\n');
939
+ process.stdout.write('Step 1/4 - proxy (the socks5h endpoint that anonymizes egress)\n');
794
940
  if (currentProxy) {
795
941
  process.stdout.write(` current: ${currentProxy}\n`);
796
942
  }
@@ -893,37 +1039,160 @@ function initProxyStep(currentProxy) {
893
1039
  }
894
1040
  /**
895
1041
  * The LOCAL MODEL step: capture host:port (pre-filled from config), probe TCP
896
- * reachability (evidence, not a gate), and return the endpoint (undefined if the
897
- * user skips it). models.json is generated from it by runInit. The one direct
898
- * hole; all other egress stays proxied.
1042
+ * reachability, then IMPORT models. It merges TWO sources, both scoped to the
1043
+ * endpoint (the one `--allow-direct` hole, so no other provider can enter the
1044
+ * seed): the host `~/.pi/agent/models.json` provider whose baseUrl matches the
1045
+ * endpoint (well-tuned entries, marked [configured]) and the endpoint's live
1046
+ * `/v1/models` (bare ids, marked [server]). The user picks which to import and
1047
+ * the default. Returns the endpoint + chosen entries + apiKey + default, or
1048
+ * ABORT (a real host apiKey without --force-allow-local-llm-api-key).
899
1049
  */
900
- function initLlmStep(currentLlm) {
901
- process.stdout.write('\nStep 2/3 - local model endpoint (the ONE direct hole)\n');
1050
+ function initLlmStep(env, currentLlm, forceLocalApiKey) {
1051
+ process.stdout.write('\nStep 2/4 - local model endpoint (the ONE direct hole)\n');
902
1052
  if (currentLlm)
903
1053
  process.stdout.write(` current: ${currentLlm}\n`);
904
1054
  const prefill = currentLlm
905
1055
  ? ` (or Enter to keep ${currentLlm})`
906
1056
  : ' (or Enter to skip)';
907
1057
  const ans = promptLine(` Local model host:port, e.g. 192.168.1.150:8080${prefill}: `);
908
- if (ans === undefined)
909
- return currentLlm;
910
- const trimmed = ans.trim();
911
- if (trimmed === '')
912
- return currentLlm;
913
- const endpoint = trimmed;
1058
+ const raw = ans === undefined ? '' : ans.trim();
1059
+ const endpoint = raw === '' ? currentLlm : raw;
1060
+ if (endpoint === undefined) {
1061
+ // No endpoint at all: nothing to import.
1062
+ return {
1063
+ endpoint: undefined,
1064
+ models: [],
1065
+ apiKey: LOCAL_PROVIDER_API_KEY,
1066
+ defaultId: undefined,
1067
+ };
1068
+ }
914
1069
  // Probe reachability: evidence only. A closed port is not fatal (the model may
915
1070
  // start later); we just report it.
916
1071
  const key = hostPortKey(endpoint);
917
1072
  const colon = key.lastIndexOf(':');
918
1073
  const host = colon > 0 ? key.slice(0, colon) : key;
919
1074
  const port = colon > 0 ? Number(key.slice(colon + 1)) : 80;
1075
+ const reachable = Number.isFinite(port) ? probeTcp(host, port) : false;
1076
+ process.stdout.write(reachable
1077
+ ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
1078
+ : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`);
1079
+ // SOURCE A: the host models.json provider matching this endpoint (only that
1080
+ // one — the anonymity scoping). Its apiKey is checked: a REAL key is refused
1081
+ // unless --force-allow-local-llm-api-key.
1082
+ const hostModels = readJsonFile(resolveHostModelsPath(env));
1083
+ const match = pickLocalProviderModels(hostModels ?? {}, endpoint);
1084
+ let apiKey = LOCAL_PROVIDER_API_KEY;
1085
+ if (match && match.apiKeyLooksReal) {
1086
+ if (!forceLocalApiKey) {
1087
+ process.stderr.write(`\n anon-pi: the matching provider in your pi config carries a real-looking\n` +
1088
+ ` apiKey. Seeding it would put a host credential into the anonymized machine\n` +
1089
+ ` home. Refusing. If this key is genuinely safe for the local model, re-run\n` +
1090
+ ` \`anon-pi init --force-allow-local-llm-api-key\` to carry it through.\n`);
1091
+ return ABORT;
1092
+ }
1093
+ apiKey = (match.apiKey ?? LOCAL_PROVIDER_API_KEY).trim();
1094
+ process.stdout.write(' WARNING: carrying the host provider apiKey into the seed (--force-allow-local-llm-api-key).\n');
1095
+ }
1096
+ const hostModelEntries = match?.models ?? [];
1097
+ // SOURCE B: the endpoint's live /v1/models (bare ids).
1098
+ let serverIds = [];
920
1099
  if (Number.isFinite(port)) {
921
- const reachable = probeTcp(host, port);
922
- process.stdout.write(reachable
923
- ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
924
- : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`);
1100
+ const listing = fetchModelsListing(host, port);
1101
+ serverIds = parseModelsListing(listing);
1102
+ }
1103
+ if (hostModelEntries.length === 0 && serverIds.length === 0) {
1104
+ process.stdout.write(' No models found (no matching provider in your pi config, and the server\n' +
1105
+ ' returned none). The provider is still seeded; add models in pi later.\n');
1106
+ return { endpoint, models: [], apiKey, defaultId: undefined };
1107
+ }
1108
+ const candidates = mergeModelSources(hostModelEntries, serverIds);
1109
+ const chosen = initModelPicker(candidates);
1110
+ if (chosen === undefined) {
1111
+ // Skip: seed the provider with no models.
1112
+ return { endpoint, models: [], apiKey, defaultId: undefined };
1113
+ }
1114
+ const defaultId = initDefaultModelPicker(chosen);
1115
+ return { endpoint, models: chosen, apiKey, defaultId };
1116
+ }
1117
+ /**
1118
+ * Present the merged candidate list and let the user choose which to import.
1119
+ * Options: Enter/`c` = all CONFIGURED (host-tuned; the safe default), `a` = ALL
1120
+ * (server + configured), space/comma-separated NUMBERS = those, `s` = skip.
1121
+ * Returns the chosen entries, or undefined to skip (seed no models).
1122
+ */
1123
+ function initModelPicker(candidates) {
1124
+ const configured = candidates.filter((c) => c.configured);
1125
+ process.stdout.write('\n Models served by this endpoint:\n');
1126
+ candidates.forEach((c, i) => {
1127
+ const tag = c.configured ? '[configured]' : '[server]';
1128
+ process.stdout.write(` [${i + 1}] ${c.id} ${tag}\n`);
1129
+ });
1130
+ process.stdout.write(' [configured] = from your pi config (well-tuned); [server] = the server\n' +
1131
+ ' also reports it (a minimal entry is synthesized).\n');
1132
+ const hasConfigured = configured.length > 0;
1133
+ const defaultHint = hasConfigured
1134
+ ? 'Enter/c = all configured, a = all, numbers = pick, s = skip'
1135
+ : 'Enter/a = all, numbers = pick, s = skip';
1136
+ for (;;) {
1137
+ const ans = promptLine(` Import which? (${defaultHint}): `);
1138
+ const v = (ans ?? '').trim().toLowerCase();
1139
+ if (v === 's')
1140
+ return undefined;
1141
+ if (v === '' && hasConfigured)
1142
+ return configured.map((c) => c.entry);
1143
+ if (v === 'c') {
1144
+ if (!hasConfigured) {
1145
+ process.stdout.write(' No [configured] models; pick numbers or `a` for all.\n');
1146
+ continue;
1147
+ }
1148
+ return configured.map((c) => c.entry);
1149
+ }
1150
+ if (v === 'a' || (v === '' && !hasConfigured)) {
1151
+ return candidates.map((c) => c.entry);
1152
+ }
1153
+ // Numbers (space/comma separated).
1154
+ const picks = v
1155
+ .split(/[\s,]+/)
1156
+ .filter((t) => t !== '')
1157
+ .map((t) => Number(t) - 1);
1158
+ if (picks.length > 0 &&
1159
+ picks.every((i) => i >= 0 && i < candidates.length)) {
1160
+ // De-dup, keep list order.
1161
+ const seen = new Set();
1162
+ const out = [];
1163
+ for (const i of picks) {
1164
+ if (!seen.has(i)) {
1165
+ seen.add(i);
1166
+ out.push(candidates[i].entry);
1167
+ }
1168
+ }
1169
+ return out;
1170
+ }
1171
+ process.stdout.write(` Please enter Enter/c/a/s or numbers 1-${candidates.length}.\n`);
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Pick the DEFAULT model among the chosen ones. Defaults to the first (Enter);
1176
+ * accepts a number. Returns the chosen id.
1177
+ */
1178
+ function initDefaultModelPicker(chosen) {
1179
+ if (chosen.length === 1)
1180
+ return chosen[0].id;
1181
+ process.stdout.write('\n Which is the DEFAULT model?\n');
1182
+ chosen.forEach((m, i) => {
1183
+ process.stdout.write(` [${i + 1}] ${m.id}\n`);
1184
+ });
1185
+ for (;;) {
1186
+ const ans = promptLine(` Default [1-${chosen.length}] (Enter = ${chosen[0].id}): `);
1187
+ const v = (ans ?? '').trim();
1188
+ if (v === '')
1189
+ return chosen[0].id;
1190
+ const idx = Number(v) - 1;
1191
+ if (Number.isInteger(idx) && idx >= 0 && idx < chosen.length) {
1192
+ return chosen[idx].id;
1193
+ }
1194
+ process.stdout.write(` Please pick a number 1-${chosen.length}.\n`);
925
1195
  }
926
- return endpoint;
927
1196
  }
928
1197
  /**
929
1198
  * The IMAGE step: the pure menu (shipped Dockerfiles / existing ref / skip), then
@@ -931,7 +1200,7 @@ function initLlmStep(currentLlm) {
931
1200
  * skip). Returns the resolved image ref, undefined for skip, or ABORT.
932
1201
  */
933
1202
  function initImageStep() {
934
- process.stdout.write('\nStep 3/3 - default machine image (an image with `pi` on PATH)\n');
1203
+ process.stdout.write('\nStep 3/4 - default machine image (an image with `pi` on PATH)\n');
935
1204
  const menu = initImageMenu();
936
1205
  menu.forEach((e, i) => {
937
1206
  process.stdout.write(` [${i + 1}] ${e.label}\n`);
@@ -976,6 +1245,39 @@ function initImageStep() {
976
1245
  return tag;
977
1246
  }
978
1247
  }
1248
+ /**
1249
+ * The PROJECTS-ROOT step: the host directory mounted into the jail at /projects
1250
+ * (pi's cwd; a project is /projects/<name>). It defaults to the built-in
1251
+ * `~/.anon-pi/projects/`; the user may point it at their own dev folder so bare
1252
+ * `anon-pi` works there without passing `--mount` every time. `--mount <parent>`
1253
+ * still overrides it per-launch. Returns the chosen root, or undefined to keep
1254
+ * the current/default (so an omitted `projects` in config.json means the
1255
+ * built-in default). Enter accepts the shown default.
1256
+ */
1257
+ function initProjectsStep(env, currentProjects) {
1258
+ process.stdout.write('\nStep 4/4 - projects root (the host folder mounted at /projects)\n');
1259
+ const builtin = builtinProjectsRoot(env);
1260
+ const shown = currentProjects ?? builtin;
1261
+ if (currentProjects) {
1262
+ process.stdout.write(` current: ${currentProjects}\n`);
1263
+ }
1264
+ process.stdout.write(' This is where bare `anon-pi` looks for projects. Point it at your own\n' +
1265
+ ' dev folder to jail pi into files you edit with host tools; `--mount\n' +
1266
+ ' <parent>` still overrides it per-launch. Leave it at the default if\n' +
1267
+ " you're unsure.\n");
1268
+ const ans = promptLine(` Projects root (Enter to keep ${shown}): `);
1269
+ if (ans === undefined)
1270
+ return currentProjects;
1271
+ const trimmed = ans.trim();
1272
+ if (trimmed === '')
1273
+ return currentProjects;
1274
+ // Store the built-in as "unset" (undefined) so config.json stays clean when
1275
+ // the user just accepts the default path explicitly.
1276
+ const chosen = resolve(trimmed);
1277
+ if (chosen === builtin)
1278
+ return undefined;
1279
+ return chosen;
1280
+ }
979
1281
  /**
980
1282
  * Create or update the `default` machine: create it (dir + machine.json) if
981
1283
  * absent, pinning the chosen image; if it already exists, re-pin the image only
@@ -1067,6 +1369,36 @@ function socks5Handshake(host, port, greeting, timeoutMs) {
1067
1369
  function probeTcp(host, port) {
1068
1370
  return socks5Handshake(host, port, [], 500) !== undefined;
1069
1371
  }
1372
+ /**
1373
+ * Best-effort SYNCHRONOUS HTTP GET of `http://<host:port>/v1/models` (the
1374
+ * OpenAI-compatible model listing llama.cpp / vLLM / LM Studio serve). Runs a
1375
+ * tiny worker on the same node binary (node has no sync HTTP), which fetches +
1376
+ * prints the body, so init stays synchronous. Returns the PARSED JSON body, or
1377
+ * undefined on any failure (unreachable / timeout / non-JSON) — init then falls
1378
+ * back to manual entry. This is a DIRECT LAN fetch on the operator's host at
1379
+ * init time (not inside the jail); it only ever touches the local-model
1380
+ * endpoint, the same host:port that becomes the one `--allow-direct` hole.
1381
+ */
1382
+ function fetchModelsListing(host, port) {
1383
+ const timeoutMs = 3000;
1384
+ const script = `const http=require('http');` +
1385
+ `const req=http.get({host:${JSON.stringify(host)},port:${port},path:'/v1/models',timeout:${timeoutMs}},(res)=>{` +
1386
+ `let b='';res.on('data',(c)=>{b+=c;if(b.length>1_000_000)req.destroy()});` +
1387
+ `res.on('end',()=>{process.stdout.write(b);process.exit(0)})});` +
1388
+ `req.on('timeout',()=>{req.destroy();process.exit(1)});` +
1389
+ `req.on('error',()=>process.exit(1));`;
1390
+ try {
1391
+ const out = execFileSync(process.execPath, ['-e', script], {
1392
+ encoding: 'utf8',
1393
+ timeout: timeoutMs + 1500,
1394
+ maxBuffer: 4 * 1024 * 1024,
1395
+ });
1396
+ return JSON.parse(out);
1397
+ }
1398
+ catch {
1399
+ return undefined;
1400
+ }
1401
+ }
1070
1402
  /**
1071
1403
  * Observe LOCAL process names (best-effort) so init can offer WEAK hints (a
1072
1404
  * running `tor` -> likely Tor). Returns the lowercased process names seen, or []