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/README.md +11 -4
- package/dist/anon-pi.d.ts +179 -13
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +281 -15
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +374 -42
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/anon-pi.ts +372 -16
- package/src/cli.ts +457 -41
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
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
725
|
-
|
|
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
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
//
|
|
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)
|
|
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
|
|
769
|
-
// captured endpoint (this is the `import` replacement).
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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/
|
|
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
|
|
897
|
-
*
|
|
898
|
-
*
|
|
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/
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
return
|
|
913
|
-
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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/
|
|
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 []
|