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/src/cli.ts
CHANGED
|
@@ -21,21 +21,31 @@ 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_FILE,
|
|
30
|
+
SETTINGS_SEED_FILE,
|
|
29
31
|
SEED_MARKER,
|
|
30
32
|
DEFAULT_MACHINE,
|
|
31
33
|
envFromProcess,
|
|
32
34
|
buildMenuChoiceList,
|
|
33
35
|
buildMenuEntries,
|
|
36
|
+
builtinProjectsRoot,
|
|
34
37
|
deriveProjectUsage,
|
|
38
|
+
globalModelsSeedPath,
|
|
39
|
+
globalSettingsSeedPath,
|
|
40
|
+
machineAgentDir,
|
|
35
41
|
machineDir,
|
|
36
42
|
machineHomeDir,
|
|
37
43
|
machineJsonPath,
|
|
44
|
+
machineModelsSeedPath,
|
|
38
45
|
machineSessionsDir,
|
|
46
|
+
mergeModelSelection,
|
|
47
|
+
resolveModelsSeedPath,
|
|
48
|
+
resolveSettingsSeedPath,
|
|
39
49
|
validateName,
|
|
40
50
|
resolveDeleteHome,
|
|
41
51
|
resolveDeleteProject,
|
|
@@ -60,6 +70,12 @@ import {
|
|
|
60
70
|
interpretSocks5Handshake,
|
|
61
71
|
initImageMenu,
|
|
62
72
|
generateModelsJson,
|
|
73
|
+
generateModelSelection,
|
|
74
|
+
pickLocalProviderModels,
|
|
75
|
+
parseModelsListing,
|
|
76
|
+
mergeModelSources,
|
|
77
|
+
resolveHostModelsPath,
|
|
78
|
+
LOCAL_PROVIDER_API_KEY,
|
|
63
79
|
parseVerifyExitIp,
|
|
64
80
|
processHint,
|
|
65
81
|
socks5hUrl,
|
|
@@ -73,12 +89,16 @@ import {
|
|
|
73
89
|
type InitImageChoice,
|
|
74
90
|
type AnonPiConfig,
|
|
75
91
|
type AnonPiEnv,
|
|
92
|
+
type GeneratedModel,
|
|
93
|
+
type ModelCandidate,
|
|
94
|
+
type ModelSelection,
|
|
76
95
|
type KeptContainer,
|
|
77
96
|
type LaunchIntent,
|
|
78
97
|
type Machine,
|
|
79
98
|
type MachineConfig,
|
|
80
99
|
type MachineCommand,
|
|
81
100
|
type ParsedLaunch,
|
|
101
|
+
type PiModelsFile,
|
|
82
102
|
} from './anon-pi.js';
|
|
83
103
|
|
|
84
104
|
// The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
|
|
@@ -136,9 +156,57 @@ function main(argv: string[]): number {
|
|
|
136
156
|
return reportAnonPiError(e);
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
// FIRST RUN: no config.json yet. Rather than fail deep in the launch with the
|
|
160
|
+
// bare "set ANON_PI_PROXY" wall (which reads like a doc dump the first time),
|
|
161
|
+
// welcome the user and run `init` automatically, then continue into the
|
|
162
|
+
// launch they asked for. Needs a TTY (init is interactive); without one we
|
|
163
|
+
// fall through to the launch path, whose fail-closed proxy error is the right
|
|
164
|
+
// signal for a script. An explicit ANON_PI_PROXY/ANON_PI_LLM env pair also
|
|
165
|
+
// skips this (the user is driving config via env, not the file).
|
|
166
|
+
if (isFirstRun()) {
|
|
167
|
+
const code = runFirstRunInit();
|
|
168
|
+
if (code !== 0) return code; // init aborted / failed: do not launch.
|
|
169
|
+
}
|
|
170
|
+
|
|
139
171
|
return runLaunch(parsed);
|
|
140
172
|
}
|
|
141
173
|
|
|
174
|
+
/**
|
|
175
|
+
* First run = no config.json in the anon-pi home AND the user has not supplied
|
|
176
|
+
* the forced-egress inputs via env (ANON_PI_PROXY is what the launch fails
|
|
177
|
+
* closed on; if it is set the user is configuring via env and we do not
|
|
178
|
+
* onboard). We only auto-onboard on an interactive terminal.
|
|
179
|
+
*/
|
|
180
|
+
function isFirstRun(): boolean {
|
|
181
|
+
const env = envFromProcess(process.env);
|
|
182
|
+
if (nonEmptyEnv(env.proxy)) return false; // env-driven config; no onboarding.
|
|
183
|
+
if (!process.stdin.isTTY) return false; // scripts get the fail-closed error.
|
|
184
|
+
const configPath = join(resolveAnonPiHome(env), 'config.json');
|
|
185
|
+
return !existsSync(configPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Show a first-time welcome, then run `init`. Returns init's exit code. */
|
|
189
|
+
function runFirstRunInit(): number {
|
|
190
|
+
process.stdout.write(
|
|
191
|
+
'\n' +
|
|
192
|
+
"Welcome to anon-pi. It looks like this is your first run (there's no\n" +
|
|
193
|
+
'config yet), so let us set things up before launching.\n' +
|
|
194
|
+
'\n' +
|
|
195
|
+
'anon-pi runs pi on anonymized, jailed MACHINES: all of pi\u2019s web/DNS egress\n' +
|
|
196
|
+
'is forced through your socks5h proxy (fail-closed), with ONE direct hole to\n' +
|
|
197
|
+
'a local model. Your machines + conversations live in ~/.anon-pi/.\n' +
|
|
198
|
+
'\n' +
|
|
199
|
+
'Running `anon-pi init` now (re-runnable any time; nothing is destroyed).\n' +
|
|
200
|
+
'\n',
|
|
201
|
+
);
|
|
202
|
+
return runInit([]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Whether an env value is present + non-blank. */
|
|
206
|
+
function nonEmptyEnv(v: string | undefined): boolean {
|
|
207
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
208
|
+
}
|
|
209
|
+
|
|
142
210
|
// --- the launch path --------------------------------------------------------
|
|
143
211
|
function runLaunch(parsed: ParsedLaunch): number {
|
|
144
212
|
const env = envFromProcess(process.env);
|
|
@@ -185,9 +253,12 @@ function runLaunch(parsed: ParsedLaunch): number {
|
|
|
185
253
|
const home = machineHomeDir(env, machineName);
|
|
186
254
|
const machine: Machine = {name: machineName, home, image};
|
|
187
255
|
|
|
188
|
-
// The
|
|
189
|
-
//
|
|
190
|
-
|
|
256
|
+
// The local-model models.json + settings seed for this machine's FRESH-home
|
|
257
|
+
// promotion. GLOBAL by default (<home>/models.json, shared across every
|
|
258
|
+
// machine because the `llm` endpoint is global), with an optional
|
|
259
|
+
// per-machine override (machines/<M>/models.json). Mounted read-only.
|
|
260
|
+
const modelsSeed = resolveModelsSeedPath(env, machineName, existsSync);
|
|
261
|
+
const settingsSeed = resolveSettingsSeedPath(env, machineName, existsSync);
|
|
191
262
|
|
|
192
263
|
intent = {
|
|
193
264
|
machine,
|
|
@@ -199,7 +270,8 @@ function runLaunch(parsed: ParsedLaunch): number {
|
|
|
199
270
|
keep: parsed.keep,
|
|
200
271
|
proxy,
|
|
201
272
|
llmDirect: llm,
|
|
202
|
-
modelsSeed
|
|
273
|
+
modelsSeed,
|
|
274
|
+
settingsSeed,
|
|
203
275
|
};
|
|
204
276
|
} catch (e) {
|
|
205
277
|
return reportAnonPiError(e);
|
|
@@ -900,9 +972,23 @@ WHAT IT DOES
|
|
|
900
972
|
1. PROXY: probes common SOCKS ports, confirms SOCKS5 via a real handshake,
|
|
901
973
|
shows the findings (EVIDENCE only, never a provider label), then runs
|
|
902
974
|
\`netcage verify\` and shows the real EXIT IP as proof. You confirm.
|
|
903
|
-
2. LOCAL MODEL: captures host:port, probes
|
|
975
|
+
2. LOCAL MODEL: captures host:port, probes it, then IMPORTS models. It merges
|
|
976
|
+
your pi config's matching provider ([configured], well-tuned) with the
|
|
977
|
+
endpoint's live /v1/models ([server]); you pick which to import + the
|
|
978
|
+
default. Only the provider served by this endpoint (the one --allow-direct
|
|
979
|
+
hole) is ever read, so no other provider or key can enter the seed. Writes
|
|
980
|
+
models.json + a settings seed (the default-model selection).
|
|
904
981
|
3. IMAGE: pick a shipped Dockerfile (built via podman), an existing ref, or skip.
|
|
982
|
+
4. PROJECTS ROOT: the host folder mounted at /projects (default ~/.anon-pi/
|
|
983
|
+
projects); point it at your own dev folder, or keep the default.
|
|
905
984
|
Then writes ~/.anon-pi/config.json + the \`default\` machine. Never destroys homes.
|
|
985
|
+
|
|
986
|
+
Runs AUTOMATICALLY the first time you launch anon-pi with no config yet.
|
|
987
|
+
|
|
988
|
+
FLAGS
|
|
989
|
+
--force-allow-local-llm-api-key carry a REAL apiKey from the matching host
|
|
990
|
+
provider into the seed (init refuses by default: a host credential should
|
|
991
|
+
not enter the anonymized machine home unless you say so).
|
|
906
992
|
`;
|
|
907
993
|
|
|
908
994
|
function runInit(args: string[]): number {
|
|
@@ -910,9 +996,12 @@ function runInit(args: string[]): number {
|
|
|
910
996
|
process.stdout.write(INIT_HELP);
|
|
911
997
|
return 0;
|
|
912
998
|
}
|
|
913
|
-
|
|
999
|
+
const FORCE_KEY_FLAG = '--force-allow-local-llm-api-key';
|
|
1000
|
+
const forceLocalApiKey = args.includes(FORCE_KEY_FLAG);
|
|
1001
|
+
const extra = args.filter((a) => a !== FORCE_KEY_FLAG);
|
|
1002
|
+
if (extra.length > 0) {
|
|
914
1003
|
process.stderr.write(
|
|
915
|
-
`anon-pi: init takes no arguments, got: ${
|
|
1004
|
+
`anon-pi: init takes no arguments (except ${FORCE_KEY_FLAG}), got: ${extra.join(' ')}. Run \`anon-pi init --help\`.\n`,
|
|
916
1005
|
);
|
|
917
1006
|
return 1;
|
|
918
1007
|
}
|
|
@@ -940,16 +1029,23 @@ function runInit(args: string[]): number {
|
|
|
940
1029
|
if (proxyHostPort === undefined) return 1;
|
|
941
1030
|
const proxyUrl = socks5hUrl(proxyHostPort);
|
|
942
1031
|
|
|
943
|
-
// 2) LOCAL MODEL endpoint: capture
|
|
944
|
-
|
|
945
|
-
//
|
|
946
|
-
//
|
|
1032
|
+
// 2) LOCAL MODEL endpoint + model import: capture the endpoint, then merge the
|
|
1033
|
+
// host config's matching provider (well-tuned) with the endpoint's live
|
|
1034
|
+
// /v1/models, let the user pick which to import + the default. May ABORT
|
|
1035
|
+
// (a real host apiKey without --force-allow-local-llm-api-key).
|
|
1036
|
+
const llmResult = initLlmStep(env, current.llm, forceLocalApiKey);
|
|
1037
|
+
if (llmResult === ABORT) return 1;
|
|
1038
|
+
const llm = llmResult.endpoint;
|
|
947
1039
|
|
|
948
1040
|
// 3) DEFAULT MACHINE IMAGE: menu (shipped Dockerfiles / existing ref / skip).
|
|
949
1041
|
const image = initImageStep();
|
|
950
1042
|
if (image === ABORT) return 1;
|
|
951
1043
|
|
|
952
|
-
// 4)
|
|
1044
|
+
// 4) PROJECTS ROOT: the host dir mounted at /projects (default ~/.anon-pi/
|
|
1045
|
+
// projects). Overridable per-launch with `--mount`; this sets the default.
|
|
1046
|
+
const projects = initProjectsStep(env, current.projects);
|
|
1047
|
+
|
|
1048
|
+
// 5) WRITE config.json + the `default` machine (never destroying an existing
|
|
953
1049
|
// home). The proxy is always present (we only reach here on a chosen proxy).
|
|
954
1050
|
const anonHome = resolveAnonPiHome(env);
|
|
955
1051
|
mkdirSync(anonHome, {recursive: true});
|
|
@@ -958,7 +1054,7 @@ function runInit(args: string[]): number {
|
|
|
958
1054
|
proxy: proxyUrl,
|
|
959
1055
|
llm: llm ?? current.llm,
|
|
960
1056
|
defaultMachine: current.defaultMachine ?? DEFAULT_MACHINE,
|
|
961
|
-
projects: current.projects,
|
|
1057
|
+
projects: projects ?? current.projects,
|
|
962
1058
|
};
|
|
963
1059
|
writeFileSync(configPath, serializeConfigJson(nextConfig));
|
|
964
1060
|
process.stdout.write(`\nanon-pi: wrote ${configPath}.\n`);
|
|
@@ -967,20 +1063,75 @@ function runInit(args: string[]): number {
|
|
|
967
1063
|
// pin/re-pin its image when one was chosen. Its home seeds on first launch.
|
|
968
1064
|
initWriteDefaultMachine(env, image);
|
|
969
1065
|
|
|
970
|
-
// The
|
|
971
|
-
// captured endpoint (this is the `import` replacement).
|
|
972
|
-
|
|
973
|
-
if (
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
join(mdir, MODELS_FILE),
|
|
979
|
-
JSON.stringify(models, null, '\t') + '\n',
|
|
1066
|
+
// The GLOBAL local-model models.json + settings seed, generated from the
|
|
1067
|
+
// captured endpoint + the CHOSEN models (this is the `import` replacement).
|
|
1068
|
+
const endpoint = llm ?? current.llm;
|
|
1069
|
+
if (endpoint !== undefined) {
|
|
1070
|
+
const models = generateModelsJson(
|
|
1071
|
+
endpoint,
|
|
1072
|
+
llmResult.models,
|
|
1073
|
+
llmResult.apiKey,
|
|
980
1074
|
);
|
|
1075
|
+
const modelsBody = JSON.stringify(models, null, '\t') + '\n';
|
|
1076
|
+
// GLOBAL seed: the local model is a workspace-level thing (config.llm is
|
|
1077
|
+
// global), so its models.json lives once at the workspace root and seeds
|
|
1078
|
+
// EVERY machine's fresh home. A machine may still override with its own
|
|
1079
|
+
// machines/<M>/models.json.
|
|
1080
|
+
mkdirSync(resolveAnonPiHome(env), {recursive: true});
|
|
1081
|
+
writeFileSync(globalModelsSeedPath(env), modelsBody);
|
|
1082
|
+
|
|
1083
|
+
// Migration: earlier versions wrote this seed under machines/default/. Now
|
|
1084
|
+
// that it is global, remove the old default-machine copy so `default`
|
|
1085
|
+
// picks up the global seed like every other machine (leaving it would look
|
|
1086
|
+
// like a deliberate per-machine override and shadow the global one). Only
|
|
1087
|
+
// the `default` machine's init-generated copy is migrated; a per-machine
|
|
1088
|
+
// override you created for ANY OTHER machine is left untouched.
|
|
1089
|
+
for (const stale of [
|
|
1090
|
+
machineModelsSeedPath(env, DEFAULT_MACHINE),
|
|
1091
|
+
join(machineDir(env, DEFAULT_MACHINE), SETTINGS_SEED_FILE),
|
|
1092
|
+
]) {
|
|
1093
|
+
if (existsSync(stale)) rmSync(stale, {force: true});
|
|
1094
|
+
}
|
|
981
1095
|
process.stdout.write(
|
|
982
|
-
`anon-pi: wrote the local-model models.json
|
|
1096
|
+
`anon-pi: wrote the global local-model models.json ` +
|
|
1097
|
+
`(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}; shared by all machines).\n`,
|
|
983
1098
|
);
|
|
1099
|
+
|
|
1100
|
+
// settings.json seed: the default model + enabledModels for the imported
|
|
1101
|
+
// set. The first-launch promotion merges it into each home's settings (so
|
|
1102
|
+
// image-staged packages/extensions survive). Only when the user picked at
|
|
1103
|
+
// least one model + a default.
|
|
1104
|
+
const selection =
|
|
1105
|
+
llmResult.defaultId !== undefined && llmResult.models.length > 0
|
|
1106
|
+
? generateModelSelection(
|
|
1107
|
+
llmResult.models.map((m) => m.id),
|
|
1108
|
+
llmResult.defaultId,
|
|
1109
|
+
)
|
|
1110
|
+
: undefined;
|
|
1111
|
+
if (selection) {
|
|
1112
|
+
writeFileSync(
|
|
1113
|
+
globalSettingsSeedPath(env),
|
|
1114
|
+
JSON.stringify(selection, null, '\t') + '\n',
|
|
1115
|
+
);
|
|
1116
|
+
process.stdout.write(
|
|
1117
|
+
`anon-pi: default model set to "${llmResult.defaultId}".\n`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Re-run reconfigure: the seed above only takes effect on a FRESH home
|
|
1122
|
+
// (the first-launch promotion is marker-guarded). Apply the new
|
|
1123
|
+
// models.json + settings selection DIRECTLY to EVERY already-seeded machine
|
|
1124
|
+
// home now, so re-running `init` updates existing environments without
|
|
1125
|
+
// wiping conversations (init runs on the host; homes are host dirs). Fresh
|
|
1126
|
+
// (unseeded) homes are left to the launch-time seed. A machine with its OWN
|
|
1127
|
+
// per-machine models.json override is skipped (its local model differs).
|
|
1128
|
+
const updated = applyModelsToSeededHomes(env, modelsBody, selection);
|
|
1129
|
+
if (updated.length > 0) {
|
|
1130
|
+
process.stdout.write(
|
|
1131
|
+
`anon-pi: updated ${updated.length} existing machine home${updated.length === 1 ? '' : 's'} ` +
|
|
1132
|
+
`(${updated.join(', ')}); conversations untouched.\n`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
984
1135
|
}
|
|
985
1136
|
|
|
986
1137
|
process.stdout.write(
|
|
@@ -990,6 +1141,42 @@ function runInit(args: string[]): number {
|
|
|
990
1141
|
return 0;
|
|
991
1142
|
}
|
|
992
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* Apply the freshly-generated GLOBAL local-model config to EVERY already-seeded
|
|
1146
|
+
* machine home directly (init runs on the host; homes are host dirs). The
|
|
1147
|
+
* launch-time seed only promotes on a FRESH home (marker-guarded), so without
|
|
1148
|
+
* this a re-run of `init` would update the global seed but never reach existing
|
|
1149
|
+
* homes. For each machine: skip a FRESH home (no marker — the launch seed will
|
|
1150
|
+
* pick up the new global seed), skip a machine with its OWN per-machine
|
|
1151
|
+
* models.json override (its local model differs on purpose), else overwrite the
|
|
1152
|
+
* home's models.json and merge the settings selection. Conversations/sessions
|
|
1153
|
+
* are untouched. Returns the machine names updated.
|
|
1154
|
+
*/
|
|
1155
|
+
function applyModelsToSeededHomes(
|
|
1156
|
+
env: AnonPiEnv,
|
|
1157
|
+
modelsBody: string,
|
|
1158
|
+
selection: ModelSelection | undefined,
|
|
1159
|
+
): string[] {
|
|
1160
|
+
const updated: string[] = [];
|
|
1161
|
+
for (const machine of listMachineNames(env).sort()) {
|
|
1162
|
+
const agentDir = machineAgentDir(env, machine);
|
|
1163
|
+
// Only an already-seeded home (marker present).
|
|
1164
|
+
if (!existsSync(join(agentDir, SEED_MARKER))) continue;
|
|
1165
|
+
// A machine that deliberately overrides the global models.json keeps its
|
|
1166
|
+
// own local model; do not clobber its home with the global one.
|
|
1167
|
+
if (existsSync(machineModelsSeedPath(env, machine))) continue;
|
|
1168
|
+
|
|
1169
|
+
writeFileSync(join(agentDir, MODELS_FILE), modelsBody);
|
|
1170
|
+
if (selection) {
|
|
1171
|
+
const settingsPath = join(agentDir, SETTINGS_FILE);
|
|
1172
|
+
const merged = mergeModelSelection(readJsonFile(settingsPath), selection);
|
|
1173
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, '\t') + '\n');
|
|
1174
|
+
}
|
|
1175
|
+
updated.push(machine);
|
|
1176
|
+
}
|
|
1177
|
+
return updated;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
993
1180
|
/** A sentinel a step returns when the user aborted (distinct from "skipped"). */
|
|
994
1181
|
const ABORT = Symbol('abort');
|
|
995
1182
|
|
|
@@ -1003,7 +1190,7 @@ const ABORT = Symbol('abort');
|
|
|
1003
1190
|
*/
|
|
1004
1191
|
function initProxyStep(currentProxy: string | undefined): string | undefined {
|
|
1005
1192
|
process.stdout.write(
|
|
1006
|
-
'Step 1/
|
|
1193
|
+
'Step 1/4 - proxy (the socks5h endpoint that anonymizes egress)\n',
|
|
1007
1194
|
);
|
|
1008
1195
|
if (currentProxy) {
|
|
1009
1196
|
process.stdout.write(` current: ${currentProxy}\n`);
|
|
@@ -1122,15 +1309,31 @@ function initProxyStep(currentProxy: string | undefined): string | undefined {
|
|
|
1122
1309
|
}
|
|
1123
1310
|
}
|
|
1124
1311
|
|
|
1312
|
+
/** What initLlmStep resolves: the endpoint, the chosen model entries, the apiKey to seed, and the default id. */
|
|
1313
|
+
interface LlmStepResult {
|
|
1314
|
+
endpoint: string | undefined;
|
|
1315
|
+
models: GeneratedModel[];
|
|
1316
|
+
apiKey: string;
|
|
1317
|
+
defaultId: string | undefined;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1125
1320
|
/**
|
|
1126
1321
|
* The LOCAL MODEL step: capture host:port (pre-filled from config), probe TCP
|
|
1127
|
-
* reachability
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1322
|
+
* reachability, then IMPORT models. It merges TWO sources, both scoped to the
|
|
1323
|
+
* endpoint (the one `--allow-direct` hole, so no other provider can enter the
|
|
1324
|
+
* seed): the host `~/.pi/agent/models.json` provider whose baseUrl matches the
|
|
1325
|
+
* endpoint (well-tuned entries, marked [configured]) and the endpoint's live
|
|
1326
|
+
* `/v1/models` (bare ids, marked [server]). The user picks which to import and
|
|
1327
|
+
* the default. Returns the endpoint + chosen entries + apiKey + default, or
|
|
1328
|
+
* ABORT (a real host apiKey without --force-allow-local-llm-api-key).
|
|
1130
1329
|
*/
|
|
1131
|
-
function initLlmStep(
|
|
1330
|
+
function initLlmStep(
|
|
1331
|
+
env: AnonPiEnv,
|
|
1332
|
+
currentLlm: string | undefined,
|
|
1333
|
+
forceLocalApiKey: boolean,
|
|
1334
|
+
): LlmStepResult | typeof ABORT {
|
|
1132
1335
|
process.stdout.write(
|
|
1133
|
-
'\nStep 2/
|
|
1336
|
+
'\nStep 2/4 - local model endpoint (the ONE direct hole)\n',
|
|
1134
1337
|
);
|
|
1135
1338
|
if (currentLlm) process.stdout.write(` current: ${currentLlm}\n`);
|
|
1136
1339
|
const prefill = currentLlm
|
|
@@ -1139,10 +1342,17 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
|
|
|
1139
1342
|
const ans = promptLine(
|
|
1140
1343
|
` Local model host:port, e.g. 192.168.1.150:8080${prefill}: `,
|
|
1141
1344
|
);
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1345
|
+
const raw = ans === undefined ? '' : ans.trim();
|
|
1346
|
+
const endpoint = raw === '' ? currentLlm : raw;
|
|
1347
|
+
if (endpoint === undefined) {
|
|
1348
|
+
// No endpoint at all: nothing to import.
|
|
1349
|
+
return {
|
|
1350
|
+
endpoint: undefined,
|
|
1351
|
+
models: [],
|
|
1352
|
+
apiKey: LOCAL_PROVIDER_API_KEY,
|
|
1353
|
+
defaultId: undefined,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1146
1356
|
|
|
1147
1357
|
// Probe reachability: evidence only. A closed port is not fatal (the model may
|
|
1148
1358
|
// start later); we just report it.
|
|
@@ -1150,15 +1360,152 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
|
|
|
1150
1360
|
const colon = key.lastIndexOf(':');
|
|
1151
1361
|
const host = colon > 0 ? key.slice(0, colon) : key;
|
|
1152
1362
|
const port = colon > 0 ? Number(key.slice(colon + 1)) : 80;
|
|
1363
|
+
const reachable = Number.isFinite(port) ? probeTcp(host, port) : false;
|
|
1364
|
+
process.stdout.write(
|
|
1365
|
+
reachable
|
|
1366
|
+
? ` reachable: ${host}:${port} accepted a TCP connection.\n`
|
|
1367
|
+
: ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
// SOURCE A: the host models.json provider matching this endpoint (only that
|
|
1371
|
+
// one — the anonymity scoping). Its apiKey is checked: a REAL key is refused
|
|
1372
|
+
// unless --force-allow-local-llm-api-key.
|
|
1373
|
+
const hostModels = readJsonFile(resolveHostModelsPath(env));
|
|
1374
|
+
const match = pickLocalProviderModels(
|
|
1375
|
+
(hostModels as PiModelsFile) ?? {},
|
|
1376
|
+
endpoint,
|
|
1377
|
+
);
|
|
1378
|
+
let apiKey = LOCAL_PROVIDER_API_KEY;
|
|
1379
|
+
if (match && match.apiKeyLooksReal) {
|
|
1380
|
+
if (!forceLocalApiKey) {
|
|
1381
|
+
process.stderr.write(
|
|
1382
|
+
`\n anon-pi: the matching provider in your pi config carries a real-looking\n` +
|
|
1383
|
+
` apiKey. Seeding it would put a host credential into the anonymized machine\n` +
|
|
1384
|
+
` home. Refusing. If this key is genuinely safe for the local model, re-run\n` +
|
|
1385
|
+
` \`anon-pi init --force-allow-local-llm-api-key\` to carry it through.\n`,
|
|
1386
|
+
);
|
|
1387
|
+
return ABORT;
|
|
1388
|
+
}
|
|
1389
|
+
apiKey = (match.apiKey ?? LOCAL_PROVIDER_API_KEY).trim();
|
|
1390
|
+
process.stdout.write(
|
|
1391
|
+
' WARNING: carrying the host provider apiKey into the seed (--force-allow-local-llm-api-key).\n',
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
const hostModelEntries = match?.models ?? [];
|
|
1395
|
+
|
|
1396
|
+
// SOURCE B: the endpoint's live /v1/models (bare ids).
|
|
1397
|
+
let serverIds: string[] = [];
|
|
1153
1398
|
if (Number.isFinite(port)) {
|
|
1154
|
-
const
|
|
1399
|
+
const listing = fetchModelsListing(host, port);
|
|
1400
|
+
serverIds = parseModelsListing(listing);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (hostModelEntries.length === 0 && serverIds.length === 0) {
|
|
1155
1404
|
process.stdout.write(
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
: ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
|
|
1405
|
+
' No models found (no matching provider in your pi config, and the server\n' +
|
|
1406
|
+
' returned none). The provider is still seeded; add models in pi later.\n',
|
|
1159
1407
|
);
|
|
1408
|
+
return {endpoint, models: [], apiKey, defaultId: undefined};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const candidates = mergeModelSources(hostModelEntries, serverIds);
|
|
1412
|
+
const chosen = initModelPicker(candidates);
|
|
1413
|
+
if (chosen === undefined) {
|
|
1414
|
+
// Skip: seed the provider with no models.
|
|
1415
|
+
return {endpoint, models: [], apiKey, defaultId: undefined};
|
|
1416
|
+
}
|
|
1417
|
+
const defaultId = initDefaultModelPicker(chosen);
|
|
1418
|
+
return {endpoint, models: chosen, apiKey, defaultId};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Present the merged candidate list and let the user choose which to import.
|
|
1423
|
+
* Options: Enter/`c` = all CONFIGURED (host-tuned; the safe default), `a` = ALL
|
|
1424
|
+
* (server + configured), space/comma-separated NUMBERS = those, `s` = skip.
|
|
1425
|
+
* Returns the chosen entries, or undefined to skip (seed no models).
|
|
1426
|
+
*/
|
|
1427
|
+
function initModelPicker(
|
|
1428
|
+
candidates: readonly ModelCandidate[],
|
|
1429
|
+
): GeneratedModel[] | undefined {
|
|
1430
|
+
const configured = candidates.filter((c) => c.configured);
|
|
1431
|
+
process.stdout.write('\n Models served by this endpoint:\n');
|
|
1432
|
+
candidates.forEach((c, i) => {
|
|
1433
|
+
const tag = c.configured ? '[configured]' : '[server]';
|
|
1434
|
+
process.stdout.write(` [${i + 1}] ${c.id} ${tag}\n`);
|
|
1435
|
+
});
|
|
1436
|
+
process.stdout.write(
|
|
1437
|
+
' [configured] = from your pi config (well-tuned); [server] = the server\n' +
|
|
1438
|
+
' also reports it (a minimal entry is synthesized).\n',
|
|
1439
|
+
);
|
|
1440
|
+
const hasConfigured = configured.length > 0;
|
|
1441
|
+
const defaultHint = hasConfigured
|
|
1442
|
+
? 'Enter/c = all configured, a = all, numbers = pick, s = skip'
|
|
1443
|
+
: 'Enter/a = all, numbers = pick, s = skip';
|
|
1444
|
+
for (;;) {
|
|
1445
|
+
const ans = promptLine(` Import which? (${defaultHint}): `);
|
|
1446
|
+
const v = (ans ?? '').trim().toLowerCase();
|
|
1447
|
+
if (v === 's') return undefined;
|
|
1448
|
+
if (v === '' && hasConfigured) return configured.map((c) => c.entry);
|
|
1449
|
+
if (v === 'c') {
|
|
1450
|
+
if (!hasConfigured) {
|
|
1451
|
+
process.stdout.write(
|
|
1452
|
+
' No [configured] models; pick numbers or `a` for all.\n',
|
|
1453
|
+
);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
return configured.map((c) => c.entry);
|
|
1457
|
+
}
|
|
1458
|
+
if (v === 'a' || (v === '' && !hasConfigured)) {
|
|
1459
|
+
return candidates.map((c) => c.entry);
|
|
1460
|
+
}
|
|
1461
|
+
// Numbers (space/comma separated).
|
|
1462
|
+
const picks = v
|
|
1463
|
+
.split(/[\s,]+/)
|
|
1464
|
+
.filter((t) => t !== '')
|
|
1465
|
+
.map((t) => Number(t) - 1);
|
|
1466
|
+
if (
|
|
1467
|
+
picks.length > 0 &&
|
|
1468
|
+
picks.every((i) => i >= 0 && i < candidates.length)
|
|
1469
|
+
) {
|
|
1470
|
+
// De-dup, keep list order.
|
|
1471
|
+
const seen = new Set<number>();
|
|
1472
|
+
const out: GeneratedModel[] = [];
|
|
1473
|
+
for (const i of picks) {
|
|
1474
|
+
if (!seen.has(i)) {
|
|
1475
|
+
seen.add(i);
|
|
1476
|
+
out.push(candidates[i].entry);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return out;
|
|
1480
|
+
}
|
|
1481
|
+
process.stdout.write(
|
|
1482
|
+
` Please enter Enter/c/a/s or numbers 1-${candidates.length}.\n`,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Pick the DEFAULT model among the chosen ones. Defaults to the first (Enter);
|
|
1489
|
+
* accepts a number. Returns the chosen id.
|
|
1490
|
+
*/
|
|
1491
|
+
function initDefaultModelPicker(chosen: readonly GeneratedModel[]): string {
|
|
1492
|
+
if (chosen.length === 1) return chosen[0].id;
|
|
1493
|
+
process.stdout.write('\n Which is the DEFAULT model?\n');
|
|
1494
|
+
chosen.forEach((m, i) => {
|
|
1495
|
+
process.stdout.write(` [${i + 1}] ${m.id}\n`);
|
|
1496
|
+
});
|
|
1497
|
+
for (;;) {
|
|
1498
|
+
const ans = promptLine(
|
|
1499
|
+
` Default [1-${chosen.length}] (Enter = ${chosen[0].id}): `,
|
|
1500
|
+
);
|
|
1501
|
+
const v = (ans ?? '').trim();
|
|
1502
|
+
if (v === '') return chosen[0].id;
|
|
1503
|
+
const idx = Number(v) - 1;
|
|
1504
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < chosen.length) {
|
|
1505
|
+
return chosen[idx].id;
|
|
1506
|
+
}
|
|
1507
|
+
process.stdout.write(` Please pick a number 1-${chosen.length}.\n`);
|
|
1160
1508
|
}
|
|
1161
|
-
return endpoint;
|
|
1162
1509
|
}
|
|
1163
1510
|
|
|
1164
1511
|
/**
|
|
@@ -1168,7 +1515,7 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
|
|
|
1168
1515
|
*/
|
|
1169
1516
|
function initImageStep(): string | undefined | typeof ABORT {
|
|
1170
1517
|
process.stdout.write(
|
|
1171
|
-
'\nStep 3/
|
|
1518
|
+
'\nStep 3/4 - default machine image (an image with `pi` on PATH)\n',
|
|
1172
1519
|
);
|
|
1173
1520
|
const menu = initImageMenu();
|
|
1174
1521
|
menu.forEach((e, i) => {
|
|
@@ -1220,6 +1567,44 @@ function initImageStep(): string | undefined | typeof ABORT {
|
|
|
1220
1567
|
}
|
|
1221
1568
|
}
|
|
1222
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* The PROJECTS-ROOT step: the host directory mounted into the jail at /projects
|
|
1572
|
+
* (pi's cwd; a project is /projects/<name>). It defaults to the built-in
|
|
1573
|
+
* `~/.anon-pi/projects/`; the user may point it at their own dev folder so bare
|
|
1574
|
+
* `anon-pi` works there without passing `--mount` every time. `--mount <parent>`
|
|
1575
|
+
* still overrides it per-launch. Returns the chosen root, or undefined to keep
|
|
1576
|
+
* the current/default (so an omitted `projects` in config.json means the
|
|
1577
|
+
* built-in default). Enter accepts the shown default.
|
|
1578
|
+
*/
|
|
1579
|
+
function initProjectsStep(
|
|
1580
|
+
env: AnonPiEnv,
|
|
1581
|
+
currentProjects: string | undefined,
|
|
1582
|
+
): string | undefined {
|
|
1583
|
+
process.stdout.write(
|
|
1584
|
+
'\nStep 4/4 - projects root (the host folder mounted at /projects)\n',
|
|
1585
|
+
);
|
|
1586
|
+
const builtin = builtinProjectsRoot(env);
|
|
1587
|
+
const shown = currentProjects ?? builtin;
|
|
1588
|
+
if (currentProjects) {
|
|
1589
|
+
process.stdout.write(` current: ${currentProjects}\n`);
|
|
1590
|
+
}
|
|
1591
|
+
process.stdout.write(
|
|
1592
|
+
' This is where bare `anon-pi` looks for projects. Point it at your own\n' +
|
|
1593
|
+
' dev folder to jail pi into files you edit with host tools; `--mount\n' +
|
|
1594
|
+
' <parent>` still overrides it per-launch. Leave it at the default if\n' +
|
|
1595
|
+
" you're unsure.\n",
|
|
1596
|
+
);
|
|
1597
|
+
const ans = promptLine(` Projects root (Enter to keep ${shown}): `);
|
|
1598
|
+
if (ans === undefined) return currentProjects;
|
|
1599
|
+
const trimmed = ans.trim();
|
|
1600
|
+
if (trimmed === '') return currentProjects;
|
|
1601
|
+
// Store the built-in as "unset" (undefined) so config.json stays clean when
|
|
1602
|
+
// the user just accepts the default path explicitly.
|
|
1603
|
+
const chosen = resolve(trimmed);
|
|
1604
|
+
if (chosen === builtin) return undefined;
|
|
1605
|
+
return chosen;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1223
1608
|
/**
|
|
1224
1609
|
* Create or update the `default` machine: create it (dir + machine.json) if
|
|
1225
1610
|
* absent, pinning the chosen image; if it already exists, re-pin the image only
|
|
@@ -1334,6 +1719,37 @@ function probeTcp(host: string, port: number): boolean {
|
|
|
1334
1719
|
return socks5Handshake(host, port, [], 500) !== undefined;
|
|
1335
1720
|
}
|
|
1336
1721
|
|
|
1722
|
+
/**
|
|
1723
|
+
* Best-effort SYNCHRONOUS HTTP GET of `http://<host:port>/v1/models` (the
|
|
1724
|
+
* OpenAI-compatible model listing llama.cpp / vLLM / LM Studio serve). Runs a
|
|
1725
|
+
* tiny worker on the same node binary (node has no sync HTTP), which fetches +
|
|
1726
|
+
* prints the body, so init stays synchronous. Returns the PARSED JSON body, or
|
|
1727
|
+
* undefined on any failure (unreachable / timeout / non-JSON) — init then falls
|
|
1728
|
+
* back to manual entry. This is a DIRECT LAN fetch on the operator's host at
|
|
1729
|
+
* init time (not inside the jail); it only ever touches the local-model
|
|
1730
|
+
* endpoint, the same host:port that becomes the one `--allow-direct` hole.
|
|
1731
|
+
*/
|
|
1732
|
+
function fetchModelsListing(host: string, port: number): unknown {
|
|
1733
|
+
const timeoutMs = 3000;
|
|
1734
|
+
const script =
|
|
1735
|
+
`const http=require('http');` +
|
|
1736
|
+
`const req=http.get({host:${JSON.stringify(host)},port:${port},path:'/v1/models',timeout:${timeoutMs}},(res)=>{` +
|
|
1737
|
+
`let b='';res.on('data',(c)=>{b+=c;if(b.length>1_000_000)req.destroy()});` +
|
|
1738
|
+
`res.on('end',()=>{process.stdout.write(b);process.exit(0)})});` +
|
|
1739
|
+
`req.on('timeout',()=>{req.destroy();process.exit(1)});` +
|
|
1740
|
+
`req.on('error',()=>process.exit(1));`;
|
|
1741
|
+
try {
|
|
1742
|
+
const out = execFileSync(process.execPath, ['-e', script], {
|
|
1743
|
+
encoding: 'utf8',
|
|
1744
|
+
timeout: timeoutMs + 1500,
|
|
1745
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1746
|
+
});
|
|
1747
|
+
return JSON.parse(out);
|
|
1748
|
+
} catch {
|
|
1749
|
+
return undefined;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1337
1753
|
/**
|
|
1338
1754
|
* Observe LOCAL process names (best-effort) so init can offer WEAK hints (a
|
|
1339
1755
|
* running `tor` -> likely Tor). Returns the lowercased process names seen, or []
|