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