anon-pi 0.4.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/Dockerfile.pi +21 -9
- package/README.md +202 -64
- package/dist/anon-pi.d.ts +989 -76
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +1428 -251
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +1512 -112
- package/dist/cli.js.map +1 -1
- package/examples/Dockerfile.pi-webveil +9 -3
- package/package.json +1 -1
- package/src/anon-pi.ts +2052 -346
- package/src/cli.ts +1808 -123
package/src/cli.ts
CHANGED
|
@@ -1,227 +1,1903 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// anon-pi CLI.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
2
|
+
// anon-pi CLI: the THIN impure launch path. Parses grammar A (pure
|
|
3
|
+
// parseLaunchArgs), reads config.json / machine.json + resolves the machine,
|
|
4
|
+
// composes the LaunchIntent, resolves the RunPlan (pure resolveRunPlan), decides
|
|
5
|
+
// run-vs-start against real netcage for `--keep`, and spawns netcage with
|
|
6
|
+
// inherited stdio (so -it is a real interactive TTY), propagating the exit code.
|
|
7
|
+
//
|
|
8
|
+
// All the DECISIONS live in the pure module (anon-pi.ts); this file only does
|
|
9
|
+
// I/O: fs reads/mkdirs, the netcage query, the spawn, and the TTY discipline.
|
|
10
|
+
// The forced-egress invariant is the RunPlan's guarantee: the composed argv
|
|
11
|
+
// ALWAYS carries --proxy + the one --allow-direct; the CLI never strips or adds
|
|
12
|
+
// egress.
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
existsSync,
|
|
13
16
|
mkdirSync,
|
|
17
|
+
readdirSync,
|
|
14
18
|
readFileSync,
|
|
15
19
|
rmSync,
|
|
16
20
|
writeFileSync,
|
|
17
21
|
} from 'node:fs';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
22
|
+
import {readSync} from 'node:fs';
|
|
23
|
+
import {spawnSync, execFileSync} from 'node:child_process';
|
|
24
|
+
import {join, dirname, resolve} from 'node:path';
|
|
20
25
|
import {
|
|
21
26
|
AnonPiError,
|
|
22
|
-
buildRunPlan,
|
|
23
|
-
envFromProcess,
|
|
24
27
|
HELP,
|
|
25
28
|
MODELS_FILE,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
SETTINGS_SEED_FILE,
|
|
30
|
+
SEED_MARKER,
|
|
31
|
+
DEFAULT_MACHINE,
|
|
32
|
+
envFromProcess,
|
|
33
|
+
buildMenuChoiceList,
|
|
34
|
+
buildMenuEntries,
|
|
35
|
+
builtinProjectsRoot,
|
|
36
|
+
deriveProjectUsage,
|
|
37
|
+
machineDir,
|
|
38
|
+
machineHomeDir,
|
|
39
|
+
machineJsonPath,
|
|
40
|
+
machineSessionsDir,
|
|
41
|
+
validateName,
|
|
42
|
+
resolveDeleteHome,
|
|
43
|
+
resolveDeleteProject,
|
|
44
|
+
parseConfigJson,
|
|
45
|
+
parseLaunchArgs,
|
|
46
|
+
parseMachineArgs,
|
|
47
|
+
parseMachineJson,
|
|
48
|
+
projectHostDir,
|
|
49
|
+
resolveAnonPiHome,
|
|
50
|
+
resolveLlm,
|
|
51
|
+
resolveProjectsRoot,
|
|
52
|
+
resolveProxy,
|
|
53
|
+
resolveRunPlan,
|
|
54
|
+
resolveRunVsStart,
|
|
55
|
+
serializeMachineJson,
|
|
56
|
+
serializeConfigJson,
|
|
57
|
+
setImageWarning,
|
|
58
|
+
keptContainerKey,
|
|
59
|
+
DEFAULT_SOCKS_PROBE_PORTS,
|
|
60
|
+
SOCKS5_METHOD_SELECTOR,
|
|
61
|
+
formatProxyFindings,
|
|
62
|
+
interpretSocks5Handshake,
|
|
63
|
+
initImageMenu,
|
|
64
|
+
generateModelsJson,
|
|
65
|
+
generateModelSelection,
|
|
66
|
+
pickLocalProviderModels,
|
|
67
|
+
parseModelsListing,
|
|
68
|
+
mergeModelSources,
|
|
69
|
+
resolveHostModelsPath,
|
|
70
|
+
LOCAL_PROVIDER_API_KEY,
|
|
71
|
+
parseVerifyExitIp,
|
|
72
|
+
processHint,
|
|
73
|
+
socks5hUrl,
|
|
74
|
+
hostPortKey,
|
|
75
|
+
shippedDockerfilePath,
|
|
76
|
+
shippedWebveilDockerfilePath,
|
|
77
|
+
type MenuEntry,
|
|
78
|
+
type SessionDirListing,
|
|
79
|
+
type ProxyFinding,
|
|
80
|
+
type SocksHandshake,
|
|
81
|
+
type InitImageChoice,
|
|
82
|
+
type AnonPiConfig,
|
|
83
|
+
type AnonPiEnv,
|
|
84
|
+
type GeneratedModel,
|
|
85
|
+
type ModelCandidate,
|
|
86
|
+
type KeptContainer,
|
|
87
|
+
type LaunchIntent,
|
|
88
|
+
type Machine,
|
|
89
|
+
type MachineConfig,
|
|
90
|
+
type MachineCommand,
|
|
91
|
+
type ParsedLaunch,
|
|
30
92
|
type PiModelsFile,
|
|
31
93
|
} from './anon-pi.js';
|
|
32
94
|
|
|
95
|
+
// The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
|
|
96
|
+
// so a `--keep` re-entry can find and `netcage start` the same kept container.
|
|
97
|
+
// netcage's `netcage.managed` label marks it a managed container; this adds the
|
|
98
|
+
// anon-pi identity ON TOP (netcage's label IS the registry; anon-pi adds no file).
|
|
99
|
+
const ANON_PI_KEY_LABEL = 'anon-pi.key';
|
|
100
|
+
|
|
33
101
|
function main(argv: string[]): number {
|
|
34
102
|
const args = argv.slice(2);
|
|
35
103
|
|
|
36
|
-
|
|
104
|
+
// The global `--help`/`-h` prints the top-level HELP, EXCEPT when the first
|
|
105
|
+
// token is a subcommand that owns its own `--help` (so `anon-pi init --help`
|
|
106
|
+
// and `anon-pi machine --help` show THEIR help, not the global one). Those
|
|
107
|
+
// subcommands route to runInit / runMachine, which print INIT_HELP /
|
|
108
|
+
// MACHINE_HELP respectively.
|
|
109
|
+
const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine']);
|
|
110
|
+
if (
|
|
111
|
+
(args.includes('--help') || args.includes('-h')) &&
|
|
112
|
+
!OWN_HELP_SUBCOMMANDS.has(args[0] ?? '')
|
|
113
|
+
) {
|
|
37
114
|
process.stdout.write(HELP);
|
|
38
115
|
return 0;
|
|
39
116
|
}
|
|
40
117
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
118
|
+
// `machine …` is the machine-management surface (create/list/set-image/rm),
|
|
119
|
+
// dispatched BEFORE the launch grammar so a bare `machine` is never parsed as
|
|
120
|
+
// a project named "machine". Everything else is a launch.
|
|
121
|
+
if (args[0] === 'machine') {
|
|
122
|
+
return runMachine(args.slice(1));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The destructive cleanup verbs (replacing the old `--fresh`). Dispatched
|
|
126
|
+
// BEFORE the launch grammar: they are top-level data verbs, not launch flags,
|
|
127
|
+
// each with the confirm/`--yes`/non-TTY discipline. `--delete-home` takes an
|
|
128
|
+
// OPTIONAL machine (default machine when omitted); `--delete-project` REQUIRES
|
|
129
|
+
// a project.
|
|
130
|
+
if (args[0] === '--delete-home') {
|
|
131
|
+
return runDeleteHome(args.slice(1));
|
|
132
|
+
}
|
|
133
|
+
if (args[0] === '--delete-project') {
|
|
134
|
+
return runDeleteProject(args.slice(1));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `init` onboards: verify the proxy, capture the llm endpoint, pick/build the
|
|
138
|
+
// default machine image, write config.json + the default machine. Re-runnable.
|
|
139
|
+
if (args[0] === 'init') {
|
|
140
|
+
return runInit(args.slice(1));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let parsed: ParsedLaunch;
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseLaunchArgs(args);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return reportAnonPiError(e);
|
|
148
|
+
}
|
|
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.
|
|
44
160
|
}
|
|
45
161
|
|
|
46
|
-
return runLaunch(
|
|
162
|
+
return runLaunch(parsed);
|
|
47
163
|
}
|
|
48
164
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
|
|
201
|
+
// --- the launch path --------------------------------------------------------
|
|
202
|
+
function runLaunch(parsed: ParsedLaunch): number {
|
|
203
|
+
const env = envFromProcess(process.env);
|
|
204
|
+
|
|
205
|
+
// config.json (the workspace default proxy/llm/defaultMachine/projects).
|
|
206
|
+
const config = readJsonConfig(env);
|
|
207
|
+
|
|
208
|
+
// Resolve the machine: an explicit -m wins, else config.defaultMachine, else
|
|
209
|
+
// the built-in DEFAULT_MACHINE (so an explicit `-m default` is honoured too).
|
|
210
|
+
const machineName = parsed.machineExplicit
|
|
211
|
+
? parsed.machine
|
|
212
|
+
: (config.defaultMachine ?? DEFAULT_MACHINE);
|
|
213
|
+
|
|
214
|
+
const machineConf = readMachineJson(env, machineName);
|
|
215
|
+
|
|
216
|
+
// Forced-egress inputs, resolved (env over config); the proxy is REQUIRED and
|
|
217
|
+
// fails closed with the verbatim guidance.
|
|
218
|
+
let proxy: string;
|
|
219
|
+
let llm: string | undefined;
|
|
220
|
+
let intent: LaunchIntent;
|
|
221
|
+
try {
|
|
222
|
+
proxy = resolveProxy({env, config});
|
|
223
|
+
llm = resolveLlm({env, config});
|
|
224
|
+
if (llm === undefined) {
|
|
225
|
+
throw new AnonPiError(
|
|
226
|
+
'anon-pi: set ANON_PI_LLM (or config.llm) to the RFC1918/link-local IP[:port]\n' +
|
|
227
|
+
'of your local model. It is the ONE direct hole; all other egress stays\n' +
|
|
228
|
+
'forced through the proxy.',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The machine's image: machine.json wins, ANON_PI_IMAGE is the fallback.
|
|
233
|
+
const image = machineConf.image ?? env.image ?? '';
|
|
234
|
+
|
|
235
|
+
// --mount re-roots at a HOST parent; otherwise the resolved projects root.
|
|
236
|
+
const mountParent = parsed.mountParent;
|
|
237
|
+
const projectsRoot = resolveProjectsRoot({
|
|
238
|
+
env,
|
|
239
|
+
config,
|
|
240
|
+
machine: machineConf,
|
|
241
|
+
mountParent,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const home = machineHomeDir(env, machineName);
|
|
245
|
+
const machine: Machine = {name: machineName, home, image};
|
|
246
|
+
|
|
247
|
+
// The generated models.json + settings seed for this machine (mounted
|
|
248
|
+
// read-only for the first-launch seed) when present. Keyed per machine.
|
|
249
|
+
const modelsSeed = join(machineDir(env, machineName), MODELS_FILE);
|
|
250
|
+
const settingsSeed = join(machineDir(env, machineName), SETTINGS_SEED_FILE);
|
|
251
|
+
|
|
252
|
+
intent = {
|
|
253
|
+
machine,
|
|
254
|
+
mode: parsed.mode,
|
|
255
|
+
projectsRoot,
|
|
256
|
+
project: parsed.project,
|
|
257
|
+
mountParent,
|
|
258
|
+
piArgs: parsed.piArgs,
|
|
259
|
+
keep: parsed.keep,
|
|
260
|
+
proxy,
|
|
261
|
+
llmDirect: llm,
|
|
262
|
+
modelsSeed: existsSync(modelsSeed) ? modelsSeed : undefined,
|
|
263
|
+
settingsSeed: existsSync(settingsSeed) ? settingsSeed : undefined,
|
|
264
|
+
};
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return reportAnonPiError(e);
|
|
64
267
|
}
|
|
65
|
-
|
|
268
|
+
|
|
269
|
+
// No-TTY discipline: the bare MENU and every INTERACTIVE launch (interactive
|
|
270
|
+
// pi, or a shell) need a TTY; a HEADLESS pi run (`<project> <pi-args…>`) does
|
|
271
|
+
// NOT. Check BEFORE we mutate anything or spawn.
|
|
272
|
+
const headless =
|
|
273
|
+
parsed.mode === 'pi' && !!parsed.piArgs && parsed.piArgs.length > 0;
|
|
274
|
+
if (!headless && !process.stdin.isTTY) {
|
|
275
|
+
if (parsed.mode === 'menu') {
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
'anon-pi: no TTY. The menu needs an interactive terminal. Pick a project\n' +
|
|
278
|
+
'directly, e.g. `anon-pi <project>`, or run anon-pi in a terminal.\n',
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
process.stderr.write(
|
|
282
|
+
`anon-pi: no TTY. An interactive ${parsed.mode === 'shell' ? 'shell' : 'pi session'} needs a terminal.\n` +
|
|
283
|
+
'Forward a one-shot pi prompt instead, e.g. `anon-pi <project> -p "..."`.\n',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Fail loud if netcage is not installed, before we mutate anything.
|
|
290
|
+
if (!hasNetcage()) {
|
|
66
291
|
process.stderr.write(
|
|
67
|
-
'anon-pi:
|
|
292
|
+
'anon-pi: `netcage` not found on PATH. anon-pi is a launcher for netcage; install it first\n' +
|
|
293
|
+
'(https://github.com/wighawag/netcage). Linux only.\n',
|
|
68
294
|
);
|
|
69
|
-
return
|
|
295
|
+
return 1;
|
|
70
296
|
}
|
|
71
297
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
298
|
+
// Resolve the RunPlan (pure). homeFresh reads the real seed marker.
|
|
299
|
+
let plan;
|
|
300
|
+
try {
|
|
301
|
+
plan = resolveRunPlan(intent, homeFresh);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return reportAnonPiError(e);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Bare launch: hand off to the interactive host-side menu, which re-resolves
|
|
307
|
+
// the user's pick into a concrete launch and executes it.
|
|
308
|
+
if (plan.kind === 'menu') {
|
|
309
|
+
return runMenu(intent, plan.machine);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return executeLaunchPlan(intent, plan);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Execute a RESOLVED non-menu LaunchPlan: create the host dirs the mounts need,
|
|
317
|
+
* then run netcage (or `netcage start` a matching kept container under --keep).
|
|
318
|
+
* Shared by the direct launch path (runLaunch) and the menu dispatch (runMenu),
|
|
319
|
+
* so a menu-picked project/here/shell launches BYTE-FOR-BYTE identically to the
|
|
320
|
+
* same command typed directly.
|
|
321
|
+
*/
|
|
322
|
+
function executeLaunchPlan(
|
|
323
|
+
intent: LaunchIntent,
|
|
324
|
+
plan: Extract<ReturnType<typeof resolveRunPlan>, {kind: 'launch'}>,
|
|
325
|
+
): number {
|
|
326
|
+
// Create the host dirs the mounts need BEFORE spawn: the machine home and,
|
|
327
|
+
// for a named project (not the root token `.` / a bare shell), its folder
|
|
328
|
+
// under the active root (the --mount parent or the projects root).
|
|
329
|
+
mkdirSync(plan.machine.home, {recursive: true});
|
|
330
|
+
if (
|
|
331
|
+
intent.project !== undefined &&
|
|
332
|
+
intent.project !== '.' &&
|
|
333
|
+
intent.mode !== 'shell'
|
|
334
|
+
) {
|
|
335
|
+
const root = intent.mountParent ?? intent.projectsRoot;
|
|
336
|
+
mkdirSync(projectHostDir(root, intent.project), {recursive: true});
|
|
337
|
+
} else {
|
|
338
|
+
// still ensure the active root exists (so a shell/`.`/menu-picked launch
|
|
339
|
+
// has a real dir to cwd into).
|
|
340
|
+
mkdirSync(intent.mountParent ?? intent.projectsRoot, {recursive: true});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Run-vs-start: under --keep, ask netcage for its kept managed containers and
|
|
344
|
+
// resume a matching one via `netcage start`; else run the composed argv. A
|
|
345
|
+
// throwaway (`--rm`) launch is always a fresh run (the pure rule never
|
|
346
|
+
// consults the listing for it).
|
|
347
|
+
if (intent.keep) {
|
|
348
|
+
const decision = resolveRunVsStart(intent, queryKeptContainers());
|
|
349
|
+
if (decision.action === 'start') {
|
|
350
|
+
return spawnNetcage(['start', '-a', '-i', decision.ref]);
|
|
351
|
+
}
|
|
352
|
+
// A fresh `--keep` run: stamp the identity key so a later re-entry can
|
|
353
|
+
// find this container. The RunPlan already omits --rm under --keep.
|
|
354
|
+
return spawnNetcage(
|
|
355
|
+
withKeyLabel(plan.netcageArgs, keptContainerKey(intent)),
|
|
75
356
|
);
|
|
76
|
-
return 2;
|
|
77
357
|
}
|
|
78
358
|
|
|
359
|
+
return spawnNetcage(plan.netcageArgs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- the interactive host-side menu (the ONLY untested I/O) -------------------
|
|
363
|
+
//
|
|
364
|
+
// Bare `anon-pi` (and bare `-m <machine>` / `--mount <parent>` with no project)
|
|
365
|
+
// dispatches here. The menu is a PURE host-side read: it lists the active root's
|
|
366
|
+
// projects (readdir) + each machine's pi session dirs (readdir) and feeds them
|
|
367
|
+
// to the pure buildMenuChoiceList / deriveProjectUsage / buildMenuEntries, which
|
|
368
|
+
// own ALL the logic (the entry order + the used-on / new-here annotation). This
|
|
369
|
+
// function does ONLY the I/O the pure seam cannot: the real dir reads, the raw-
|
|
370
|
+
// mode arrow-key render/select (select()), the new-project name prompt, and the
|
|
371
|
+
// dispatch of the pick back through resolveRunPlan + executeLaunchPlan (so a
|
|
372
|
+
// menu pick launches identically to the same command typed directly). No jail
|
|
373
|
+
// runs until the user chooses; the no-TTY case is handled BEFORE we reach here
|
|
374
|
+
// (runLaunch's discipline), so a TTY is guaranteed.
|
|
375
|
+
function runMenu(intent: LaunchIntent, machine: Machine): number {
|
|
79
376
|
const env = envFromProcess(process.env);
|
|
80
|
-
if (ephemeralFlag) env.ephemeral = true;
|
|
81
377
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
378
|
+
// The active root the menu lists projects from: a --mount parent re-roots, else
|
|
379
|
+
// the resolved projects root. (Named projects live under it; `.` is the root.)
|
|
380
|
+
const root = intent.mountParent ?? intent.projectsRoot;
|
|
381
|
+
const rawProjects = readDirNames(root);
|
|
382
|
+
const choiceList = buildMenuChoiceList({projects: rawProjects});
|
|
383
|
+
|
|
384
|
+
// Per-machine usage: the session-slug set present in each machine home's pi
|
|
385
|
+
// sessions dir, machine-invariant, so a shared project is credited on each.
|
|
386
|
+
const sessions: SessionDirListing = {};
|
|
387
|
+
for (const name of listMachineNames(env)) {
|
|
388
|
+
sessions[name] = readDirNames(machineSessionsDir(env, name));
|
|
389
|
+
}
|
|
390
|
+
// The current machine may be brand-new (no sessions dir yet); an absent entry
|
|
391
|
+
// reads as "new for it" in deriveProjectUsage.
|
|
392
|
+
if (sessions[machine.name] === undefined) sessions[machine.name] = [];
|
|
393
|
+
const usage = deriveProjectUsage({
|
|
394
|
+
projects: choiceList.projects,
|
|
395
|
+
currentMachine: machine.name,
|
|
396
|
+
sessions,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const entries = buildMenuEntries({choiceList, usage});
|
|
400
|
+
|
|
401
|
+
const picked = select(entries, {
|
|
402
|
+
header: `anon-pi: machine "${machine.name}" (\u2191/\u2193 move, Enter select, Ctrl-C quit)`,
|
|
403
|
+
});
|
|
404
|
+
if (picked === undefined) {
|
|
405
|
+
// Ctrl-C / EOF: a clean quit, nothing launched (the terminal is restored).
|
|
406
|
+
process.stderr.write('anon-pi: cancelled; nothing launched.\n');
|
|
407
|
+
return 130; // 128 + SIGINT, the conventional Ctrl-C exit code.
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Turn the pick into a concrete launch intent, then re-resolve + execute it
|
|
411
|
+
// EXACTLY as the equivalent direct command would (same resolveRunPlan path).
|
|
412
|
+
let launchIntent: LaunchIntent;
|
|
413
|
+
switch (picked.kind) {
|
|
414
|
+
case 'project':
|
|
415
|
+
case 'here':
|
|
416
|
+
launchIntent = {...intent, mode: 'pi', project: picked.project};
|
|
417
|
+
break;
|
|
418
|
+
case 'shell':
|
|
419
|
+
launchIntent = {...intent, mode: 'shell', project: undefined};
|
|
420
|
+
break;
|
|
421
|
+
case 'new': {
|
|
422
|
+
const name = promptNewProject();
|
|
423
|
+
if (name === undefined) return 1;
|
|
424
|
+
launchIntent = {...intent, mode: 'pi', project: name};
|
|
425
|
+
break;
|
|
95
426
|
}
|
|
96
427
|
}
|
|
97
428
|
|
|
98
429
|
let plan;
|
|
99
430
|
try {
|
|
100
|
-
plan =
|
|
431
|
+
plan = resolveRunPlan(launchIntent, homeFresh);
|
|
101
432
|
} catch (e) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
433
|
+
return reportAnonPiError(e);
|
|
434
|
+
}
|
|
435
|
+
// A menu pick is always a concrete launch (never the menu marker again).
|
|
436
|
+
if (plan.kind !== 'launch') {
|
|
437
|
+
process.stderr.write('anon-pi: internal error resolving the menu pick.\n');
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
return executeLaunchPlan(launchIntent, plan);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Prompt for a NEW project name and validate it (validateName, the same guard a
|
|
445
|
+
* direct `anon-pi <name>` uses), so a menu-created project is a safe single
|
|
446
|
+
* folder segment. Returns the validated name, or undefined on an empty entry /
|
|
447
|
+
* EOF / a rejected name (with the error printed). TTY is guaranteed here.
|
|
448
|
+
*/
|
|
449
|
+
function promptNewProject(): string | undefined {
|
|
450
|
+
const ans = promptLine('New project name (a single folder segment): ');
|
|
451
|
+
if (ans === undefined || ans.trim() === '') {
|
|
452
|
+
process.stderr.write('anon-pi: no name given; nothing launched.\n');
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
return validateName(ans.trim(), 'project');
|
|
457
|
+
} catch (e) {
|
|
458
|
+
reportAnonPiError(e);
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Read the entry NAMES of a directory (best-effort): the plain names of its
|
|
465
|
+
* direct children, or [] if the dir is absent / unreadable. Used for both the
|
|
466
|
+
* projects-root listing (pure buildMenuChoiceList filters it to folder-safe
|
|
467
|
+
* project names) and each machine's sessions dir (the session slugs present).
|
|
468
|
+
*/
|
|
469
|
+
function readDirNames(dir: string): string[] {
|
|
470
|
+
if (!existsSync(dir)) return [];
|
|
471
|
+
try {
|
|
472
|
+
return readdirSync(dir, {withFileTypes: true}).map((d) => d.name);
|
|
473
|
+
} catch {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// --- the hand-rolled, zero-dependency raw-mode selector ----------------------
|
|
479
|
+
//
|
|
480
|
+
// A small supply-chain surface is on-brand for a security tool and the project
|
|
481
|
+
// list is short, so instead of a prompt library we drive stdin in raw mode
|
|
482
|
+
// ourselves: up/down (arrows or k/j) move a `>` cursor, Enter selects, Ctrl-C /
|
|
483
|
+
// q / Esc cancels. The active row is highlighted (reverse video). The terminal
|
|
484
|
+
// is ALWAYS restored (raw mode off, cursor shown) on every exit path, including
|
|
485
|
+
// Ctrl-C. Isolated here behind a tiny signature so a well-regarded prompt lib
|
|
486
|
+
// could swap in later as a localized change. This is the ONLY untested I/O in
|
|
487
|
+
// the menu; all logic (entries + labels) is the pure buildMenuEntries.
|
|
488
|
+
|
|
489
|
+
const ESC = '\u001b';
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Present `entries` as an arrow-key list and return the chosen one, or undefined
|
|
493
|
+
* on cancel (Ctrl-C / q / Esc / EOF). Blocks on raw stdin; restores the terminal
|
|
494
|
+
* on every path. An empty entry list returns undefined immediately (nothing to
|
|
495
|
+
* pick), though the menu always offers at least the `.` here entry.
|
|
496
|
+
*/
|
|
497
|
+
function select(
|
|
498
|
+
entries: readonly MenuEntry[],
|
|
499
|
+
opts: {header?: string} = {},
|
|
500
|
+
): MenuEntry | undefined {
|
|
501
|
+
if (entries.length === 0) return undefined;
|
|
502
|
+
const out = process.stdout;
|
|
503
|
+
const stdin = process.stdin;
|
|
504
|
+
let active = 0;
|
|
505
|
+
|
|
506
|
+
const render = (first: boolean): void => {
|
|
507
|
+
if (!first) {
|
|
508
|
+
// move cursor up over the previously drawn rows to redraw in place.
|
|
509
|
+
out.write(`${ESC}[${entries.length}A`);
|
|
510
|
+
}
|
|
511
|
+
for (let i = 0; i < entries.length; i++) {
|
|
512
|
+
const selected = i === active;
|
|
513
|
+
const cursor = selected ? '>' : ' ';
|
|
514
|
+
const text = `${cursor} ${entries[i].label}`;
|
|
515
|
+
// clear the line, then draw; reverse-video the active row.
|
|
516
|
+
out.write(`${ESC}[2K`);
|
|
517
|
+
out.write(selected ? `${ESC}[7m${text}${ESC}[0m` : text);
|
|
518
|
+
out.write('\n');
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
523
|
+
const restore = (): void => {
|
|
524
|
+
try {
|
|
525
|
+
if (stdin.setRawMode) stdin.setRawMode(wasRaw);
|
|
526
|
+
} catch {
|
|
527
|
+
/* best-effort */
|
|
105
528
|
}
|
|
106
|
-
|
|
529
|
+
out.write(`${ESC}[?25h`); // show the cursor again
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
if (opts.header) out.write(opts.header + '\n');
|
|
533
|
+
out.write(`${ESC}[?25l`); // hide the cursor while navigating
|
|
534
|
+
try {
|
|
535
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
536
|
+
} catch {
|
|
537
|
+
/* if raw mode is unavailable we still render + read line-ish below */
|
|
107
538
|
}
|
|
539
|
+
render(true);
|
|
108
540
|
|
|
109
|
-
|
|
110
|
-
|
|
541
|
+
const buf = Buffer.alloc(3);
|
|
542
|
+
for (;;) {
|
|
543
|
+
let n: number;
|
|
544
|
+
try {
|
|
545
|
+
n = readSync(0, buf, 0, 3, null);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
if ((e as NodeJS.ErrnoException).code === 'EAGAIN') continue;
|
|
548
|
+
restore();
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
if (n === 0) {
|
|
552
|
+
restore();
|
|
553
|
+
return undefined; // EOF
|
|
554
|
+
}
|
|
555
|
+
const s = buf.toString('utf8', 0, n);
|
|
556
|
+
// Ctrl-C (ETX \x03), q, or a bare Esc: cancel.
|
|
557
|
+
if (s === '\u0003' || s === 'q' || s === ESC) {
|
|
558
|
+
restore();
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
// Enter (CR or LF): select the active row.
|
|
562
|
+
if (s === '\r' || s === '\n') {
|
|
563
|
+
restore();
|
|
564
|
+
return entries[active];
|
|
565
|
+
}
|
|
566
|
+
// Up: arrow `Esc [ A` / `Esc O A`, or k. Down: `Esc [ B` / `Esc O B`, or j.
|
|
567
|
+
if (s === `${ESC}[A` || s === `${ESC}OA` || s === 'k') {
|
|
568
|
+
active = (active - 1 + entries.length) % entries.length;
|
|
569
|
+
render(false);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (s === `${ESC}[B` || s === `${ESC}OB` || s === 'j') {
|
|
573
|
+
active = (active + 1) % entries.length;
|
|
574
|
+
render(false);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// any other key: ignore, keep waiting.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// --- the `machine` verbs (thin dispatch over the pure parts) -----------------
|
|
582
|
+
//
|
|
583
|
+
// Parse the `machine <verb> …` grammar (pure parseMachineArgs), then do only the
|
|
584
|
+
// I/O: mkdir/write the machine layout (create), read machines/*/machine.json
|
|
585
|
+
// (list), rewrite machine.json + WARN (set-image), and rm the machine dir with
|
|
586
|
+
// the confirm/`--yes`/non-TTY discipline (rm). All validation + the machine.json
|
|
587
|
+
// body + the warning wording live in the pure module.
|
|
588
|
+
function runMachine(machineArgs: string[]): number {
|
|
589
|
+
if (machineArgs.includes('--help') || machineArgs.includes('-h')) {
|
|
590
|
+
process.stdout.write(MACHINE_HELP);
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const env = envFromProcess(process.env);
|
|
595
|
+
let cmd: MachineCommand;
|
|
596
|
+
try {
|
|
597
|
+
cmd = parseMachineArgs(machineArgs);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
return reportAnonPiError(e);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
switch (cmd.verb) {
|
|
604
|
+
case 'create':
|
|
605
|
+
return machineCreate(env, cmd.name, cmd.image);
|
|
606
|
+
case 'list':
|
|
607
|
+
return machineList(env);
|
|
608
|
+
case 'set-image':
|
|
609
|
+
return machineSetImage(env, cmd.name, cmd.image);
|
|
610
|
+
case 'rm':
|
|
611
|
+
return machineRm(env, cmd.name, cmd.yes);
|
|
612
|
+
}
|
|
613
|
+
} catch (e) {
|
|
614
|
+
return reportAnonPiError(e);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* `machine create <name> [--image <ref>]`: write machines/<name>/{machine.json,
|
|
620
|
+
* home/} and PIN the image (from --image or a TTY prompt). The home is only a
|
|
621
|
+
* dir here; it is SEEDED on first LAUNCH, not now. Refuses to clobber an
|
|
622
|
+
* existing machine.
|
|
623
|
+
*/
|
|
624
|
+
function machineCreate(
|
|
625
|
+
env: AnonPiEnv,
|
|
626
|
+
name: string,
|
|
627
|
+
image: string | undefined,
|
|
628
|
+
): number {
|
|
629
|
+
const dir = machineDir(env, name);
|
|
630
|
+
if (existsSync(dir)) {
|
|
111
631
|
process.stderr.write(
|
|
112
|
-
|
|
113
|
-
'
|
|
632
|
+
`anon-pi: machine ${JSON.stringify(name)} already exists (${dir}). ` +
|
|
633
|
+
'Use `anon-pi machine set-image` to re-pin its image, or `anon-pi machine rm` first.\n',
|
|
114
634
|
);
|
|
115
635
|
return 1;
|
|
116
636
|
}
|
|
117
637
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
process.
|
|
123
|
-
'anon-pi: ephemeral session (nothing persisted; no host state)\n',
|
|
124
|
-
);
|
|
125
|
-
} else {
|
|
126
|
-
// Persistent mode: create the per-workdir state home to mount.
|
|
127
|
-
mkdirSync(plan.stateDir, {recursive: true});
|
|
128
|
-
if (plan.fresh) {
|
|
638
|
+
// Pin the image: --image wins; else prompt on a TTY; else it is an error (a
|
|
639
|
+
// machine with no image cannot launch, so we refuse a headless imageless create).
|
|
640
|
+
let pinned = image;
|
|
641
|
+
if (pinned === undefined) {
|
|
642
|
+
if (!process.stdin.isTTY) {
|
|
129
643
|
process.stderr.write(
|
|
130
|
-
|
|
644
|
+
'anon-pi: no image and no TTY to prompt. Pass `--image <ref>` to pin the ' +
|
|
645
|
+
"machine's image (a container ref with `pi` on PATH).\n",
|
|
131
646
|
);
|
|
647
|
+
return 1;
|
|
648
|
+
}
|
|
649
|
+
pinned = promptLine(
|
|
650
|
+
`Image ref for machine ${JSON.stringify(name)} (a container with \`pi\` on PATH): `,
|
|
651
|
+
);
|
|
652
|
+
if (pinned === undefined || pinned.trim() === '') {
|
|
653
|
+
process.stderr.write('anon-pi: no image given; aborting create.\n');
|
|
654
|
+
return 1;
|
|
132
655
|
}
|
|
133
656
|
}
|
|
134
657
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
658
|
+
mkdirSync(machineHomeDir(env, name), {recursive: true});
|
|
659
|
+
writeFileSync(
|
|
660
|
+
machineJsonPath(env, name),
|
|
661
|
+
serializeMachineJson({image: pinned}),
|
|
662
|
+
);
|
|
663
|
+
process.stdout.write(
|
|
664
|
+
`anon-pi: created machine ${JSON.stringify(name)} (image ${pinned.trim()}) at ${dir}.\n` +
|
|
665
|
+
`Its home is seeded on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`,
|
|
666
|
+
);
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* `machine list`: print each machine under machines/ with its pinned image
|
|
672
|
+
* (reading each machine's machine.json). An absent/garbage machine.json shows
|
|
673
|
+
* `(no image)` rather than erroring, so a hand-edited workspace still lists.
|
|
674
|
+
*/
|
|
675
|
+
function machineList(env: AnonPiEnv): number {
|
|
676
|
+
const root = join(resolveAnonPiHome(env), 'machines');
|
|
677
|
+
const names = existsSync(root)
|
|
678
|
+
? readdirSync(root, {withFileTypes: true})
|
|
679
|
+
.filter((d) => d.isDirectory())
|
|
680
|
+
.map((d) => d.name)
|
|
681
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
682
|
+
: [];
|
|
683
|
+
if (names.length === 0) {
|
|
684
|
+
process.stdout.write(
|
|
685
|
+
'anon-pi: no machines yet. Create one with `anon-pi machine create <name> --image <ref>`.\n',
|
|
686
|
+
);
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
for (const name of names) {
|
|
690
|
+
const conf = readMachineJson(env, name);
|
|
691
|
+
const image = conf.image ?? '(no image)';
|
|
692
|
+
process.stdout.write(`${name}\t${image}\n`);
|
|
693
|
+
}
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* `machine set-image <name> <ref>`: RE-PIN the image and WARN only. It does NOT
|
|
699
|
+
* reseed or touch the home (the home's extensions/bin were built for the OLD
|
|
700
|
+
* image). Preserves any per-machine projects override. The machine must exist.
|
|
701
|
+
*/
|
|
702
|
+
function machineSetImage(env: AnonPiEnv, name: string, image: string): number {
|
|
703
|
+
const dir = machineDir(env, name);
|
|
704
|
+
if (!existsSync(dir)) {
|
|
138
705
|
process.stderr.write(
|
|
139
|
-
`anon-pi:
|
|
706
|
+
`anon-pi: no machine ${JSON.stringify(name)} (${dir}). ` +
|
|
707
|
+
'Create it first with `anon-pi machine create`.\n',
|
|
140
708
|
);
|
|
141
709
|
return 1;
|
|
142
710
|
}
|
|
143
|
-
|
|
144
|
-
|
|
711
|
+
const prev = readMachineJson(env, name);
|
|
712
|
+
writeFileSync(
|
|
713
|
+
machineJsonPath(env, name),
|
|
714
|
+
serializeMachineJson({image, projects: prev.projects}),
|
|
715
|
+
);
|
|
716
|
+
process.stderr.write(setImageWarning(name, prev.image, image.trim()) + '\n');
|
|
717
|
+
return 0;
|
|
145
718
|
}
|
|
146
719
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
720
|
+
/**
|
|
721
|
+
* `machine rm <name> [--yes]`: delete the machine dir (its machine.json + home)
|
|
722
|
+
* after a confirm. Mirrors the destructive data-verb discipline: confirm on a
|
|
723
|
+
* TTY, `--yes` skips it, and a non-TTY WITHOUT `--yes` ABORTS (never deletes
|
|
724
|
+
* unprompted in a script). The machine must exist.
|
|
725
|
+
*/
|
|
726
|
+
function machineRm(env: AnonPiEnv, name: string, yes: boolean): number {
|
|
727
|
+
const dir = machineDir(env, name);
|
|
728
|
+
if (!existsSync(dir)) {
|
|
729
|
+
process.stderr.write(
|
|
730
|
+
`anon-pi: no machine ${JSON.stringify(name)} (${dir}); nothing to remove.\n`,
|
|
731
|
+
);
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!yes) {
|
|
736
|
+
if (!process.stdin.isTTY) {
|
|
737
|
+
process.stderr.write(
|
|
738
|
+
`anon-pi: refusing to delete machine ${JSON.stringify(name)} without a TTY to confirm. ` +
|
|
739
|
+
'Re-run with `--yes` to delete it (its home + conversations) non-interactively.\n',
|
|
740
|
+
);
|
|
741
|
+
return 1;
|
|
742
|
+
}
|
|
743
|
+
const answer = promptLine(
|
|
744
|
+
`Delete machine ${JSON.stringify(name)} and its home (conversations, config) at ${dir}? [y/N] `,
|
|
745
|
+
);
|
|
746
|
+
if (answer === undefined || !/^y(es)?$/i.test(answer.trim())) {
|
|
747
|
+
process.stderr.write('anon-pi: aborted; nothing deleted.\n');
|
|
748
|
+
return 1;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
rmSync(dir, {recursive: true, force: true});
|
|
753
|
+
process.stdout.write(
|
|
754
|
+
`anon-pi: removed machine ${JSON.stringify(name)} (${dir}).\n`,
|
|
152
755
|
);
|
|
153
|
-
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// --- the destructive cleanup verbs (thin I/O over the pure resolvers) --------
|
|
760
|
+
//
|
|
761
|
+
// `--delete-home [<machine>]` and `--delete-project <project>` REPLACE the old
|
|
762
|
+
// `--fresh`. The pure module resolves the affected host paths (resolveDeleteHome
|
|
763
|
+
// / resolveDeleteProject); the CLI does ONLY the I/O: read config (for the
|
|
764
|
+
// default machine + the projects root), filter the resolved paths to those that
|
|
765
|
+
// exist, run the shared confirm/`--yes`/non-TTY discipline, then `rm`.
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Parse the shared `[<positional>] [--yes|-y]` tail of a data verb. Returns the
|
|
769
|
+
* (optional) positional (a machine or project name) + the `--yes` flag, or an
|
|
770
|
+
* AnonPiError-style exit for an unknown flag / an extra positional.
|
|
771
|
+
*/
|
|
772
|
+
function parseDeleteArgs(
|
|
773
|
+
args: string[],
|
|
774
|
+
verb: string,
|
|
775
|
+
): {name?: string; yes: boolean} | number {
|
|
776
|
+
let name: string | undefined;
|
|
777
|
+
let yes = false;
|
|
778
|
+
for (const a of args) {
|
|
779
|
+
if (a === '--yes' || a === '-y') {
|
|
780
|
+
yes = true;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (a.startsWith('-')) {
|
|
784
|
+
process.stderr.write(
|
|
785
|
+
`anon-pi: unknown option for ${verb}: ${a}. Run \`anon-pi --help\`.\n`,
|
|
786
|
+
);
|
|
787
|
+
return 1;
|
|
788
|
+
}
|
|
789
|
+
if (name !== undefined) {
|
|
790
|
+
process.stderr.write(
|
|
791
|
+
`anon-pi: ${verb} takes one name, got extra: ${a}. Run \`anon-pi --help\`.\n`,
|
|
792
|
+
);
|
|
793
|
+
return 1;
|
|
794
|
+
}
|
|
795
|
+
name = a;
|
|
796
|
+
}
|
|
797
|
+
return {name, yes};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Run the confirm/`--yes`/non-TTY discipline for a destructive delete: `--yes`
|
|
802
|
+
* skips the prompt; a non-TTY WITHOUT `--yes` ABORTS (never deletes unprompted
|
|
803
|
+
* in a script); a TTY prompts `[y/N]`. Returns true to PROCEED, false to abort
|
|
804
|
+
* (the caller has already printed nothing; this prints the abort/refusal note).
|
|
805
|
+
*/
|
|
806
|
+
function confirmDelete(what: string, yes: boolean): boolean {
|
|
807
|
+
if (yes) return true;
|
|
808
|
+
if (!process.stdin.isTTY) {
|
|
154
809
|
process.stderr.write(
|
|
155
|
-
`anon-pi
|
|
810
|
+
`anon-pi: refusing to delete ${what} without a TTY to confirm. ` +
|
|
811
|
+
'Re-run with `--yes` to delete it non-interactively.\n',
|
|
156
812
|
);
|
|
157
|
-
return
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
const answer = promptLine(`Delete ${what}? [y/N] `);
|
|
816
|
+
if (answer === undefined || !/^y(es)?$/i.test(answer.trim())) {
|
|
817
|
+
process.stderr.write('anon-pi: aborted; nothing deleted.\n');
|
|
818
|
+
return false;
|
|
158
819
|
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
159
822
|
|
|
823
|
+
/**
|
|
824
|
+
* `--delete-home [<machine>]`: delete ONE machine's HOME (config + convos + shell
|
|
825
|
+
* env), keeping its machine.json image pin (so it can be relaunched to reseed a
|
|
826
|
+
* fresh home) and ALL project files (they live under the projects root). Default
|
|
827
|
+
* machine (config.defaultMachine, else the built-in DEFAULT_MACHINE) when the
|
|
828
|
+
* name is omitted. Confirm / `--yes` / non-TTY abort.
|
|
829
|
+
*/
|
|
830
|
+
function runDeleteHome(args: string[]): number {
|
|
831
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
832
|
+
process.stdout.write(HELP);
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
160
835
|
const env = envFromProcess(process.env);
|
|
836
|
+
const parsed = parseDeleteArgs(args, '--delete-home');
|
|
837
|
+
if (typeof parsed === 'number') return parsed;
|
|
838
|
+
|
|
839
|
+
const config = readJsonConfig(env);
|
|
840
|
+
const machine = parsed.name ?? config.defaultMachine ?? DEFAULT_MACHINE;
|
|
841
|
+
|
|
842
|
+
let plan;
|
|
843
|
+
try {
|
|
844
|
+
plan = resolveDeleteHome(env, machine);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
return reportAnonPiError(e);
|
|
847
|
+
}
|
|
161
848
|
|
|
162
|
-
if (!
|
|
849
|
+
if (!existsSync(plan.home)) {
|
|
163
850
|
process.stderr.write(
|
|
164
|
-
|
|
165
|
-
'model whose provider should be imported (e.g. ANON_PI_LLM=192.168.1.150:8080).\n',
|
|
851
|
+
`anon-pi: no home for machine ${JSON.stringify(plan.machine)} (${plan.home}); nothing to delete.\n`,
|
|
166
852
|
);
|
|
167
853
|
return 1;
|
|
168
854
|
}
|
|
169
855
|
|
|
170
|
-
|
|
171
|
-
|
|
856
|
+
if (
|
|
857
|
+
!confirmDelete(
|
|
858
|
+
`machine ${JSON.stringify(plan.machine)} home (conversations + config) at ${plan.home}`,
|
|
859
|
+
parsed.yes,
|
|
860
|
+
)
|
|
861
|
+
) {
|
|
862
|
+
return 1;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
rmSync(plan.home, {recursive: true, force: true});
|
|
866
|
+
process.stdout.write(
|
|
867
|
+
`anon-pi: deleted machine ${JSON.stringify(plan.machine)} home (${plan.home}). ` +
|
|
868
|
+
'Its image pin is kept; relaunch to seed a fresh home.\n',
|
|
869
|
+
);
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* `--delete-project <project>`: delete the project's FILES (its folder under the
|
|
875
|
+
* resolved projects root) AND that project's per-machine session dir in EVERY
|
|
876
|
+
* machine home (the machine-invariant slug), keeping the homes otherwise intact.
|
|
877
|
+
* Confirm / `--yes` / non-TTY abort. The project name is REQUIRED.
|
|
878
|
+
*/
|
|
879
|
+
function runDeleteProject(args: string[]): number {
|
|
880
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
881
|
+
process.stdout.write(HELP);
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
const env = envFromProcess(process.env);
|
|
885
|
+
const parsed = parseDeleteArgs(args, '--delete-project');
|
|
886
|
+
if (typeof parsed === 'number') return parsed;
|
|
887
|
+
if (parsed.name === undefined) {
|
|
172
888
|
process.stderr.write(
|
|
173
|
-
|
|
174
|
-
'Set ANON_PI_SOURCE_MODELS to your pi models.json, or run pi once to create it.\n',
|
|
889
|
+
'anon-pi: --delete-project needs a <project>. Run `anon-pi --help`.\n',
|
|
175
890
|
);
|
|
176
891
|
return 1;
|
|
177
892
|
}
|
|
178
893
|
|
|
179
|
-
|
|
894
|
+
const config = readJsonConfig(env);
|
|
895
|
+
// The RESOLVED projects root (config/env override, else the built-in). No
|
|
896
|
+
// --mount here: a data verb targets the durable projects root, not a per-run
|
|
897
|
+
// host parent.
|
|
898
|
+
const projectsRoot = resolveProjectsRoot({env, config});
|
|
899
|
+
const machines = listMachineNames(env);
|
|
900
|
+
|
|
901
|
+
let plan;
|
|
180
902
|
try {
|
|
181
|
-
|
|
903
|
+
plan = resolveDeleteProject({
|
|
904
|
+
env,
|
|
905
|
+
project: parsed.name,
|
|
906
|
+
projectsRoot,
|
|
907
|
+
machines,
|
|
908
|
+
});
|
|
182
909
|
} catch (e) {
|
|
910
|
+
return reportAnonPiError(e);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Only the paths that actually exist: the folder (maybe absent) + whichever
|
|
914
|
+
// machine homes hold this project's session dir.
|
|
915
|
+
const targets = [plan.folder, ...plan.sessions].filter((p) => existsSync(p));
|
|
916
|
+
if (targets.length === 0) {
|
|
183
917
|
process.stderr.write(
|
|
184
|
-
`anon-pi
|
|
918
|
+
`anon-pi: no files or sessions found for project ${JSON.stringify(plan.project)} ` +
|
|
919
|
+
`(looked in ${plan.folder} and each machine home); nothing to delete.\n`,
|
|
185
920
|
);
|
|
186
921
|
return 1;
|
|
187
922
|
}
|
|
188
923
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
924
|
+
const sessionCount = targets.length - (existsSync(plan.folder) ? 1 : 0);
|
|
925
|
+
if (
|
|
926
|
+
!confirmDelete(
|
|
927
|
+
`project ${JSON.stringify(plan.project)}: its files (${plan.folder}) ` +
|
|
928
|
+
`and ${sessionCount} per-machine session dir(s)`,
|
|
929
|
+
parsed.yes,
|
|
930
|
+
)
|
|
931
|
+
) {
|
|
932
|
+
return 1;
|
|
198
933
|
}
|
|
199
934
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
935
|
+
for (const p of targets) rmSync(p, {recursive: true, force: true});
|
|
936
|
+
process.stdout.write(
|
|
937
|
+
`anon-pi: deleted project ${JSON.stringify(plan.project)} ` +
|
|
938
|
+
`(files + ${sessionCount} per-machine session dir(s)). The machine homes are kept.\n`,
|
|
939
|
+
);
|
|
940
|
+
return 0;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// --- `anon-pi init` onboarding (thin I/O over the pure detect/verify decisions) --
|
|
944
|
+
//
|
|
945
|
+
// init is the HONEST, re-runnable onboarding. It captures the socks5h PROXY (by
|
|
946
|
+
// evidence: open ports + a real SOCKS5 handshake + a real `netcage verify` exit
|
|
947
|
+
// IP, NEVER a provider label), the local-model ENDPOINT (generating models.json
|
|
948
|
+
// from it), and the default machine IMAGE (menu from shipped Dockerfiles / an
|
|
949
|
+
// existing ref / skip, building via `podman build`), then writes config.json +
|
|
950
|
+
// the `default` machine. It REPLACES the old `import`. All the DECISIONS are
|
|
951
|
+
// pure (anon-pi.ts); this does only the socket probes, the netcage/podman
|
|
952
|
+
// spawns, and the prompts. It NEVER destroys machines/homes: it pre-fills
|
|
953
|
+
// current values and only ADDS/updates config + a fresh default machine.
|
|
954
|
+
|
|
955
|
+
const INIT_HELP = `anon-pi init - onboard: verify your proxy, capture your local model, pick an image
|
|
956
|
+
|
|
957
|
+
USAGE
|
|
958
|
+
anon-pi init interactive onboarding (re-runnable reconfigure)
|
|
959
|
+
|
|
960
|
+
WHAT IT DOES
|
|
961
|
+
1. PROXY: probes common SOCKS ports, confirms SOCKS5 via a real handshake,
|
|
962
|
+
shows the findings (EVIDENCE only, never a provider label), then runs
|
|
963
|
+
\`netcage verify\` and shows the real EXIT IP as proof. You confirm.
|
|
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).
|
|
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.
|
|
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).
|
|
981
|
+
`;
|
|
982
|
+
|
|
983
|
+
function runInit(args: string[]): number {
|
|
984
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
985
|
+
process.stdout.write(INIT_HELP);
|
|
986
|
+
return 0;
|
|
987
|
+
}
|
|
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) {
|
|
203
992
|
process.stderr.write(
|
|
204
|
-
`anon-pi
|
|
993
|
+
`anon-pi: init takes no arguments (except ${FORCE_KEY_FLAG}), got: ${extra.join(' ')}. Run \`anon-pi init --help\`.\n`,
|
|
205
994
|
);
|
|
206
995
|
return 1;
|
|
207
996
|
}
|
|
208
997
|
|
|
209
|
-
if (
|
|
998
|
+
if (!process.stdin.isTTY) {
|
|
210
999
|
process.stderr.write(
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
|
|
1000
|
+
'anon-pi: init is interactive and needs a TTY. Run it in a terminal. To set\n' +
|
|
1001
|
+
'values non-interactively, write ~/.anon-pi/config.json + a machine.json by hand,\n' +
|
|
1002
|
+
'or export ANON_PI_PROXY / ANON_PI_LLM (they override config.json).\n',
|
|
214
1003
|
);
|
|
1004
|
+
return 1;
|
|
215
1005
|
}
|
|
216
1006
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
1007
|
+
const env = envFromProcess(process.env);
|
|
1008
|
+
// Pre-fill from the CURRENT config (re-runnable: init doubles as reconfigure).
|
|
1009
|
+
const current = readJsonConfig(env);
|
|
1010
|
+
|
|
1011
|
+
process.stdout.write(
|
|
1012
|
+
'anon-pi init: honest, evidence-based onboarding. Nothing is destroyed; your\n' +
|
|
1013
|
+
'current values are pre-filled. Press Ctrl-C to abort at any prompt.\n\n',
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// 1) PROXY: probe + handshake + findings + netcage verify + confirm.
|
|
1017
|
+
const proxyHostPort = initProxyStep(current.proxy);
|
|
1018
|
+
if (proxyHostPort === undefined) return 1;
|
|
1019
|
+
const proxyUrl = socks5hUrl(proxyHostPort);
|
|
1020
|
+
|
|
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;
|
|
1028
|
+
|
|
1029
|
+
// 3) DEFAULT MACHINE IMAGE: menu (shipped Dockerfiles / existing ref / skip).
|
|
1030
|
+
const image = initImageStep();
|
|
1031
|
+
if (image === ABORT) return 1;
|
|
1032
|
+
|
|
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
|
|
1038
|
+
// home). The proxy is always present (we only reach here on a chosen proxy).
|
|
1039
|
+
const anonHome = resolveAnonPiHome(env);
|
|
1040
|
+
mkdirSync(anonHome, {recursive: true});
|
|
1041
|
+
const configPath = join(anonHome, 'config.json');
|
|
1042
|
+
const nextConfig: AnonPiConfig = {
|
|
1043
|
+
proxy: proxyUrl,
|
|
1044
|
+
llm: llm ?? current.llm,
|
|
1045
|
+
defaultMachine: current.defaultMachine ?? DEFAULT_MACHINE,
|
|
1046
|
+
projects: projects ?? current.projects,
|
|
1047
|
+
};
|
|
1048
|
+
writeFileSync(configPath, serializeConfigJson(nextConfig));
|
|
1049
|
+
process.stdout.write(`\nanon-pi: wrote ${configPath}.\n`);
|
|
1050
|
+
|
|
1051
|
+
// The `default` machine: create it if absent (NEVER wipe an existing home),
|
|
1052
|
+
// pin/re-pin its image when one was chosen. Its home seeds on first launch.
|
|
1053
|
+
initWriteDefaultMachine(env, image);
|
|
1054
|
+
|
|
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) {
|
|
1061
|
+
const mdir = machineDir(env, DEFAULT_MACHINE);
|
|
1062
|
+
mkdirSync(mdir, {recursive: true});
|
|
1063
|
+
const models = generateModelsJson(
|
|
1064
|
+
endpoint,
|
|
1065
|
+
llmResult.models,
|
|
1066
|
+
llmResult.apiKey,
|
|
1067
|
+
);
|
|
1068
|
+
writeFileSync(
|
|
1069
|
+
join(mdir, MODELS_FILE),
|
|
1070
|
+
JSON.stringify(models, null, '\t') + '\n',
|
|
1071
|
+
);
|
|
1072
|
+
process.stdout.write(
|
|
1073
|
+
`anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}" ` +
|
|
1074
|
+
`(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}).\n`,
|
|
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
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
process.stdout.write(
|
|
1097
|
+
'\nanon-pi: onboarding complete. Launch with `anon-pi <project>` or ' +
|
|
1098
|
+
'`anon-pi --shell`.\n',
|
|
221
1099
|
);
|
|
222
1100
|
return 0;
|
|
223
1101
|
}
|
|
224
1102
|
|
|
1103
|
+
/** A sentinel a step returns when the user aborted (distinct from "skipped"). */
|
|
1104
|
+
const ABORT = Symbol('abort');
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* The PROXY step: probe the default SOCKS ports, confirm SOCKS5 via a real
|
|
1108
|
+
* handshake, show the EVIDENCE (never a provider label), let the user CHOOSE a
|
|
1109
|
+
* SOCKS5-confirmed port or enter host:port, then run `netcage verify` and show
|
|
1110
|
+
* the real EXIT IP before confirming. Returns the chosen host:port, or undefined
|
|
1111
|
+
* on abort. The socket probes + the netcage spawn are the only I/O; the display
|
|
1112
|
+
* + the handshake verdict come from the pure module.
|
|
1113
|
+
*/
|
|
1114
|
+
function initProxyStep(currentProxy: string | undefined): string | undefined {
|
|
1115
|
+
process.stdout.write(
|
|
1116
|
+
'Step 1/4 - proxy (the socks5h endpoint that anonymizes egress)\n',
|
|
1117
|
+
);
|
|
1118
|
+
if (currentProxy) {
|
|
1119
|
+
process.stdout.write(` current: ${currentProxy}\n`);
|
|
1120
|
+
}
|
|
1121
|
+
process.stdout.write(' Probing common SOCKS ports (evidence only)...\n');
|
|
1122
|
+
|
|
1123
|
+
// Probe each default port: TCP-open + a real SOCKS5 handshake. The weak
|
|
1124
|
+
// process hint (a running `tor`/`wireproxy` LOCAL process, never the exit
|
|
1125
|
+
// provider) is HOST-WIDE, so gather it ONCE and pass it as the formatter's
|
|
1126
|
+
// single general note rather than gluing it onto every port line. The display
|
|
1127
|
+
// is the PURE formatter's job.
|
|
1128
|
+
const runningProcesses = observeRunningProcesses();
|
|
1129
|
+
const processNote = matchProcessHint(runningProcesses);
|
|
1130
|
+
const findings: ProxyFinding[] = DEFAULT_SOCKS_PROBE_PORTS.map(
|
|
1131
|
+
({port, hint}) => {
|
|
1132
|
+
const {open, handshake} = probeSocks5('127.0.0.1', port);
|
|
1133
|
+
return {
|
|
1134
|
+
host: '127.0.0.1',
|
|
1135
|
+
port,
|
|
1136
|
+
open,
|
|
1137
|
+
handshake,
|
|
1138
|
+
portHint: hint,
|
|
1139
|
+
};
|
|
1140
|
+
},
|
|
1141
|
+
);
|
|
1142
|
+
process.stdout.write(
|
|
1143
|
+
'\n' + formatProxyFindings(findings, processNote) + '\n\n',
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
// Offer the SOCKS5-confirmed candidates as quick picks; always allow a manual
|
|
1147
|
+
// host:port entry (and pre-fill the current one).
|
|
1148
|
+
const confirmed = findings.filter((f) => f.open && f.handshake?.socks5);
|
|
1149
|
+
for (;;) {
|
|
1150
|
+
if (confirmed.length > 0) {
|
|
1151
|
+
process.stdout.write('SOCKS5-confirmed ports:\n');
|
|
1152
|
+
confirmed.forEach((f, i) => {
|
|
1153
|
+
process.stdout.write(` [${i + 1}] ${f.host}:${f.port}\n`);
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
const prefill = currentProxy
|
|
1157
|
+
? ` (or Enter to keep ${hostPortKey(currentProxy)})`
|
|
1158
|
+
: '';
|
|
1159
|
+
const ans = promptLine(`Choose a number, or enter host:port${prefill}: `);
|
|
1160
|
+
if (ans === undefined) {
|
|
1161
|
+
process.stderr.write('anon-pi: aborted; nothing written.\n');
|
|
1162
|
+
return undefined;
|
|
1163
|
+
}
|
|
1164
|
+
const trimmed = ans.trim();
|
|
1165
|
+
let chosen: string | undefined;
|
|
1166
|
+
if (trimmed === '' && currentProxy) {
|
|
1167
|
+
chosen = hostPortKey(currentProxy);
|
|
1168
|
+
} else if (/^\d+$/.test(trimmed) && confirmed.length > 0) {
|
|
1169
|
+
const idx = Number(trimmed) - 1;
|
|
1170
|
+
if (idx >= 0 && idx < confirmed.length) {
|
|
1171
|
+
const f = confirmed[idx];
|
|
1172
|
+
chosen = `${f.host}:${f.port}`;
|
|
1173
|
+
}
|
|
1174
|
+
} else if (trimmed !== '') {
|
|
1175
|
+
chosen = hostPortKey(trimmed);
|
|
1176
|
+
}
|
|
1177
|
+
if (chosen === undefined || chosen === '') {
|
|
1178
|
+
process.stdout.write(
|
|
1179
|
+
' Please pick a listed number or enter a host:port.\n',
|
|
1180
|
+
);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// VERIFY: run `netcage verify --proxy socks5h://<chosen>` and show the real
|
|
1185
|
+
// exit IP as evidence it is NOT the host IP. The user confirms ON that
|
|
1186
|
+
// evidence. netcage never announces the provider, so neither do we.
|
|
1187
|
+
const url = socks5hUrl(chosen);
|
|
1188
|
+
process.stdout.write(
|
|
1189
|
+
`\n Verifying via netcage: netcage verify --proxy ${url}\n`,
|
|
1190
|
+
);
|
|
1191
|
+
if (!hasNetcage()) {
|
|
1192
|
+
process.stderr.write(
|
|
1193
|
+
'anon-pi: `netcage` not found on PATH, cannot verify the exit IP. Install\n' +
|
|
1194
|
+
'it first (https://github.com/wighawag/netcage). Linux only.\n',
|
|
1195
|
+
);
|
|
1196
|
+
return undefined;
|
|
1197
|
+
}
|
|
1198
|
+
const verify = spawnSync('netcage', ['verify', '--proxy', url], {
|
|
1199
|
+
encoding: 'utf8',
|
|
1200
|
+
});
|
|
1201
|
+
const output = `${verify.stdout ?? ''}${verify.stderr ?? ''}`;
|
|
1202
|
+
if (verify.error || verify.status !== 0) {
|
|
1203
|
+
process.stdout.write(output.trimEnd() + '\n');
|
|
1204
|
+
process.stdout.write(
|
|
1205
|
+
` netcage verify FAILED for ${url} (exit ${verify.status ?? 'n/a'}). ` +
|
|
1206
|
+
'Pick another port or fix the proxy.\n\n',
|
|
1207
|
+
);
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
const exitIp = parseVerifyExitIp(output);
|
|
1211
|
+
if (exitIp) {
|
|
1212
|
+
process.stdout.write(
|
|
1213
|
+
` Exit IP (via the proxy, NOT your host): ${exitIp}\n`,
|
|
1214
|
+
);
|
|
1215
|
+
} else {
|
|
1216
|
+
process.stdout.write(
|
|
1217
|
+
' netcage verify succeeded but no exit IP was parsed; raw output:\n' +
|
|
1218
|
+
output.trimEnd() +
|
|
1219
|
+
'\n',
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
const ok = promptLine(` Use ${url} as your proxy? [Y/n] `);
|
|
1223
|
+
if (ok === undefined) {
|
|
1224
|
+
process.stderr.write('anon-pi: aborted; nothing written.\n');
|
|
1225
|
+
return undefined;
|
|
1226
|
+
}
|
|
1227
|
+
if (/^n(o)?$/i.test(ok.trim())) {
|
|
1228
|
+
process.stdout.write(' OK, pick another.\n\n');
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
return chosen;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
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
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The LOCAL MODEL step: capture host:port (pre-filled from config), probe TCP
|
|
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).
|
|
1252
|
+
*/
|
|
1253
|
+
function initLlmStep(
|
|
1254
|
+
env: AnonPiEnv,
|
|
1255
|
+
currentLlm: string | undefined,
|
|
1256
|
+
forceLocalApiKey: boolean,
|
|
1257
|
+
): LlmStepResult | typeof ABORT {
|
|
1258
|
+
process.stdout.write(
|
|
1259
|
+
'\nStep 2/4 - local model endpoint (the ONE direct hole)\n',
|
|
1260
|
+
);
|
|
1261
|
+
if (currentLlm) process.stdout.write(` current: ${currentLlm}\n`);
|
|
1262
|
+
const prefill = currentLlm
|
|
1263
|
+
? ` (or Enter to keep ${currentLlm})`
|
|
1264
|
+
: ' (or Enter to skip)';
|
|
1265
|
+
const ans = promptLine(
|
|
1266
|
+
` Local model host:port, e.g. 192.168.1.150:8080${prefill}: `,
|
|
1267
|
+
);
|
|
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
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Probe reachability: evidence only. A closed port is not fatal (the model may
|
|
1281
|
+
// start later); we just report it.
|
|
1282
|
+
const key = hostPortKey(endpoint);
|
|
1283
|
+
const colon = key.lastIndexOf(':');
|
|
1284
|
+
const host = colon > 0 ? key.slice(0, colon) : key;
|
|
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[] = [];
|
|
1321
|
+
if (Number.isFinite(port)) {
|
|
1322
|
+
const listing = fetchModelsListing(host, port);
|
|
1323
|
+
serverIds = parseModelsListing(listing);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (hostModelEntries.length === 0 && serverIds.length === 0) {
|
|
1327
|
+
process.stdout.write(
|
|
1328
|
+
' No models found (no matching provider in your pi config, and the server\n' +
|
|
1329
|
+
' returned none). The provider is still seeded; add models in pi later.\n',
|
|
1330
|
+
);
|
|
1331
|
+
return {endpoint, models: [], apiKey, defaultId: undefined};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const candidates = mergeModelSources(hostModelEntries, serverIds);
|
|
1335
|
+
const chosen = initModelPicker(candidates);
|
|
1336
|
+
if (chosen === undefined) {
|
|
1337
|
+
// Skip: seed the provider with no models.
|
|
1338
|
+
return {endpoint, models: [], apiKey, defaultId: undefined};
|
|
1339
|
+
}
|
|
1340
|
+
const defaultId = initDefaultModelPicker(chosen);
|
|
1341
|
+
return {endpoint, models: chosen, apiKey, defaultId};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Present the merged candidate list and let the user choose which to import.
|
|
1346
|
+
* Options: Enter/`c` = all CONFIGURED (host-tuned; the safe default), `a` = ALL
|
|
1347
|
+
* (server + configured), space/comma-separated NUMBERS = those, `s` = skip.
|
|
1348
|
+
* Returns the chosen entries, or undefined to skip (seed no models).
|
|
1349
|
+
*/
|
|
1350
|
+
function initModelPicker(
|
|
1351
|
+
candidates: readonly ModelCandidate[],
|
|
1352
|
+
): GeneratedModel[] | undefined {
|
|
1353
|
+
const configured = candidates.filter((c) => c.configured);
|
|
1354
|
+
process.stdout.write('\n Models served by this endpoint:\n');
|
|
1355
|
+
candidates.forEach((c, i) => {
|
|
1356
|
+
const tag = c.configured ? '[configured]' : '[server]';
|
|
1357
|
+
process.stdout.write(` [${i + 1}] ${c.id} ${tag}\n`);
|
|
1358
|
+
});
|
|
1359
|
+
process.stdout.write(
|
|
1360
|
+
' [configured] = from your pi config (well-tuned); [server] = the server\n' +
|
|
1361
|
+
' also reports it (a minimal entry is synthesized).\n',
|
|
1362
|
+
);
|
|
1363
|
+
const hasConfigured = configured.length > 0;
|
|
1364
|
+
const defaultHint = hasConfigured
|
|
1365
|
+
? 'Enter/c = all configured, a = all, numbers = pick, s = skip'
|
|
1366
|
+
: 'Enter/a = all, numbers = pick, s = skip';
|
|
1367
|
+
for (;;) {
|
|
1368
|
+
const ans = promptLine(` Import which? (${defaultHint}): `);
|
|
1369
|
+
const v = (ans ?? '').trim().toLowerCase();
|
|
1370
|
+
if (v === 's') return undefined;
|
|
1371
|
+
if (v === '' && hasConfigured) return configured.map((c) => c.entry);
|
|
1372
|
+
if (v === 'c') {
|
|
1373
|
+
if (!hasConfigured) {
|
|
1374
|
+
process.stdout.write(
|
|
1375
|
+
' No [configured] models; pick numbers or `a` for all.\n',
|
|
1376
|
+
);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
return configured.map((c) => c.entry);
|
|
1380
|
+
}
|
|
1381
|
+
if (v === 'a' || (v === '' && !hasConfigured)) {
|
|
1382
|
+
return candidates.map((c) => c.entry);
|
|
1383
|
+
}
|
|
1384
|
+
// Numbers (space/comma separated).
|
|
1385
|
+
const picks = v
|
|
1386
|
+
.split(/[\s,]+/)
|
|
1387
|
+
.filter((t) => t !== '')
|
|
1388
|
+
.map((t) => Number(t) - 1);
|
|
1389
|
+
if (
|
|
1390
|
+
picks.length > 0 &&
|
|
1391
|
+
picks.every((i) => i >= 0 && i < candidates.length)
|
|
1392
|
+
) {
|
|
1393
|
+
// De-dup, keep list order.
|
|
1394
|
+
const seen = new Set<number>();
|
|
1395
|
+
const out: GeneratedModel[] = [];
|
|
1396
|
+
for (const i of picks) {
|
|
1397
|
+
if (!seen.has(i)) {
|
|
1398
|
+
seen.add(i);
|
|
1399
|
+
out.push(candidates[i].entry);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return out;
|
|
1403
|
+
}
|
|
1404
|
+
process.stdout.write(
|
|
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}): `,
|
|
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`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* The IMAGE step: the pure menu (shipped Dockerfiles / existing ref / skip), then
|
|
1436
|
+
* the impure action for the pick (build via `podman build`, take a ref, or
|
|
1437
|
+
* skip). Returns the resolved image ref, undefined for skip, or ABORT.
|
|
1438
|
+
*/
|
|
1439
|
+
function initImageStep(): string | undefined | typeof ABORT {
|
|
1440
|
+
process.stdout.write(
|
|
1441
|
+
'\nStep 3/4 - default machine image (an image with `pi` on PATH)\n',
|
|
1442
|
+
);
|
|
1443
|
+
const menu = initImageMenu();
|
|
1444
|
+
menu.forEach((e, i) => {
|
|
1445
|
+
process.stdout.write(` [${i + 1}] ${e.label}\n`);
|
|
1446
|
+
});
|
|
1447
|
+
for (;;) {
|
|
1448
|
+
const ans = promptLine(' Choose [1-4]: ');
|
|
1449
|
+
if (ans === undefined) return ABORT;
|
|
1450
|
+
const idx = Number(ans.trim()) - 1;
|
|
1451
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= menu.length) {
|
|
1452
|
+
process.stdout.write(' Please pick a number 1-4.\n');
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const choice: InitImageChoice = menu[idx].choice;
|
|
1456
|
+
if (choice === 'skip') {
|
|
1457
|
+
process.stdout.write(
|
|
1458
|
+
' Skipping the image; pin it later with `anon-pi machine set-image`.\n',
|
|
1459
|
+
);
|
|
1460
|
+
return undefined;
|
|
1461
|
+
}
|
|
1462
|
+
if (choice === 'existing') {
|
|
1463
|
+
const ref = promptLine(' Image ref (a container with `pi` on PATH): ');
|
|
1464
|
+
if (ref === undefined || ref.trim() === '') {
|
|
1465
|
+
process.stdout.write(' No ref given; pick again.\n');
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
return ref.trim();
|
|
1469
|
+
}
|
|
1470
|
+
// basic | webveil: build the shipped Dockerfile via `podman build`.
|
|
1471
|
+
const dockerfile =
|
|
1472
|
+
choice === 'basic'
|
|
1473
|
+
? shippedDockerfilePath()
|
|
1474
|
+
: shippedWebveilDockerfilePath();
|
|
1475
|
+
if (dockerfile === undefined || !existsSync(dockerfile)) {
|
|
1476
|
+
process.stderr.write(
|
|
1477
|
+
` anon-pi: could not locate the shipped ${choice === 'basic' ? 'Dockerfile.pi' : 'examples/Dockerfile.pi-webveil'}. ` +
|
|
1478
|
+
'Pick an existing ref instead.\n',
|
|
1479
|
+
);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
const tag =
|
|
1483
|
+
choice === 'basic' ? 'anon-pi/pi:latest' : 'anon-pi/pi-webveil:latest';
|
|
1484
|
+
const built = buildImage(dockerfile, tag);
|
|
1485
|
+
if (!built) {
|
|
1486
|
+
process.stdout.write(' Build failed; pick another option.\n');
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
return tag;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
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
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Create or update the `default` machine: create it (dir + machine.json) if
|
|
1533
|
+
* absent, pinning the chosen image; if it already exists, re-pin the image only
|
|
1534
|
+
* when one was chosen (preserving any per-machine projects override), and NEVER
|
|
1535
|
+
* touch its home (init is non-destructive). A skipped image leaves an existing
|
|
1536
|
+
* machine's image as-is, or creates an imageless machine.
|
|
1537
|
+
*/
|
|
1538
|
+
function initWriteDefaultMachine(
|
|
1539
|
+
env: AnonPiEnv,
|
|
1540
|
+
image: string | undefined,
|
|
1541
|
+
): void {
|
|
1542
|
+
const name = DEFAULT_MACHINE;
|
|
1543
|
+
const dir = machineDir(env, name);
|
|
1544
|
+
const existed = existsSync(dir);
|
|
1545
|
+
mkdirSync(machineHomeDir(env, name), {recursive: true});
|
|
1546
|
+
if (!existed) {
|
|
1547
|
+
writeFileSync(machineJsonPath(env, name), serializeMachineJson({image}));
|
|
1548
|
+
process.stdout.write(
|
|
1549
|
+
`anon-pi: created machine "${name}"${image ? ` (image ${image})` : ' (imageless; pin it later)'} at ${dir}.\n`,
|
|
1550
|
+
);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
// Existing machine: re-pin only if a new image was chosen; keep its projects
|
|
1554
|
+
// override and its home untouched.
|
|
1555
|
+
const prev = readMachineJson(env, name);
|
|
1556
|
+
if (image !== undefined) {
|
|
1557
|
+
writeFileSync(
|
|
1558
|
+
machineJsonPath(env, name),
|
|
1559
|
+
serializeMachineJson({image, projects: prev.projects}),
|
|
1560
|
+
);
|
|
1561
|
+
process.stdout.write(
|
|
1562
|
+
`anon-pi: re-pinned machine "${name}" image to ${image} (home kept intact).\n`,
|
|
1563
|
+
);
|
|
1564
|
+
} else {
|
|
1565
|
+
process.stdout.write(
|
|
1566
|
+
`anon-pi: machine "${name}" already exists; kept its image + home.\n`,
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// --- init's thin I/O primitives (socket probes, process observe, podman build) --
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Probe a TCP port for openness AND a SOCKS5 handshake: connect, send the
|
|
1575
|
+
* no-auth method-selection greeting, read the reply, and interpret it with the
|
|
1576
|
+
* PURE interpretSocks5Handshake. Fully synchronous + best-effort with a short
|
|
1577
|
+
* timeout so init stays a simple linear prompt flow. On any connect failure the
|
|
1578
|
+
* port is `open: false` with no handshake.
|
|
1579
|
+
*/
|
|
1580
|
+
function probeSocks5(
|
|
1581
|
+
host: string,
|
|
1582
|
+
port: number,
|
|
1583
|
+
): {open: boolean; handshake?: SocksHandshake} {
|
|
1584
|
+
// A synchronous SOCKS5 probe: node's net is async, so we drive a tiny state
|
|
1585
|
+
// machine over a blocking loop using a short deadline. To keep it simple and
|
|
1586
|
+
// dependency-free we use a child `bash`+`/dev/tcp`-style probe is unavailable
|
|
1587
|
+
// portably, so instead we do a promise-free spin with a hard cap via a
|
|
1588
|
+
// separate helper that returns synchronously.
|
|
1589
|
+
const reply = socks5Handshake(host, port, SOCKS5_METHOD_SELECTOR, 600);
|
|
1590
|
+
if (reply === undefined) return {open: false};
|
|
1591
|
+
return {open: true, handshake: interpretSocks5Handshake(reply)};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Best-effort synchronous SOCKS5 handshake: open a TCP connection, write the
|
|
1596
|
+
* greeting, and collect the reply bytes, blocking up to `timeoutMs`. Returns the
|
|
1597
|
+
* reply bytes when the connection opened (possibly empty if the server sent
|
|
1598
|
+
* nothing), or undefined when the connection could not be opened at all (port
|
|
1599
|
+
* closed / refused). Implemented with a nested event loop drained via a shared
|
|
1600
|
+
* flag, so the caller stays a simple linear script.
|
|
1601
|
+
*/
|
|
1602
|
+
function socks5Handshake(
|
|
1603
|
+
host: string,
|
|
1604
|
+
port: number,
|
|
1605
|
+
greeting: readonly number[],
|
|
1606
|
+
timeoutMs: number,
|
|
1607
|
+
): number[] | undefined {
|
|
1608
|
+
// node has no synchronous socket API; run a tiny worker via execFileSync on
|
|
1609
|
+
// the same node binary so the probe is fully synchronous and portable. The
|
|
1610
|
+
// worker connects, sends the greeting, reads up to 2 bytes, and prints them as
|
|
1611
|
+
// JSON (or prints "null" when the connection was refused).
|
|
1612
|
+
const script =
|
|
1613
|
+
`const net=require('net');` +
|
|
1614
|
+
`const s=net.connect({host:${JSON.stringify(host)},port:${port}});` +
|
|
1615
|
+
`let done=false;const bytes=[];` +
|
|
1616
|
+
`const fin=(v)=>{if(done)return;done=true;try{s.destroy()}catch(e){}` +
|
|
1617
|
+
`process.stdout.write(JSON.stringify(v));process.exit(0)};` +
|
|
1618
|
+
`s.setTimeout(${timeoutMs});` +
|
|
1619
|
+
`s.on('connect',()=>{s.write(Buffer.from(${JSON.stringify([...greeting])}))});` +
|
|
1620
|
+
`s.on('data',(d)=>{for(const b of d)bytes.push(b);if(bytes.length>=2)fin(bytes)});` +
|
|
1621
|
+
`s.on('timeout',()=>fin(bytes));` +
|
|
1622
|
+
`s.on('error',()=>fin(null));` +
|
|
1623
|
+
`s.on('close',()=>fin(bytes));`;
|
|
1624
|
+
try {
|
|
1625
|
+
const out = execFileSync(process.execPath, ['-e', script], {
|
|
1626
|
+
encoding: 'utf8',
|
|
1627
|
+
timeout: timeoutMs + 1500,
|
|
1628
|
+
});
|
|
1629
|
+
const parsed = JSON.parse(out) as number[] | null;
|
|
1630
|
+
return parsed === null ? undefined : parsed;
|
|
1631
|
+
} catch {
|
|
1632
|
+
return undefined;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Best-effort synchronous TCP reachability probe (open a connection, succeed or
|
|
1638
|
+
* not) for the local-model endpoint. Reuses the socks5Handshake worker with an
|
|
1639
|
+
* empty greeting: a non-undefined return means the connection opened.
|
|
1640
|
+
*/
|
|
1641
|
+
function probeTcp(host: string, port: number): boolean {
|
|
1642
|
+
return socks5Handshake(host, port, [], 500) !== undefined;
|
|
1643
|
+
}
|
|
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
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Observe LOCAL process names (best-effort) so init can offer WEAK hints (a
|
|
1678
|
+
* running `tor` -> likely Tor). Returns the lowercased process names seen, or []
|
|
1679
|
+
* on any failure. This is a LOCAL observation only; it never claims the exit
|
|
1680
|
+
* provider.
|
|
1681
|
+
*/
|
|
1682
|
+
function observeRunningProcesses(): string[] {
|
|
1683
|
+
const res = spawnSync('ps', ['-eo', 'comm='], {encoding: 'utf8'});
|
|
1684
|
+
if (res.error || res.status !== 0 || !res.stdout) return [];
|
|
1685
|
+
return res.stdout
|
|
1686
|
+
.split('\n')
|
|
1687
|
+
.map((l) => l.trim().split('/').pop() ?? '')
|
|
1688
|
+
.filter((n) => n !== '')
|
|
1689
|
+
.map((n) => n.toLowerCase());
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* The weak process-hint text for the observed processes, if any maps (via the
|
|
1694
|
+
* PURE processHint). Returns the FIRST matching hint (tor before wireproxy), or
|
|
1695
|
+
* undefined. Never names the exit provider.
|
|
1696
|
+
*/
|
|
1697
|
+
function matchProcessHint(processes: readonly string[]): string | undefined {
|
|
1698
|
+
for (const p of processes) {
|
|
1699
|
+
const h = processHint(p);
|
|
1700
|
+
if (h) return h.hint;
|
|
1701
|
+
}
|
|
1702
|
+
return undefined;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Build a shipped Dockerfile into `tag` via `podman build`. Streams podman's
|
|
1707
|
+
* output (inherited stdio) so the user sees the build. Returns true on success.
|
|
1708
|
+
* The build CONTEXT is the Dockerfile's own directory (the shipped examples/
|
|
1709
|
+
* dir or the package root), which is where its COPY sources live.
|
|
1710
|
+
*/
|
|
1711
|
+
function buildImage(dockerfile: string, tag: string): boolean {
|
|
1712
|
+
const context = dirname(dockerfile);
|
|
1713
|
+
process.stdout.write(
|
|
1714
|
+
` Building ${tag} from ${dockerfile} (podman build)...\n`,
|
|
1715
|
+
);
|
|
1716
|
+
const res = spawnSync(
|
|
1717
|
+
'podman',
|
|
1718
|
+
['build', '-t', tag, '-f', dockerfile, context],
|
|
1719
|
+
{stdio: 'inherit'},
|
|
1720
|
+
);
|
|
1721
|
+
if (res.error) {
|
|
1722
|
+
process.stderr.write(
|
|
1723
|
+
` anon-pi: failed to run podman: ${res.error.message}. Is podman installed?\n`,
|
|
1724
|
+
);
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
return res.status === 0;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** List machine names (readdir of machines/), or [] if the dir is absent. */
|
|
1731
|
+
function listMachineNames(env: AnonPiEnv): string[] {
|
|
1732
|
+
const root = join(resolveAnonPiHome(env), 'machines');
|
|
1733
|
+
if (!existsSync(root)) return [];
|
|
1734
|
+
return readdirSync(root, {withFileTypes: true})
|
|
1735
|
+
.filter((d) => d.isDirectory())
|
|
1736
|
+
.map((d) => d.name);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Read one line from stdin synchronously for a confirm/value prompt, writing the
|
|
1741
|
+
* prompt to stderr first. Returns undefined on EOF/error. Only called on a TTY
|
|
1742
|
+
* (the verbs enforce the non-TTY discipline before prompting), so a blocking
|
|
1743
|
+
* byte-at-a-time read from fd 0 is fine: the user types a line and hits enter.
|
|
1744
|
+
*/
|
|
1745
|
+
function promptLine(prompt: string): string | undefined {
|
|
1746
|
+
process.stderr.write(prompt);
|
|
1747
|
+
const byte = Buffer.alloc(1);
|
|
1748
|
+
let line = '';
|
|
1749
|
+
for (;;) {
|
|
1750
|
+
let n: number;
|
|
1751
|
+
try {
|
|
1752
|
+
n = readSync(0, byte, 0, 1, null);
|
|
1753
|
+
} catch (e) {
|
|
1754
|
+
// EAGAIN on a non-blocking TTY: retry; anything else ends the read.
|
|
1755
|
+
if ((e as NodeJS.ErrnoException).code === 'EAGAIN') continue;
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
if (n === 0) break; // EOF
|
|
1759
|
+
const ch = byte.toString('utf8', 0, 1);
|
|
1760
|
+
if (ch === '\n') return line;
|
|
1761
|
+
if (ch !== '\r') line += ch;
|
|
1762
|
+
}
|
|
1763
|
+
return line === '' ? undefined : line;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/** The `machine` subcommand help. */
|
|
1767
|
+
const MACHINE_HELP = `anon-pi machine - manage machines (an image + a persistent host home)
|
|
1768
|
+
|
|
1769
|
+
USAGE
|
|
1770
|
+
anon-pi machine create <name> [--image <ref>] create a machine, pin its image
|
|
1771
|
+
anon-pi machine list list machines and their images
|
|
1772
|
+
anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
|
|
1773
|
+
anon-pi machine rm <name> [--yes] delete the machine + its home
|
|
1774
|
+
|
|
1775
|
+
A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
|
|
1776
|
+
The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
|
|
1777
|
+
warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
|
|
1778
|
+
\`--yes\`, and aborts non-interactively without it.
|
|
1779
|
+
`;
|
|
1780
|
+
|
|
1781
|
+
// --- impure helpers ---------------------------------------------------------
|
|
1782
|
+
|
|
1783
|
+
/** Read + parse <anon-pi-home>/config.json (tolerant: absent/garbage => {}). */
|
|
1784
|
+
function readJsonConfig(env: AnonPiEnv): AnonPiConfig {
|
|
1785
|
+
const path = join(resolveAnonPiHome(env), 'config.json');
|
|
1786
|
+
return parseConfigJson(readJsonFile(path));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Read + parse a machine's machine.json (tolerant: absent/garbage => {}). */
|
|
1790
|
+
function readMachineJson(env: AnonPiEnv, name: string): MachineConfig {
|
|
1791
|
+
return parseMachineJson(readJsonFile(machineJsonPath(env, name)));
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/** Read + JSON.parse a file, returning undefined if absent or unparseable. */
|
|
1795
|
+
function readJsonFile(path: string): unknown {
|
|
1796
|
+
if (!existsSync(path)) return undefined;
|
|
1797
|
+
try {
|
|
1798
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1799
|
+
} catch {
|
|
1800
|
+
return undefined;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* True iff a machine home is FRESH (no seed marker): the seed will run. The
|
|
1806
|
+
* marker lives under the mounted home at `.pi/agent/<SEED_MARKER>` (the host
|
|
1807
|
+
* side of the container's /root/.pi/agent = CONTAINER_AGENT_DIR).
|
|
1808
|
+
*/
|
|
1809
|
+
function homeFresh(machineHome: string): boolean {
|
|
1810
|
+
const marker = join(machineHome, '.pi', 'agent', SEED_MARKER);
|
|
1811
|
+
return !existsSync(marker);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Query netcage for its KEPT managed containers, surfacing each one's stamped
|
|
1816
|
+
* anon-pi identity key so the pure run-vs-start decision can match it. Thin,
|
|
1817
|
+
* best-effort I/O: on any failure (netcage missing the query, no containers, a
|
|
1818
|
+
* parse error) it returns an EMPTY listing, so the decision falls back to a
|
|
1819
|
+
* fresh `run` (safe: it never wrongly resumes, it just creates a new container).
|
|
1820
|
+
*/
|
|
1821
|
+
function queryKeptContainers(): KeptContainer[] {
|
|
1822
|
+
// Ask netcage for its managed containers as JSON, reading back the anon-pi
|
|
1823
|
+
// key label. netcage is a podman drop-in, so `ps` accepts the same
|
|
1824
|
+
// label-filter + Go-template/JSON format flags.
|
|
1825
|
+
const res = spawnSync(
|
|
1826
|
+
'netcage',
|
|
1827
|
+
[
|
|
1828
|
+
'ps',
|
|
1829
|
+
'-a',
|
|
1830
|
+
'--filter',
|
|
1831
|
+
'label=netcage.managed',
|
|
1832
|
+
'--format',
|
|
1833
|
+
'{{.ID}}\t{{.Labels}}',
|
|
1834
|
+
],
|
|
1835
|
+
{encoding: 'utf8'},
|
|
1836
|
+
);
|
|
1837
|
+
if (res.error || res.status !== 0 || !res.stdout) return [];
|
|
1838
|
+
|
|
1839
|
+
const out: KeptContainer[] = [];
|
|
1840
|
+
for (const line of res.stdout.split('\n')) {
|
|
1841
|
+
const trimmed = line.trim();
|
|
1842
|
+
if (trimmed === '') continue;
|
|
1843
|
+
const tab = trimmed.indexOf('\t');
|
|
1844
|
+
if (tab < 0) continue;
|
|
1845
|
+
const ref = trimmed.slice(0, tab).trim();
|
|
1846
|
+
const labels = trimmed.slice(tab + 1);
|
|
1847
|
+
const key = extractKeyLabel(labels);
|
|
1848
|
+
if (ref !== '' && key !== undefined) out.push({key, ref});
|
|
1849
|
+
}
|
|
1850
|
+
return out;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Pull the anon-pi key out of a podman `{{.Labels}}` rendering (a
|
|
1855
|
+
* comma-separated `k=v` list). The key is stamped as `anon-pi.key=<opaque>`;
|
|
1856
|
+
* because keptContainerKey embeds newlines, the CLI base64-encodes it when
|
|
1857
|
+
* stamping (withKeyLabel) and decodes it here, so a `\n` never breaks the label.
|
|
1858
|
+
*/
|
|
1859
|
+
function extractKeyLabel(labels: string): string | undefined {
|
|
1860
|
+
for (const pair of labels.split(',')) {
|
|
1861
|
+
const eq = pair.indexOf('=');
|
|
1862
|
+
if (eq < 0) continue;
|
|
1863
|
+
const k = pair.slice(0, eq).trim();
|
|
1864
|
+
if (k !== ANON_PI_KEY_LABEL) continue;
|
|
1865
|
+
const v = pair.slice(eq + 1).trim();
|
|
1866
|
+
try {
|
|
1867
|
+
return Buffer.from(v, 'base64').toString('utf8');
|
|
1868
|
+
} catch {
|
|
1869
|
+
return undefined;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return undefined;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Insert the anon-pi identity label into a `netcage run` argv (right after
|
|
1877
|
+
* `run`), so a kept container can be found on re-entry. The key is base64'd
|
|
1878
|
+
* (keptContainerKey embeds newlines) to keep it a single safe label value. This
|
|
1879
|
+
* is ADDITIVE and touches NO egress flag (the RunPlan owns --proxy/--allow-direct).
|
|
1880
|
+
*/
|
|
1881
|
+
function withKeyLabel(netcageArgs: string[], key: string): string[] {
|
|
1882
|
+
const enc = Buffer.from(key, 'utf8').toString('base64');
|
|
1883
|
+
const out = netcageArgs.slice();
|
|
1884
|
+
// netcageArgs[0] is 'run'; splice the label right after it.
|
|
1885
|
+
out.splice(1, 0, '--label', `${ANON_PI_KEY_LABEL}=${enc}`);
|
|
1886
|
+
return out;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** Spawn netcage with inherited stdio; propagate its exit code. */
|
|
1890
|
+
function spawnNetcage(netcageArgs: string[]): number {
|
|
1891
|
+
const res = spawnSync('netcage', netcageArgs, {stdio: 'inherit'});
|
|
1892
|
+
if (res.error) {
|
|
1893
|
+
process.stderr.write(
|
|
1894
|
+
`anon-pi: failed to run netcage: ${res.error.message}\n`,
|
|
1895
|
+
);
|
|
1896
|
+
return 1;
|
|
1897
|
+
}
|
|
1898
|
+
return res.status ?? 1;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
225
1901
|
function hasNetcage(): boolean {
|
|
226
1902
|
const which = spawnSync(
|
|
227
1903
|
process.platform === 'win32' ? 'where' : 'command',
|
|
@@ -237,4 +1913,13 @@ function hasNetcage(): boolean {
|
|
|
237
1913
|
return !probe.error;
|
|
238
1914
|
}
|
|
239
1915
|
|
|
1916
|
+
/** Print an AnonPiError's message verbatim (exit 1) or rethrow anything else. */
|
|
1917
|
+
function reportAnonPiError(e: unknown): number {
|
|
1918
|
+
if (e instanceof AnonPiError) {
|
|
1919
|
+
process.stderr.write(e.message + '\n');
|
|
1920
|
+
return 1;
|
|
1921
|
+
}
|
|
1922
|
+
throw e;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
240
1925
|
process.exit(main(process.argv));
|