anon-pi 0.4.0 → 0.5.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 +199 -64
- package/dist/anon-pi.d.ts +849 -75
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +1222 -260
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +1243 -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 +1723 -312
- package/src/cli.ts +1470 -124
package/dist/anon-pi.js
CHANGED
|
@@ -1,34 +1,71 @@
|
|
|
1
1
|
// anon-pi: the PURE logic (no process spawning, no interactive I/O) so every
|
|
2
2
|
// decision is unit-testable. cli.ts wires this to the real filesystem + spawn.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// -
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
4
|
+
// The model (machines + projects; see CONTEXT.md + docs/adr/0001):
|
|
5
|
+
// - A MACHINE is an image + a persistent HOST home (`machines/<M>/home`),
|
|
6
|
+
// bind-mounted into the jail at /root. It holds shell config, pi config +
|
|
7
|
+
// extensions, and pi conversations (`~/.pi/agent/sessions/`). The container
|
|
8
|
+
// is disposable; ALL valuable state is in this host home.
|
|
9
|
+
// - A PROJECT is a folder under the PROJECTS ROOT, bind-mounted at /projects,
|
|
10
|
+
// so a project's cwd is /projects/<name>. pi keys a conversation by its
|
|
11
|
+
// launch cwd, so /projects/<name> is the conversation key (per-machine,
|
|
12
|
+
// since it lives in that machine's home).
|
|
13
|
+
// - TWO invariant container mounts, always: /root (the machine home) and
|
|
14
|
+
// /projects (the projects root). `--mount <parent>` adds EXACTLY one more
|
|
15
|
+
// mount at the DISTINCT /work and re-roots cwd there; nothing else changes,
|
|
16
|
+
// so we never remount a running container.
|
|
17
|
+
// - Throwaway (`--rm`) is the DEFAULT; `--keep` leaves the container kept so
|
|
18
|
+
// its filesystem survives (found + resumed by netcage's `netcage.managed`
|
|
19
|
+
// label via `netcage start`). The machine home persists either way.
|
|
20
|
+
// - Open exactly ONE direct hole (--allow-direct <llm>) so pi can reach a
|
|
21
|
+
// local model while ALL other egress stays forced through the socks5h proxy
|
|
22
|
+
// (fail-closed; the proxy is REQUIRED and never guessed).
|
|
23
|
+
// - Seed-if-fresh (marker-guarded, per MACHINE home): on a fresh home, promote
|
|
24
|
+
// the image's /root defaults + pi staging + the generated models.json into
|
|
25
|
+
// the home once, then stamp the marker and never clobber it again.
|
|
26
|
+
//
|
|
27
|
+
// This module holds every DECISION as a pure function (config load + precedence,
|
|
28
|
+
// machine/project resolvers, name validation, the RunPlan argv, the menu
|
|
29
|
+
// choice-list, project usage, the run-vs-start rule, models.json generation,
|
|
30
|
+
// init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
|
|
31
|
+
// the interactive TUI, the netcage query, the spawn).
|
|
20
32
|
import { existsSync } from 'node:fs';
|
|
21
33
|
import { homedir } from 'node:os';
|
|
22
|
-
import { dirname,
|
|
34
|
+
import { dirname, join, resolve } from 'node:path';
|
|
23
35
|
import { fileURLToPath } from 'node:url';
|
|
24
|
-
/**
|
|
25
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The jail cwd root for the projects-root launch: the projects root is mounted
|
|
38
|
+
* here and a project `<name>` is `/projects/<name>` (pi keys a conversation by
|
|
39
|
+
* its launch cwd, so `/projects/<name>` is the conversation key). This is the
|
|
40
|
+
* machines + projects mount (distinct from `--mount`'s /work).
|
|
41
|
+
*/
|
|
42
|
+
export const CONTAINER_PROJECTS_ROOT = '/projects';
|
|
43
|
+
/**
|
|
44
|
+
* The jail cwd root for a `--mount <parent>` launch: the HOST parent is mounted
|
|
45
|
+
* here (kept DISTINCT from /projects so the two roots never collide), and a
|
|
46
|
+
* project `<name>` is `/work/<name>`. See ADR-0001 (`--mount` keeps `/work`).
|
|
47
|
+
*/
|
|
48
|
+
export const CONTAINER_MOUNT_ROOT = '/work';
|
|
49
|
+
/**
|
|
50
|
+
* The jail cwd root for a machine (its persistent home, bind-mounted at /root).
|
|
51
|
+
* A machine root has no named subfolders: only the root token `.` (a scratch pi
|
|
52
|
+
* / shell at `~`) is valid. Written as `~` so it reads as "the machine home".
|
|
53
|
+
*/
|
|
54
|
+
export const CONTAINER_MACHINE_HOME = '~';
|
|
55
|
+
/**
|
|
56
|
+
* The REAL container path the machine home is bind-mounted at (the source is
|
|
57
|
+
* the host `machineHomeDir`). This is what a shell-at-`~` launch actually cwds
|
|
58
|
+
* into (`-w /root`), distinct from CONTAINER_MACHINE_HOME (`~`), which is the
|
|
59
|
+
* human-readable menu token. It is the parent of CONTAINER_AGENT_DIR
|
|
60
|
+
* (`/root/.pi/agent`); the seed-if-fresh promotes the image's `/root` defaults +
|
|
61
|
+
* pi staging into the mounted home here.
|
|
62
|
+
*/
|
|
63
|
+
export const CONTAINER_HOME_ROOT = '/root';
|
|
26
64
|
/**
|
|
27
65
|
* The container path pi uses as its config+state home. anon-pi mounts a
|
|
28
66
|
* PERSISTENT host dir here (Model B), so everything pi writes, sessions,
|
|
29
67
|
* history, settings (your model choice), `pi install`ed extensions, downloaded
|
|
30
|
-
* bin/fd, survives across launches. Statefulness is the default
|
|
31
|
-
* mounts a throwaway dir here instead.
|
|
68
|
+
* bin/fd, survives across launches. Statefulness is the default.
|
|
32
69
|
*/
|
|
33
70
|
export const CONTAINER_AGENT_DIR = '/root/.pi/agent';
|
|
34
71
|
/**
|
|
@@ -76,24 +113,741 @@ export const SEED_VERSION = '1';
|
|
|
76
113
|
/** A user-facing error whose message is meant to be printed verbatim (no stack). */
|
|
77
114
|
export class AnonPiError extends Error {
|
|
78
115
|
}
|
|
79
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* The verbatim guidance printed when no proxy is supplied. Kept as a single
|
|
118
|
+
* source so the fail-closed path (resolveProxy) emits byte-identical
|
|
119
|
+
* copy-pasteable guidance. The proxy is REQUIRED and never guessed: it is what
|
|
120
|
+
* anonymizes egress (fail-closed is the anonymity invariant).
|
|
121
|
+
*/
|
|
122
|
+
export const PROXY_REQUIRED_MESSAGE = 'anon-pi: set ANON_PI_PROXY to your socks5h proxy. anon-pi has no default:\n' +
|
|
123
|
+
'the proxy is what makes the session anonymous, so it is never guessed.\n' +
|
|
124
|
+
'\n' +
|
|
125
|
+
'Pick the one you run (copy-paste), then re-run anon-pi:\n' +
|
|
126
|
+
'\n' +
|
|
127
|
+
'# Tor (system tor / Tor Browser bundle default port)\n' +
|
|
128
|
+
'export ANON_PI_PROXY=socks5h://127.0.0.1:9050\n' +
|
|
129
|
+
'\n' +
|
|
130
|
+
'# wireproxy -> a WireGuard VPN (Mullvad, Proton, ...); use YOUR configured\n' +
|
|
131
|
+
'# [Socks5] BindAddress port (1080 in wireproxy examples):\n' +
|
|
132
|
+
'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
|
|
133
|
+
'\n' +
|
|
134
|
+
'# an SSH dynamic-forward (ssh -D 1080 host) or any other socks5h endpoint\n' +
|
|
135
|
+
'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
|
|
136
|
+
'\n' +
|
|
137
|
+
'Only socks5h:// is accepted (plain socks5:// resolves DNS locally and leaks).';
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the anon-pi home dir: the dedicated, browsable workspace folder
|
|
140
|
+
* (`~/.anon-pi/`, NOT under `~/.config`), holding config.json, machines/<M>/,
|
|
141
|
+
* and the default global projects root. Overridable via ANON_PI_HOME.
|
|
142
|
+
*/
|
|
80
143
|
export function resolveAnonPiHome(env) {
|
|
81
144
|
if (env.anonPiHome)
|
|
82
145
|
return resolve(env.anonPiHome);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
146
|
+
return join(env.home, '.anon-pi');
|
|
147
|
+
}
|
|
148
|
+
/** A machine's directory: <home>/machines/<name> (holds machine.json + home/). */
|
|
149
|
+
export function machineDir(env, name) {
|
|
150
|
+
return join(resolveAnonPiHome(env), 'machines', name);
|
|
151
|
+
}
|
|
152
|
+
/** A machine's persistent HOST home: <home>/machines/<name>/home (bind-mounted at /root). */
|
|
153
|
+
export function machineHomeDir(env, name) {
|
|
154
|
+
return join(machineDir(env, name), 'home');
|
|
155
|
+
}
|
|
156
|
+
/** A machine's machine.json path: <home>/machines/<name>/machine.json. */
|
|
157
|
+
export function machineJsonPath(env, name) {
|
|
158
|
+
return join(machineDir(env, name), 'machine.json');
|
|
159
|
+
}
|
|
160
|
+
/** The sessions dirname pi keeps its per-cwd conversation dirs under (in the agent dir). */
|
|
161
|
+
export const SESSIONS_DIRNAME = 'sessions';
|
|
162
|
+
/**
|
|
163
|
+
* A machine's HOST pi agent dir: the host side of the container's
|
|
164
|
+
* CONTAINER_AGENT_DIR (`/root/.pi/agent`, since the home is bind-mounted at
|
|
165
|
+
* /root). i.e. <machineHome>/.pi/agent. Where pi's config + sessions live.
|
|
166
|
+
*/
|
|
167
|
+
export function machineAgentDir(env, name) {
|
|
168
|
+
return join(machineHomeDir(env, name), '.pi', 'agent');
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A machine's HOST pi sessions dir: <machineAgentDir>/sessions. Each per-cwd
|
|
172
|
+
* conversation is a slug-named subdir here (projectSessionSlug for a project).
|
|
173
|
+
*/
|
|
174
|
+
export function machineSessionsDir(env, name) {
|
|
175
|
+
return join(machineAgentDir(env, name), SESSIONS_DIRNAME);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The HOST session dir a given project's conversation occupies in a given
|
|
179
|
+
* machine's home: <machineSessionsDir>/<projectSessionSlug>. Because the slug is
|
|
180
|
+
* MACHINE-INVARIANT (pi keys by the `/projects/<name>` cwd, identical on every
|
|
181
|
+
* machine), the SAME shared project has this dir in each machine that used it.
|
|
182
|
+
* Validates the project name (rejecting traversal) via projectSessionSlug.
|
|
183
|
+
*/
|
|
184
|
+
export function machineProjectSessionDir(env, machine, project) {
|
|
185
|
+
return join(machineSessionsDir(env, machine), projectSessionSlug(project));
|
|
186
|
+
}
|
|
187
|
+
/** The built-in default global projects root: <home>/projects. */
|
|
188
|
+
export function builtinProjectsRoot(env) {
|
|
189
|
+
return join(resolveAnonPiHome(env), 'projects');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* PURE: resolve the affected path for `--delete-home <machine>`: the machine's
|
|
193
|
+
* HOME dir only (config + convos + shell env), NOT the whole machine dir, so the
|
|
194
|
+
* image pin (machine.json) survives a re-seed. Validates the machine name
|
|
195
|
+
* (rejecting traversal) via machineHomeDir's join being under a validated name;
|
|
196
|
+
* we validate explicitly here so the plan itself is a safe single segment.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveDeleteHome(env, machine) {
|
|
199
|
+
validateName(machine, 'machine');
|
|
200
|
+
return { machine, home: machineHomeDir(env, machine) };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* PURE: resolve the affected paths for `--delete-project <project>`: the
|
|
204
|
+
* project's files under the RESOLVED projects root, plus that project's session
|
|
205
|
+
* dir in each SUPPLIED machine home (the machine-invariant slug). Validates the
|
|
206
|
+
* project name (rejecting traversal) so both the folder join and every session
|
|
207
|
+
* join stay inside their roots. The homes are NOT targeted (only the per-project
|
|
208
|
+
* slug dir inside each), matching the prd behaviour table.
|
|
209
|
+
*/
|
|
210
|
+
export function resolveDeleteProject(args) {
|
|
211
|
+
const { env, project, projectsRoot, machines } = args;
|
|
212
|
+
validateName(project, 'project');
|
|
213
|
+
return {
|
|
214
|
+
project,
|
|
215
|
+
folder: projectHostDir(projectsRoot, project),
|
|
216
|
+
sessions: machines.map((m) => machineProjectSessionDir(env, m, project)),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// --- Name validation + the "." root token ------------------------------------
|
|
220
|
+
/**
|
|
221
|
+
* The project token meaning "the root itself": cwd `/projects` (projects root),
|
|
222
|
+
* `/work` (`--mount`), or `~` (a machine home). It is NOT a valid machine or
|
|
223
|
+
* project name (validateName rejects it) so a folder can never shadow it.
|
|
224
|
+
*/
|
|
225
|
+
export const ROOT_TOKEN = '.';
|
|
226
|
+
/**
|
|
227
|
+
* Reserved names that a machine/project may NOT take (case-sensitive). Kept
|
|
228
|
+
* DELIBERATELY minimal: only the two structural path tokens. `.` is the root
|
|
229
|
+
* token (see ROOT_TOKEN); `..` is parent-traversal. Both are also rejected by
|
|
230
|
+
* the leading-dot / `..` structural checks below, but are listed here so the
|
|
231
|
+
* reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
|
|
232
|
+
* CONTAINER path, not a name in this namespace, so it needs no reservation.
|
|
233
|
+
*/
|
|
234
|
+
export const RESERVED_NAMES = ['.', '..'];
|
|
235
|
+
/**
|
|
236
|
+
* PURE: validate a machine or project name as a safe single path segment, and
|
|
237
|
+
* return it unchanged on success. Rejects (with AnonPiError):
|
|
238
|
+
* - empty
|
|
239
|
+
* - a path separator `/` or `\`, or a colon `:`
|
|
240
|
+
* - the traversal token `..` (and any leading dot, incl. `.`)
|
|
241
|
+
* - any whitespace
|
|
242
|
+
* - a reserved name (RESERVED_NAMES)
|
|
243
|
+
* A valid name is thus a single folder segment safe to join under the projects
|
|
244
|
+
* root or the machines dir with no traversal or drive/scheme surprises.
|
|
245
|
+
*/
|
|
246
|
+
export function validateName(name, kind) {
|
|
247
|
+
const bad = (why) => {
|
|
248
|
+
throw new AnonPiError(`anon-pi: invalid ${kind} name ${JSON.stringify(name)}: ${why}. ` +
|
|
249
|
+
`A ${kind} name must be a single folder segment (no / \\ : whitespace, ` +
|
|
250
|
+
`no leading dot, not "..").`);
|
|
251
|
+
};
|
|
252
|
+
if (name === '')
|
|
253
|
+
return bad('it is empty');
|
|
254
|
+
if (/[/\\:]/.test(name))
|
|
255
|
+
return bad('it contains / \\ or :');
|
|
256
|
+
if (/\s/.test(name))
|
|
257
|
+
return bad('it contains whitespace');
|
|
258
|
+
if (name.startsWith('.'))
|
|
259
|
+
return bad('it starts with a dot');
|
|
260
|
+
if (name === '..')
|
|
261
|
+
return bad('it is the parent-traversal token');
|
|
262
|
+
if (RESERVED_NAMES.includes(name))
|
|
263
|
+
return bad('it is a reserved name');
|
|
264
|
+
return name;
|
|
87
265
|
}
|
|
88
266
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
267
|
+
* PURE: map a validated project `<name>` to its host folder under the resolved
|
|
268
|
+
* projects root (the parent from resolveProjectsRoot / a `--mount` parent).
|
|
269
|
+
* Validates the name (rejecting traversal) so the join stays inside the root.
|
|
92
270
|
*/
|
|
93
|
-
export function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
271
|
+
export function projectHostDir(projectsRoot, name) {
|
|
272
|
+
return join(projectsRoot, validateName(name, 'project'));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* PURE: the jail cwd for a validated project `<name>`: `/projects/<name>`. This
|
|
276
|
+
* is pi's conversation key (pi keys a session by its launch cwd). Validates the
|
|
277
|
+
* name. For the `--mount` root use resolveCwd('mount', name) (=> /work/<name>).
|
|
278
|
+
*/
|
|
279
|
+
export function projectContainerCwd(name) {
|
|
280
|
+
return `${CONTAINER_PROJECTS_ROOT}/${validateName(name, 'project')}`;
|
|
281
|
+
}
|
|
282
|
+
/** True iff `token` is exactly the root token `.` ("the root itself"). */
|
|
283
|
+
export function isRootToken(token) {
|
|
284
|
+
return token === ROOT_TOKEN;
|
|
285
|
+
}
|
|
286
|
+
/** PURE: the jail cwd of a root itself: /projects, /work (mount), or ~ (machine). */
|
|
287
|
+
export function rootCwd(kind) {
|
|
288
|
+
switch (kind) {
|
|
289
|
+
case 'projects':
|
|
290
|
+
return CONTAINER_PROJECTS_ROOT;
|
|
291
|
+
case 'mount':
|
|
292
|
+
return CONTAINER_MOUNT_ROOT;
|
|
293
|
+
case 'machine':
|
|
294
|
+
return CONTAINER_MACHINE_HOME;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* PURE: resolve a launch's jail cwd UNIFORMLY from a `token` and its root kind.
|
|
299
|
+
* The root token `.` means "the root itself" (rootCwd) in every context; any
|
|
300
|
+
* other token is a project name resolved to `<root>/<name>` (validated). A
|
|
301
|
+
* machine root has no named subfolders (projects live at /projects or /work,
|
|
302
|
+
* never under the machine home), so a non-`.` token for a machine is rejected.
|
|
303
|
+
* This is the one seam so `anon-pi --mount <p> .` and a menu "here" entry agree.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveCwd(kind, token) {
|
|
306
|
+
if (isRootToken(token))
|
|
307
|
+
return rootCwd(kind);
|
|
308
|
+
if (kind === 'machine') {
|
|
309
|
+
throw new AnonPiError(`anon-pi: a machine root takes only "${ROOT_TOKEN}" (the machine home ${CONTAINER_MACHINE_HOME}), ` +
|
|
310
|
+
`not a named project ${JSON.stringify(token)}. Projects live under /projects or /work.`);
|
|
311
|
+
}
|
|
312
|
+
return `${rootCwd(kind)}/${validateName(token, 'project')}`;
|
|
313
|
+
}
|
|
314
|
+
/** Pick a string field from a parsed-JSON object, or undefined if absent/non-string. */
|
|
315
|
+
function strField(o, key) {
|
|
316
|
+
if (!o || typeof o !== 'object')
|
|
317
|
+
return undefined;
|
|
318
|
+
const v = o[key];
|
|
319
|
+
return typeof v === 'string' ? v : undefined;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* PURE: parse an already-JSON-decoded config.json value into an AnonPiConfig,
|
|
323
|
+
* keeping only the known string fields (defensive against a hand-edited file).
|
|
324
|
+
* Tolerates undefined/null/partial input (an absent config is `{}`).
|
|
325
|
+
*/
|
|
326
|
+
export function parseConfigJson(raw) {
|
|
327
|
+
const out = {};
|
|
328
|
+
const proxy = strField(raw, 'proxy');
|
|
329
|
+
if (proxy !== undefined)
|
|
330
|
+
out.proxy = proxy;
|
|
331
|
+
const llm = strField(raw, 'llm');
|
|
332
|
+
if (llm !== undefined)
|
|
333
|
+
out.llm = llm;
|
|
334
|
+
const defaultMachine = strField(raw, 'defaultMachine');
|
|
335
|
+
if (defaultMachine !== undefined)
|
|
336
|
+
out.defaultMachine = defaultMachine;
|
|
337
|
+
const projects = strField(raw, 'projects');
|
|
338
|
+
if (projects !== undefined)
|
|
339
|
+
out.projects = projects;
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* PURE: parse an already-JSON-decoded machine.json value into a MachineConfig.
|
|
344
|
+
* Tolerates undefined/null/partial input (an absent machine.json is `{}`).
|
|
345
|
+
*/
|
|
346
|
+
export function parseMachineJson(raw) {
|
|
347
|
+
const out = {};
|
|
348
|
+
const image = strField(raw, 'image');
|
|
349
|
+
if (image !== undefined)
|
|
350
|
+
out.image = image;
|
|
351
|
+
const projects = strField(raw, 'projects');
|
|
352
|
+
if (projects !== undefined)
|
|
353
|
+
out.projects = projects;
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
/** A non-empty (after-trim) string, or undefined. */
|
|
357
|
+
function nonEmpty(v) {
|
|
358
|
+
return v && v.trim() !== '' ? v.trim() : undefined;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* PURE: resolve the projects root (the host dir mounted at /projects) with the
|
|
362
|
+
* decided precedence, highest first:
|
|
363
|
+
* --mount (CLI) > env ANON_PI_PROJECTS > machine.json.projects >
|
|
364
|
+
* config.json.projects > built-in <home>/projects
|
|
365
|
+
* This task delivers the config/env/machine layers; `mountParent` is the
|
|
366
|
+
* documented top slot the later --mount CLI task threads in (pass the resolved
|
|
367
|
+
* host parent). A relative override is resolved to an absolute path.
|
|
368
|
+
*/
|
|
369
|
+
export function resolveProjectsRoot(args) {
|
|
370
|
+
const { env, config, machine, mountParent } = args;
|
|
371
|
+
const pick = nonEmpty(mountParent) ??
|
|
372
|
+
nonEmpty(env.projects) ??
|
|
373
|
+
nonEmpty(machine?.projects) ??
|
|
374
|
+
nonEmpty(config?.projects);
|
|
375
|
+
if (pick !== undefined)
|
|
376
|
+
return resolve(pick);
|
|
377
|
+
return builtinProjectsRoot(env);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* PURE: resolve the proxy with env-over-config precedence, REQUIRED /
|
|
381
|
+
* fail-closed. Throws AnonPiError with the verbatim PROXY_REQUIRED_MESSAGE when
|
|
382
|
+
* neither env nor config supplies a non-empty proxy (never a guessed default:
|
|
383
|
+
* fail-closed is the anonymity invariant).
|
|
384
|
+
*/
|
|
385
|
+
export function resolveProxy(args) {
|
|
386
|
+
const pick = nonEmpty(args.env.proxy) ?? nonEmpty(args.config?.proxy);
|
|
387
|
+
if (pick === undefined)
|
|
388
|
+
throw new AnonPiError(PROXY_REQUIRED_MESSAGE);
|
|
389
|
+
return pick;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* PURE: resolve the local-model direct target with env-over-config precedence.
|
|
393
|
+
* Unlike the proxy this is NOT fail-closed here (a launch with no local model
|
|
394
|
+
* is a later decision); returns undefined when neither supplies one.
|
|
395
|
+
*/
|
|
396
|
+
export function resolveLlm(args) {
|
|
397
|
+
return nonEmpty(args.env.llmDirect) ?? nonEmpty(args.config?.llm);
|
|
398
|
+
}
|
|
399
|
+
// --- Grammar A: the pure argv -> ParsedLaunch parser -------------------------
|
|
400
|
+
//
|
|
401
|
+
// A bare positional is a PROJECT; `-m` picks the machine. The CLI (cli.ts)
|
|
402
|
+
// combines the ParsedLaunch with config/machine reads (proxy, llm, image, home,
|
|
403
|
+
// projects root) into a LaunchIntent and runs resolveRunPlan. Kept PURE (argv
|
|
404
|
+
// in -> struct out, or AnonPiError) so parsing + the reserved-name guard are
|
|
405
|
+
// unit-testable; the CLI stays thin I/O.
|
|
406
|
+
/** The machine bare `anon-pi` launches when no `-m` and no config default. */
|
|
407
|
+
export const DEFAULT_MACHINE = 'default';
|
|
408
|
+
/**
|
|
409
|
+
* PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
|
|
410
|
+
* (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
|
|
411
|
+
* project positional; the FIRST bare positional is the project (`.` allowed as
|
|
412
|
+
* the root token). In pi mode every token AFTER the project is forwarded to pi
|
|
413
|
+
* verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
|
|
414
|
+
* the project. In shell/menu mode a stray extra positional is an error (bash has
|
|
415
|
+
* no forwarded-args grammar; the menu takes no project).
|
|
416
|
+
*
|
|
417
|
+
* Validates the project name and the `-m` machine name via validateName (the
|
|
418
|
+
* reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
|
|
419
|
+
* distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
|
|
420
|
+
* it is NOT name-validated here. Throws AnonPiError for an unknown option, a
|
|
421
|
+
* missing `-m`/`--mount` argument, a contradictory `--keep --rm`, or a bad name.
|
|
422
|
+
*/
|
|
423
|
+
export function parseLaunchArgs(args) {
|
|
424
|
+
let machine = DEFAULT_MACHINE;
|
|
425
|
+
let machineSet = false;
|
|
426
|
+
let shell = false;
|
|
427
|
+
let mountParent;
|
|
428
|
+
let keepSeen = false;
|
|
429
|
+
let rmSeen = false;
|
|
430
|
+
let project;
|
|
431
|
+
let piArgs;
|
|
432
|
+
const fail = (msg) => {
|
|
433
|
+
throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi --help\`.`);
|
|
434
|
+
};
|
|
435
|
+
let i = 0;
|
|
436
|
+
for (; i < args.length; i++) {
|
|
437
|
+
const a = args[i];
|
|
438
|
+
if (a === '-m' || a === '--machine') {
|
|
439
|
+
const v = args[++i];
|
|
440
|
+
if (v === undefined)
|
|
441
|
+
fail(`${a} needs a machine name`);
|
|
442
|
+
machine = validateName(v, 'machine');
|
|
443
|
+
machineSet = true;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (a === '--shell') {
|
|
447
|
+
shell = true;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (a === '--mount') {
|
|
451
|
+
const v = args[++i];
|
|
452
|
+
if (v === undefined)
|
|
453
|
+
fail('--mount needs a HOST parent path');
|
|
454
|
+
mountParent = v;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (a === '--keep') {
|
|
458
|
+
keepSeen = true;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (a === '--rm') {
|
|
462
|
+
rmSeen = true;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (a === '.') {
|
|
466
|
+
// the root token is a valid project positional (not a name).
|
|
467
|
+
project = ROOT_TOKEN;
|
|
468
|
+
i++;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
if (a.startsWith('-')) {
|
|
472
|
+
fail(`unknown option: ${a}`);
|
|
473
|
+
}
|
|
474
|
+
// the first bare positional is the project.
|
|
475
|
+
project = validateName(a, 'project');
|
|
476
|
+
i++;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
if (keepSeen && rmSeen) {
|
|
480
|
+
fail('--keep and --rm are contradictory (pick one; --rm is the default)');
|
|
481
|
+
}
|
|
482
|
+
// tokens remaining after the project.
|
|
483
|
+
const rest = args.slice(i);
|
|
484
|
+
if (shell) {
|
|
485
|
+
if (rest.length > 0) {
|
|
486
|
+
fail(`--shell takes at most one project, got extra: ${rest.join(' ')} ` +
|
|
487
|
+
'(a shell forwards no args; run pi from inside it instead)');
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
mode: 'shell',
|
|
491
|
+
machine,
|
|
492
|
+
machineExplicit: machineSet,
|
|
493
|
+
project,
|
|
494
|
+
mountParent,
|
|
495
|
+
keep: keepSeen,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (project === undefined) {
|
|
499
|
+
// no project + no --shell: the menu (bare, or -m/--mount with no project).
|
|
500
|
+
if (rest.length > 0)
|
|
501
|
+
fail(`unexpected argument: ${rest[0]}`);
|
|
502
|
+
return {
|
|
503
|
+
mode: 'menu',
|
|
504
|
+
machine,
|
|
505
|
+
machineExplicit: machineSet,
|
|
506
|
+
project: undefined,
|
|
507
|
+
mountParent,
|
|
508
|
+
keep: keepSeen,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// pi mode: every token after the project is forwarded to pi verbatim.
|
|
512
|
+
if (rest.length > 0)
|
|
513
|
+
piArgs = rest.slice();
|
|
514
|
+
return {
|
|
515
|
+
mode: 'pi',
|
|
516
|
+
machine,
|
|
517
|
+
machineExplicit: machineSet,
|
|
518
|
+
project,
|
|
519
|
+
mountParent,
|
|
520
|
+
keep: keepSeen,
|
|
521
|
+
piArgs,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* PURE: resolve a LaunchIntent into a LaunchPlan, composing the netcage argv for
|
|
526
|
+
* every mode. Never spawns, never touches the filesystem: `homeFresh` reports
|
|
527
|
+
* whether the machine home has been seeded (so `fresh` is known) and is the only
|
|
528
|
+
* capability injected.
|
|
529
|
+
*
|
|
530
|
+
* Invariants held on EVERY composed argv:
|
|
531
|
+
* - the two mounts <home>:/root and <projectsRoot>:/projects, always;
|
|
532
|
+
* - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
|
|
533
|
+
* - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
|
|
534
|
+
* - --rm by default, omitted only under --keep.
|
|
535
|
+
*
|
|
536
|
+
* Throws AnonPiError (a plan is NEVER produced) when the image, the machine
|
|
537
|
+
* home, the proxy, or the direct-hole llm is missing.
|
|
538
|
+
*/
|
|
539
|
+
export function resolveRunPlan(intent, homeFresh) {
|
|
540
|
+
const { machine, mode, projectsRoot, project, mountParent } = intent;
|
|
541
|
+
// Forced egress FIRST, on every path incl. the menu marker: a plan can never
|
|
542
|
+
// be produced without the proxy + the one direct hole (fail-closed).
|
|
543
|
+
const proxy = nonEmpty(intent.proxy);
|
|
544
|
+
if (proxy === undefined)
|
|
545
|
+
throw new AnonPiError(PROXY_REQUIRED_MESSAGE);
|
|
546
|
+
const llm = nonEmpty(intent.llmDirect);
|
|
547
|
+
if (llm === undefined) {
|
|
548
|
+
throw new AnonPiError('anon-pi: no local-model direct target: set ANON_PI_LLM (or config.llm) to the ' +
|
|
549
|
+
'RFC1918/link-local IP[:port] of the local model. It is the ONE direct hole; ' +
|
|
550
|
+
'all other egress stays forced through the proxy.');
|
|
551
|
+
}
|
|
552
|
+
if (nonEmpty(machine.image) === undefined) {
|
|
553
|
+
throw new AnonPiError(`anon-pi: machine ${JSON.stringify(machine.name)} has no image. Set one with ` +
|
|
554
|
+
'`anon-pi machine set-image` or in its machine.json.');
|
|
555
|
+
}
|
|
556
|
+
if (nonEmpty(machine.home) === undefined) {
|
|
557
|
+
throw new AnonPiError(`anon-pi: machine ${JSON.stringify(machine.name)} has no resolved home dir.`);
|
|
558
|
+
}
|
|
559
|
+
// Bare launch: defer to the host-side menu; compose no argv yet (but the
|
|
560
|
+
// forced-egress checks above have already run, so a menu is never a way to
|
|
561
|
+
// slip past the proxy requirement).
|
|
562
|
+
if (mode === 'menu') {
|
|
563
|
+
return { kind: 'menu', machine };
|
|
564
|
+
}
|
|
565
|
+
const mounted = nonEmpty(mountParent) !== undefined;
|
|
566
|
+
// Which root the cwd resolves under: /work when --mount, else /projects.
|
|
567
|
+
const rootKind = mounted ? 'mount' : 'projects';
|
|
568
|
+
// cwd: shell with no project sits at the machine home (/root); otherwise the
|
|
569
|
+
// project token (a name or `.`) resolves under the active root uniformly.
|
|
570
|
+
const cwd = project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
|
|
571
|
+
const fresh = homeFresh(machine.home);
|
|
572
|
+
const seedVersion = intent.seedVersion ?? SEED_VERSION;
|
|
573
|
+
const directTarget = hostPortKey(llm);
|
|
574
|
+
const modelsSeed = nonEmpty(intent.modelsSeed);
|
|
575
|
+
// Interactive modes (interactive pi, shell) need a TTY; a HEADLESS pi run
|
|
576
|
+
// (`<project> <pi-args…>`) must work WITHOUT one, so `-it` is omitted there
|
|
577
|
+
// (podman fails to allocate a TTY on a non-tty stdin). The CLI's broader
|
|
578
|
+
// no-TTY discipline (erroring when an interactive mode has no TTY) is a later
|
|
579
|
+
// task; here the argv simply omits -it for the one headless shape.
|
|
580
|
+
const headless = mode === 'pi' && !!intent.piArgs && intent.piArgs.length > 0;
|
|
581
|
+
const netcageArgs = ['run'];
|
|
582
|
+
// --rm by DEFAULT (throwaway); --keep leaves the container kept.
|
|
583
|
+
if (intent.keep !== true)
|
|
584
|
+
netcageArgs.push('--rm');
|
|
585
|
+
// Forced egress: the proxy + the ONE direct hole. Never omitted.
|
|
586
|
+
netcageArgs.push('--proxy', proxy, '--allow-direct', directTarget);
|
|
587
|
+
if (!headless)
|
|
588
|
+
netcageArgs.push('-it');
|
|
589
|
+
// The TWO invariant mounts, ALWAYS.
|
|
590
|
+
netcageArgs.push('-v', `${machine.home}:${CONTAINER_HOME_ROOT}`);
|
|
591
|
+
netcageArgs.push('-v', `${projectsRoot}:${CONTAINER_PROJECTS_ROOT}`);
|
|
592
|
+
// --mount adds EXACTLY the one parent mount at /work (distinct from /projects,
|
|
593
|
+
// so the two roots never collide). Nothing else changes.
|
|
594
|
+
if (mounted) {
|
|
595
|
+
netcageArgs.push('-v', `${mountParent}:${CONTAINER_MOUNT_ROOT}`);
|
|
596
|
+
}
|
|
597
|
+
// The generated models.json read-only for the first-launch seed, when present.
|
|
598
|
+
if (modelsSeed !== undefined) {
|
|
599
|
+
netcageArgs.push('-v', `${modelsSeed}:${CONTAINER_MODELS_SEED}:ro`);
|
|
600
|
+
}
|
|
601
|
+
// The jail cwd.
|
|
602
|
+
netcageArgs.push('-w', cwd);
|
|
603
|
+
// The image, then the command: a marker-guarded seed-if-fresh then the tool.
|
|
604
|
+
// pi (with forwarded args) for pi mode; bash for a shell. The seed shape is
|
|
605
|
+
// containerRunCmd re-pointed at the machine home (/root), so a fresh machine
|
|
606
|
+
// home gets the image's staged defaults + models.json once.
|
|
607
|
+
netcageArgs.push(machine.image);
|
|
608
|
+
if (mode === 'shell') {
|
|
609
|
+
// A jailed bash: seed-if-fresh (so a fresh home still gets .bashrc etc.),
|
|
610
|
+
// then exec bash.
|
|
611
|
+
netcageArgs.push('sh', '-c', containerSeedThen(seedVersion, 'exec bash'));
|
|
612
|
+
}
|
|
613
|
+
else if (intent.piArgs && intent.piArgs.length > 0) {
|
|
614
|
+
// Forward args: seed-if-fresh, then exec pi with the args. The args are the
|
|
615
|
+
// shell's positional argv ($@) so they are forwarded verbatim (no re-quote).
|
|
616
|
+
netcageArgs.push('sh', '-c', containerSeedThen(seedVersion, 'exec pi "$@"'), 'pi', ...intent.piArgs);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
// Interactive pi: seed-if-fresh, then exec pi.
|
|
620
|
+
netcageArgs.push('sh', '-c', containerSeedThen(seedVersion, 'exec pi'));
|
|
621
|
+
}
|
|
622
|
+
return { kind: 'launch', machine, cwd, fresh, netcageArgs };
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* The marker-guarded seed-if-fresh prefix (reused across pi/bash), followed by
|
|
626
|
+
* the given exec. On a FRESH machine home (no `.anon-pi-seed` marker under
|
|
627
|
+
* /root/.pi/agent) it promotes the image's staged pi defaults
|
|
628
|
+
* (/opt/anon-pi-seed/agent) + the mounted models.json into the home and stamps
|
|
629
|
+
* the marker; on a seeded home it does nothing. Then it runs `exec`. This is
|
|
630
|
+
* `containerRunCmd`'s shape (already /root-pointed), generalised over the tool.
|
|
631
|
+
*/
|
|
632
|
+
function containerSeedThen(seedVersion, exec) {
|
|
633
|
+
const agent = CONTAINER_AGENT_DIR;
|
|
634
|
+
const marker = `${agent}/${SEED_MARKER}`;
|
|
635
|
+
return (`mkdir -p "${agent}" && ` +
|
|
636
|
+
`if [ ! -f "${marker}" ]; then ` +
|
|
637
|
+
`{ [ -d "${CONTAINER_STAGE_DIR}" ] && cp -a "${CONTAINER_STAGE_DIR}/." "${agent}/" || true; } && ` +
|
|
638
|
+
`{ [ -f "${CONTAINER_MODELS_SEED}" ] && cp "${CONTAINER_MODELS_SEED}" "${agent}/${MODELS_FILE}" || true; } && ` +
|
|
639
|
+
`printf '%s\\n' "${seedVersion}" > "${marker}"; ` +
|
|
640
|
+
`fi && ` +
|
|
641
|
+
`${exec}`);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* PURE: the launch-identity match key for a kept container, derived ENTIRELY
|
|
645
|
+
* from the (machine, projects-root, project) identity (ADR-0002). It is what
|
|
646
|
+
* decides whether an existing kept `netcage.managed` container IS the one a
|
|
647
|
+
* `--keep` launch should resume.
|
|
648
|
+
*
|
|
649
|
+
* The fields, and why each is load-bearing:
|
|
650
|
+
* - `machine.name`: a kept container mounts THIS machine's home at /root; a
|
|
651
|
+
* same-project container on another machine is a different environment.
|
|
652
|
+
* - `projectsRoot`: the host dir mounted at /projects; two launches with the
|
|
653
|
+
* same project name but different roots are different working trees.
|
|
654
|
+
* - `mountParent` (or '' when absent): `--mount` re-roots into a DIFFERENT
|
|
655
|
+
* host parent at /work, so a `--mount` launch is a distinct identity from
|
|
656
|
+
* the projects-root launch of the same name.
|
|
657
|
+
* - the resolved container `cwd`: this already encodes the project token
|
|
658
|
+
* (`/projects/<p>`, `/work/<p>`, `.` -> a root, or /root for a bare shell)
|
|
659
|
+
* AND which root it sits under, so it is pi's conversation key too. Using
|
|
660
|
+
* the cwd keeps the container identity aligned with the conversation the
|
|
661
|
+
* kept container hosts.
|
|
662
|
+
*
|
|
663
|
+
* DELIBERATELY EXCLUDED (not part of identity): `--keep`/`--rm` (the throwaway
|
|
664
|
+
* choice for THIS run), the proxy + the direct-hole llm (forced-egress inputs),
|
|
665
|
+
* forwarded pi args, and the seed. Two launches that differ only in those must
|
|
666
|
+
* resolve to the SAME kept container.
|
|
667
|
+
*
|
|
668
|
+
* The key is a single opaque string (a `\n`-joined, field-tagged record) so the
|
|
669
|
+
* CLI can stamp it verbatim onto a netcage label and match on string equality;
|
|
670
|
+
* its internal shape is not a contract (compare only keys this function makes).
|
|
671
|
+
*/
|
|
672
|
+
export function keptContainerKey(intent) {
|
|
673
|
+
const { machine, projectsRoot, project, mountParent } = intent;
|
|
674
|
+
const mounted = nonEmpty(mountParent) !== undefined;
|
|
675
|
+
const rootKind = mounted ? 'mount' : 'projects';
|
|
676
|
+
// The same cwd resolution resolveRunPlan uses, so the key names the exact
|
|
677
|
+
// container a matching launch would run in (its conversation key).
|
|
678
|
+
const cwd = project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
|
|
679
|
+
return [
|
|
680
|
+
`machine=${machine.name}`,
|
|
681
|
+
`projectsRoot=${projectsRoot}`,
|
|
682
|
+
`mountParent=${nonEmpty(mountParent) ?? ''}`,
|
|
683
|
+
`cwd=${cwd}`,
|
|
684
|
+
].join('\n');
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
|
|
688
|
+
* `netcage.managed` containers (the CLI's netcage query result).
|
|
689
|
+
*
|
|
690
|
+
* - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
|
|
691
|
+
* listing is NOT consulted (a throwaway launch never resumes a kept box).
|
|
692
|
+
* - `--keep`: a kept container whose `key` equals this launch's
|
|
693
|
+
* keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
|
|
694
|
+
* (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
|
|
695
|
+
*
|
|
696
|
+
* Never spawns, never queries netcage: the listing is injected, so the whole
|
|
697
|
+
* decision is a pure function of (intent, listing).
|
|
698
|
+
*/
|
|
699
|
+
export function resolveRunVsStart(intent, kept) {
|
|
700
|
+
// Throwaway short-circuit: a `--rm` launch is always a fresh run and never
|
|
701
|
+
// consults the listing (it must not resume a kept container).
|
|
702
|
+
if (intent.keep !== true)
|
|
703
|
+
return { action: 'run' };
|
|
704
|
+
const want = keptContainerKey(intent);
|
|
705
|
+
const match = kept.find((c) => c.key === want);
|
|
706
|
+
return match ? { action: 'start', ref: match.ref } : { action: 'run' };
|
|
707
|
+
}
|
|
708
|
+
// --- The bare-launch menu: choice-list + per-machine project-usage record ----
|
|
709
|
+
//
|
|
710
|
+
// anon-pi's bare launch shows a HOST-side arrow-key menu of a machine's
|
|
711
|
+
// projects BEFORE any jail runs. This module owns only the PURE data the menu
|
|
712
|
+
// renders; the CLI reads the real dirs (the projects root + each machine home's
|
|
713
|
+
// sessions dir) and renders the raw-mode TUI (the cli-bare-launch-menu-tui
|
|
714
|
+
// task). Everything here takes SUPPLIED listings so it stays unit-testable.
|
|
715
|
+
//
|
|
716
|
+
// Conversations are per-machine (each machine's home keeps its own pi
|
|
717
|
+
// sessions), but project FILES are global (the same folder is shared across
|
|
718
|
+
// machines). pi keys a session by its launch cwd, so a project used on a machine
|
|
719
|
+
// leaves a session dir at machines/<M>/home/.pi/agent/sessions/<slug>/, where
|
|
720
|
+
// <slug> is pi's cwd convention over /projects/<name> (projectSessionSlug),
|
|
721
|
+
// machine-invariant. "Used on" is therefore DERIVED from which machine homes
|
|
722
|
+
// contain that session dir - no marker file.
|
|
723
|
+
/**
|
|
724
|
+
* PURE: the pi session-dir slug for a project, i.e. pathSlug of its jail cwd
|
|
725
|
+
* `/projects/<name>`. Because the cwd is the SAME on every machine (files are
|
|
726
|
+
* global, the projects root is mounted at /projects everywhere), this slug is
|
|
727
|
+
* MACHINE-INVARIANT: the same shared project is recognised in each machine's
|
|
728
|
+
* sessions dir. Validates the name (rejecting traversal) as projectContainerCwd
|
|
729
|
+
* does. e.g. `alpha` -> `--projects-alpha--`.
|
|
730
|
+
*/
|
|
731
|
+
export function projectSessionSlug(name) {
|
|
732
|
+
return pathSlug(projectContainerCwd(name));
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* PURE: build the menu choice-list from a SUPPLIED projects-root listing (the
|
|
736
|
+
* CLI's real `readdir` of the projects root). Entries that are not folder-safe
|
|
737
|
+
* project names (dotfiles like `.git`, `..`, path-separator names, whitespace,
|
|
738
|
+
* reserved tokens) are DROPPED silently: they can never be a valid project
|
|
739
|
+
* launch (validateName would reject them), and the `.` root is the separate
|
|
740
|
+
* `here` entry, not a listed project. The surviving names are sorted
|
|
741
|
+
* case-insensitively so the menu order is stable regardless of dir-read order.
|
|
742
|
+
*
|
|
743
|
+
* `canNew` / `canShell` default TRUE (both affordances are always offered
|
|
744
|
+
* today); they are fields so a later policy can gate them without a signature
|
|
745
|
+
* change. An empty projects root still offers here / new / shell.
|
|
746
|
+
*/
|
|
747
|
+
export function buildMenuChoiceList(args) {
|
|
748
|
+
const projects = args.projects.filter(isProjectName).sort((a, b) => {
|
|
749
|
+
const la = a.toLowerCase();
|
|
750
|
+
const lb = b.toLowerCase();
|
|
751
|
+
if (la < lb)
|
|
752
|
+
return -1;
|
|
753
|
+
if (la > lb)
|
|
754
|
+
return 1;
|
|
755
|
+
// Case-insensitive ties keep a deterministic order via the raw compare.
|
|
756
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
projects,
|
|
760
|
+
here: ROOT_TOKEN,
|
|
761
|
+
canNew: args.canNew ?? true,
|
|
762
|
+
canShell: args.canShell ?? true,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/** True iff `name` is a folder-safe project name (validateName would accept it). */
|
|
766
|
+
function isProjectName(name) {
|
|
767
|
+
try {
|
|
768
|
+
validateName(name, 'project');
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* PURE: derive the per-machine project-usage record from SUPPLIED session-dir
|
|
777
|
+
* presence (no marker file). For each supplied project, in the SUPPLIED order,
|
|
778
|
+
* it reports which machines' homes contain that project's (machine-invariant)
|
|
779
|
+
* session slug, and whether the CURRENT machine is new for it.
|
|
780
|
+
*
|
|
781
|
+
* The project ORDER is preserved (the caller orders the menu, e.g. via
|
|
782
|
+
* buildMenuChoiceList); only the per-project `machines` list is sorted, so the
|
|
783
|
+
* "used on" annotation is stable. Validates each project name (rejecting
|
|
784
|
+
* traversal) via projectSessionSlug.
|
|
785
|
+
*/
|
|
786
|
+
export function deriveProjectUsage(args) {
|
|
787
|
+
const { projects, currentMachine, sessions } = args;
|
|
788
|
+
const machineNames = Object.keys(sessions);
|
|
789
|
+
return projects.map((project) => {
|
|
790
|
+
const slug = projectSessionSlug(project);
|
|
791
|
+
const machines = machineNames
|
|
792
|
+
.filter((m) => (sessions[m] ?? []).includes(slug))
|
|
793
|
+
.sort();
|
|
794
|
+
const currentMachineIsNew = !(sessions[currentMachine] ?? []).includes(slug);
|
|
795
|
+
return { project, machines, currentMachineIsNew };
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
/** The fixed labels for the non-project affordances (one source, so the TUI + its test agree). */
|
|
799
|
+
export const MENU_HERE_LABEL = '. (here: a scratch pi at the root)';
|
|
800
|
+
export const MENU_NEW_LABEL = '+ new project\u2026';
|
|
801
|
+
export const MENU_SHELL_LABEL = 'shell (a jailed bash on this machine)';
|
|
802
|
+
/**
|
|
803
|
+
* PURE: render ONE project row's annotation from its usage record. Files are
|
|
804
|
+
* global but conversations are per-machine, so the row tells the user where a
|
|
805
|
+
* conversation for this project already lives (`used on: <machines>`) and
|
|
806
|
+
* whether the CURRENT machine has none yet (`new here`). An unused project on a
|
|
807
|
+
* fresh machine is just `new here` (no machine list). This is the whole
|
|
808
|
+
* user-visible surface of the derived usage record, kept pure + testable.
|
|
809
|
+
*/
|
|
810
|
+
export function formatProjectAnnotation(usage) {
|
|
811
|
+
const parts = [];
|
|
812
|
+
if (usage.machines.length > 0) {
|
|
813
|
+
parts.push(`used on: ${usage.machines.join(', ')}`);
|
|
814
|
+
}
|
|
815
|
+
if (usage.currentMachineIsNew)
|
|
816
|
+
parts.push('new here');
|
|
817
|
+
return parts.length > 0 ? ` (${parts.join('; ')})` : '';
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* PURE: assemble the ordered, labelled, selectable menu rows from the choice-
|
|
821
|
+
* list + the per-project usage record. The order is: the projects (in the
|
|
822
|
+
* choice-list's stable sorted order), then the `.` "here" scratch entry, then
|
|
823
|
+
* `+ new project\u2026` (when `canNew`), then `shell` (when `canShell`). Each
|
|
824
|
+
* project row's label carries its used-on / new-here annotation
|
|
825
|
+
* (formatProjectAnnotation). This holds ALL the menu's logic (order + wording)
|
|
826
|
+
* so the raw-mode selector only renders these rows and dispatches the picked
|
|
827
|
+
* one by its `kind`/`project`.
|
|
828
|
+
*
|
|
829
|
+
* The `usage` list is expected to be keyed to `choiceList.projects` (same order,
|
|
830
|
+
* as deriveProjectUsage produces from the choice-list's projects); a project
|
|
831
|
+
* with no matching usage entry gets a bare, unannotated row rather than erroring.
|
|
832
|
+
*/
|
|
833
|
+
export function buildMenuEntries(args) {
|
|
834
|
+
const { choiceList, usage } = args;
|
|
835
|
+
const byProject = new Map(usage.map((u) => [u.project, u]));
|
|
836
|
+
const entries = choiceList.projects.map((project) => {
|
|
837
|
+
const u = byProject.get(project);
|
|
838
|
+
const annotation = u ? formatProjectAnnotation(u) : '';
|
|
839
|
+
return { kind: 'project', project, label: `${project}${annotation}` };
|
|
840
|
+
});
|
|
841
|
+
entries.push({
|
|
842
|
+
kind: 'here',
|
|
843
|
+
project: choiceList.here,
|
|
844
|
+
label: MENU_HERE_LABEL,
|
|
845
|
+
});
|
|
846
|
+
if (choiceList.canNew)
|
|
847
|
+
entries.push({ kind: 'new', label: MENU_NEW_LABEL });
|
|
848
|
+
if (choiceList.canShell)
|
|
849
|
+
entries.push({ kind: 'shell', label: MENU_SHELL_LABEL });
|
|
850
|
+
return entries;
|
|
97
851
|
}
|
|
98
852
|
/**
|
|
99
853
|
* Encode an absolute path into a directory name using pi's OWN convention (see
|
|
@@ -104,14 +858,6 @@ export function resolveConfigSeed(env) {
|
|
|
104
858
|
export function pathSlug(absPath) {
|
|
105
859
|
return `--${absPath.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
|
|
106
860
|
}
|
|
107
|
-
/**
|
|
108
|
-
* The persistent per-workdir state dir on the host (mounted at the container's
|
|
109
|
-
* ~/.pi/agent). Keyed by the workdir via pi's path-slug convention:
|
|
110
|
-
* <anonPiHome>/state/<slug>/agent
|
|
111
|
-
*/
|
|
112
|
-
export function stateAgentDir(env, absWorkdir) {
|
|
113
|
-
return join(resolveAnonPiHome(env), 'state', pathSlug(absWorkdir), 'agent');
|
|
114
|
-
}
|
|
115
861
|
/**
|
|
116
862
|
* Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
|
|
117
863
|
* baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
|
|
@@ -126,182 +872,296 @@ export function hostPortKey(value) {
|
|
|
126
872
|
v = v.replace(/^[^@]*@/, ''); // drop any user:pass@
|
|
127
873
|
return v.toLowerCase();
|
|
128
874
|
}
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
875
|
+
/**
|
|
876
|
+
* The provider key anon-pi gives the single local provider it generates. A
|
|
877
|
+
* neutral, host-agnostic name (matches the CONTEXT glossary's "local model"):
|
|
878
|
+
* it carries NO host identity, unlike the old `import` path which kept the
|
|
879
|
+
* host's own provider key.
|
|
880
|
+
*/
|
|
881
|
+
export const LOCAL_PROVIDER_NAME = 'local';
|
|
882
|
+
/**
|
|
883
|
+
* The pi `api` dialect the generated local provider speaks. Local model servers
|
|
884
|
+
* (llama.cpp, ollama, LM Studio, vLLM, ...) are overwhelmingly OpenAI-compatible
|
|
885
|
+
* and serve the completions API under `/v1`, so this is the safe default for an
|
|
886
|
+
* endpoint captured by `init` (there is no host models.json to copy a dialect
|
|
887
|
+
* from anymore). See the ## Decisions note in the done record.
|
|
888
|
+
*/
|
|
889
|
+
export const LOCAL_PROVIDER_API = 'openai-completions';
|
|
890
|
+
/**
|
|
891
|
+
* A benign, non-secret apiKey for the local provider (a LAN model rarely needs a
|
|
892
|
+
* real key). It is one of the values pi never flags as a real secret.
|
|
893
|
+
*/
|
|
894
|
+
export const LOCAL_PROVIDER_API_KEY = 'none';
|
|
895
|
+
/**
|
|
896
|
+
* PURE: synthesize a barebones pi `models.json` from a single `llm` endpoint
|
|
897
|
+
* (a URL, `ip:port`, or bare ip). It normalises the endpoint with `hostPortKey`
|
|
898
|
+
* (drops scheme/path/user:pass@, lowercases) and returns a models.json carrying
|
|
899
|
+
* exactly ONE local provider pointed at that endpoint.
|
|
900
|
+
*
|
|
901
|
+
* This REPLACES the old `import`-from-host-models.json flow: it reads NO host pi
|
|
902
|
+
* config, so no other provider, no paid API key, no session identity can leak
|
|
903
|
+
* into the seed. Endpoint in -> object out; `init` / seed-if-fresh write the
|
|
904
|
+
* result into the machine home.
|
|
905
|
+
*
|
|
906
|
+
* The baseUrl is `http://<host[:port]>/v1` (the OpenAI-compatible convention the
|
|
907
|
+
* completions api uses); the api dialect + benign apiKey are the LOCAL_PROVIDER_*
|
|
908
|
+
* constants.
|
|
909
|
+
*/
|
|
910
|
+
export function generateModelsJson(llmEndpoint) {
|
|
911
|
+
const hostPort = hostPortKey(llmEndpoint);
|
|
912
|
+
const provider = {
|
|
913
|
+
api: LOCAL_PROVIDER_API,
|
|
914
|
+
apiKey: LOCAL_PROVIDER_API_KEY,
|
|
915
|
+
baseUrl: `http://${hostPort}/v1`,
|
|
916
|
+
models: [],
|
|
167
917
|
};
|
|
918
|
+
return { providers: { [LOCAL_PROVIDER_NAME]: provider } };
|
|
168
919
|
}
|
|
920
|
+
// --- `anon-pi init` onboarding: the PURE proxy detect/verify DECISIONS --------
|
|
921
|
+
//
|
|
922
|
+
// `anon-pi init` onboards HONESTLY (this is an anonymity tool): its proxy step
|
|
923
|
+
// presents EVIDENCE only (open ports, a real SOCKS5 handshake, a real `netcage
|
|
924
|
+
// verify` exit IP) plus WEAK process hints. It MUST NEVER claim/label the exit
|
|
925
|
+
// provider: a SOCKS proxy does not announce Mullvad/Proton/NordVPN/etc, so a
|
|
926
|
+
// provider label would be a DANGEROUS LIE. This module owns the pure decisions
|
|
927
|
+
// (handshake interpretation, the findings-without-labels formatter, the weak
|
|
928
|
+
// hint wording, the verify exit-IP parse); the socket probes, the `netcage
|
|
929
|
+
// verify` / `podman build` spawns, and the prompts are cli.ts's thin I/O.
|
|
169
930
|
/**
|
|
170
|
-
* The default
|
|
171
|
-
*
|
|
172
|
-
*
|
|
931
|
+
* The default SOCKS ports `init` probes, each with a WEAK, structural hint (the
|
|
932
|
+
* conventional tool that DEFAULTS to that port). The hint names a local tool a
|
|
933
|
+
* port is CONVENTIONALLY used by, NOT the exit provider: `9050`/`9150` are Tor's
|
|
934
|
+
* own listeners (Tor IS the tool, so naming it is honest), `1080` is the generic
|
|
935
|
+
* SOCKS default (wireproxy / `ssh -D` / other), which is why its hint stays
|
|
936
|
+
* provider-agnostic ("wireproxy / ssh -D / generic"): behind a `1080` wireproxy
|
|
937
|
+
* could be ANY WireGuard VPN, and we never guess which. See the ADR / Decisions.
|
|
173
938
|
*/
|
|
174
|
-
export
|
|
175
|
-
|
|
176
|
-
|
|
939
|
+
export const DEFAULT_SOCKS_PROBE_PORTS = [
|
|
940
|
+
{ port: 9050, hint: 'Tor default (system tor)' },
|
|
941
|
+
{ port: 9150, hint: 'Tor Browser default' },
|
|
942
|
+
{ port: 1080, hint: 'generic SOCKS (wireproxy / ssh -D)' },
|
|
943
|
+
];
|
|
944
|
+
/**
|
|
945
|
+
* The SOCKS5 method-selection greeting `init` sends to CONFIRM a port really
|
|
946
|
+
* speaks SOCKS5 (RFC 1928 §3): version 5, one method offered, `0x00`
|
|
947
|
+
* (no-authentication). A real SOCKS5 server replies with two bytes
|
|
948
|
+
* `[0x05, <method>]`; anything else is not SOCKS5. Exposed as a constant so the
|
|
949
|
+
* probe I/O and the handshake test send byte-identical bytes.
|
|
950
|
+
*/
|
|
951
|
+
export const SOCKS5_METHOD_SELECTOR = [0x05, 0x01, 0x00];
|
|
952
|
+
/**
|
|
953
|
+
* PURE: interpret a SOCKS5 method-selection REPLY (the bytes read back after
|
|
954
|
+
* sending SOCKS5_METHOD_SELECTOR). A valid reply is EXACTLY the two bytes
|
|
955
|
+
* `[0x05, <method>]` where `<method> != 0xff` (0xff = "no acceptable methods",
|
|
956
|
+
* i.e. the server IS SOCKS5 but rejected no-auth; that is still a SOCKS5 server,
|
|
957
|
+
* but for a bare no-auth probe we treat it as a soft failure so the finding does
|
|
958
|
+
* not imply the port is usable no-auth). Any non-5 first byte, a short reply, or
|
|
959
|
+
* an empty reply is NOT SOCKS5.
|
|
960
|
+
*
|
|
961
|
+
* Reply in -> verdict out; the socket read is cli.ts's job. The reason strings
|
|
962
|
+
* are deliberately structural ("no reply", "not SOCKS5") and NEVER name a
|
|
963
|
+
* provider.
|
|
964
|
+
*/
|
|
965
|
+
export function interpretSocks5Handshake(reply) {
|
|
966
|
+
const bytes = Array.from(reply);
|
|
967
|
+
if (bytes.length === 0)
|
|
968
|
+
return { socks5: false, reason: 'no reply' };
|
|
969
|
+
if (bytes.length < 2)
|
|
970
|
+
return { socks5: false, reason: 'short reply' };
|
|
971
|
+
if (bytes[0] !== 0x05)
|
|
972
|
+
return { socks5: false, reason: 'not SOCKS5' };
|
|
973
|
+
const method = bytes[1];
|
|
974
|
+
if (method === 0xff) {
|
|
975
|
+
return { socks5: false, reason: 'SOCKS5 but no acceptable auth method' };
|
|
177
976
|
}
|
|
178
|
-
|
|
179
|
-
? env.piAgentDir
|
|
180
|
-
: join(env.home, '.pi', 'agent');
|
|
181
|
-
return join(agentDir, MODELS_FILE);
|
|
977
|
+
return { socks5: true, method };
|
|
182
978
|
}
|
|
183
979
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
980
|
+
* PURE: map an observed local process name to a WEAK, hedged hint, or undefined
|
|
981
|
+
* when we have nothing honest to say. The ONLY confident mapping is `tor` ->
|
|
982
|
+
* "likely Tor", because Tor is a LOCAL tool that runs its OWN SOCKS listener (so
|
|
983
|
+
* seeing `tor` is real evidence the port is Tor). We do NOT map anything to an
|
|
984
|
+
* EXIT provider (Mullvad/Proton/...): a `wireproxy` process only tells us the
|
|
985
|
+
* SOCKS front-end, never which VPN sits behind it, so its hint stays
|
|
986
|
+
* provider-agnostic. Every returned hint is HEDGED ("likely", "-> a SOCKS
|
|
987
|
+
* front-end") and never states the exit provider.
|
|
988
|
+
*/
|
|
989
|
+
export function processHint(processName) {
|
|
990
|
+
const name = processName.trim().toLowerCase();
|
|
991
|
+
if (name === '')
|
|
992
|
+
return undefined;
|
|
993
|
+
if (name === 'tor') {
|
|
994
|
+
return {
|
|
995
|
+
process: processName,
|
|
996
|
+
hint: 'a `tor` process is running -> likely Tor',
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
if (name === 'wireproxy') {
|
|
1000
|
+
return {
|
|
1001
|
+
process: processName,
|
|
1002
|
+
// A SOCKS front-end for SOME WireGuard VPN; we NEVER guess which one.
|
|
1003
|
+
hint: 'a `wireproxy` process is running -> a SOCKS front-end for a ' +
|
|
1004
|
+
'WireGuard VPN (which one is not observable here)',
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return undefined;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* The set of substrings a findings line must NEVER contain: known exit-provider
|
|
1011
|
+
* / VPN brand names. This is the machine-checkable half of the never-label rule
|
|
1012
|
+
* (a test asserts formatProxyFindings' output contains NONE of these for any
|
|
1013
|
+
* input). It is not exhaustive of every brand, but it pins the obvious ones so a
|
|
1014
|
+
* regression that starts labelling providers is caught. `tor` is NOT here: Tor
|
|
1015
|
+
* is the LOCAL tool we legitimately hint at, not an opaque exit provider.
|
|
1016
|
+
*/
|
|
1017
|
+
export const FORBIDDEN_PROVIDER_LABELS = [
|
|
1018
|
+
'mullvad',
|
|
1019
|
+
'proton',
|
|
1020
|
+
'nordvpn',
|
|
1021
|
+
'nord vpn',
|
|
1022
|
+
'expressvpn',
|
|
1023
|
+
'express vpn',
|
|
1024
|
+
'surfshark',
|
|
1025
|
+
'ivpn',
|
|
1026
|
+
'pia',
|
|
1027
|
+
'private internet access',
|
|
1028
|
+
'cyberghost',
|
|
1029
|
+
'windscribe',
|
|
1030
|
+
];
|
|
1031
|
+
/**
|
|
1032
|
+
* PURE: format the probe findings into the human-readable block `init` shows
|
|
1033
|
+
* before asking the user to CHOOSE a proxy. It renders EVIDENCE ONLY: for each
|
|
1034
|
+
* candidate, the `host:port`, whether it is open, the SOCKS5 handshake verdict,
|
|
1035
|
+
* and the structural PORT hint. It NEVER emits an exit-provider label (a SOCKS
|
|
1036
|
+
* proxy does not announce its provider; a false label is a dangerous lie). The
|
|
1037
|
+
* `## Decisions` note + a test assert the output never contains a
|
|
1038
|
+
* FORBIDDEN_PROVIDER_LABELS substring for any input.
|
|
193
1039
|
*
|
|
194
|
-
* `
|
|
195
|
-
*
|
|
196
|
-
*
|
|
1040
|
+
* `processNote` is the HOST-WIDE weak process hint (a running `tor`/`wireproxy`
|
|
1041
|
+
* LOCAL process), shown ONCE as a general note rather than glued onto every port
|
|
1042
|
+
* line: the observation is host-wide, not per-port, so repeating it on each
|
|
1043
|
+
* candidate (including closed ports the process is unrelated to) reads as noise.
|
|
1044
|
+
* A per-finding `processHint`, if still set, is also honoured inline for
|
|
1045
|
+
* backward compatibility, but `init` now passes the host-wide note instead.
|
|
197
1046
|
*
|
|
198
|
-
*
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
*/
|
|
204
|
-
export function buildRunPlan(env, workdirArg, modelsSeedExists, stateExists) {
|
|
205
|
-
if (!env.image || env.image.trim() === '') {
|
|
206
|
-
// dockerfilePath is injected (cli.ts resolves the shipped Dockerfile.pi via
|
|
207
|
-
// import.meta.url; tests pass a fixed path). Every command is emitted
|
|
208
|
-
// flush-left so it copy-pastes cleanly: an indented heredoc would bake
|
|
209
|
-
// leading spaces into the Dockerfile and break the EOF terminator, so we
|
|
210
|
-
// point at the shipped file instead of printing a heredoc.
|
|
211
|
-
const df = env.dockerfilePath ?? 'Dockerfile.pi';
|
|
212
|
-
const wv = env.webveilDockerfilePath ?? 'examples/Dockerfile.pi-webveil';
|
|
213
|
-
throw new AnonPiError('anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
|
|
214
|
-
'\n' +
|
|
215
|
-
'No image yet? A ready Dockerfile.pi ships with anon-pi (it installs the\n' +
|
|
216
|
-
'official @earendil-works/pi-coding-agent). Build it and point at it:\n' +
|
|
217
|
-
'\n' +
|
|
218
|
-
`podman build -t localhost/anon-pi-pi:latest -f "${df}" "$(dirname "${df}")"\n` +
|
|
219
|
-
'export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\n' +
|
|
220
|
-
'\n' +
|
|
221
|
-
'Or the fuller example with the pi-webveil extension + a local SearXNG\n' +
|
|
222
|
-
'(anonymized web search):\n' +
|
|
223
|
-
'\n' +
|
|
224
|
-
`podman build -t localhost/anon-pi-webveil:latest -f "${wv}" "$(dirname "${wv}")"\n` +
|
|
225
|
-
'export ANON_PI_IMAGE=localhost/anon-pi-webveil:latest\n' +
|
|
226
|
-
'\n' +
|
|
227
|
-
'See the README (Providing a pi image) for details and a community-image note.');
|
|
228
|
-
}
|
|
229
|
-
if (!env.llmDirect || env.llmDirect.trim() === '') {
|
|
230
|
-
throw new AnonPiError('anon-pi: set ANON_PI_LLM to the RFC1918/link-local IP[:port] of the local model pi should reach directly (e.g. ANON_PI_LLM=192.168.1.150:8080). All other egress stays forced through the proxy.');
|
|
231
|
-
}
|
|
232
|
-
if (!env.proxy || env.proxy.trim() === '') {
|
|
233
|
-
// No default: this is an anonymity tool, so the proxy is REQUIRED and never
|
|
234
|
-
// guessed (mirrors netcage, which fails closed without --proxy). A silent
|
|
235
|
-
// default would anonymize through the wrong endpoint, or fail deep in the
|
|
236
|
-
// jail with a confusing DNS error, if the guessed proxy is not actually up.
|
|
237
|
-
throw new AnonPiError('anon-pi: set ANON_PI_PROXY to your socks5h proxy. anon-pi has no default:\n' +
|
|
238
|
-
'the proxy is what makes the session anonymous, so it is never guessed.\n' +
|
|
239
|
-
'\n' +
|
|
240
|
-
'Pick the one you run (copy-paste), then re-run anon-pi:\n' +
|
|
241
|
-
'\n' +
|
|
242
|
-
'# Tor (system tor / Tor Browser bundle default port)\n' +
|
|
243
|
-
'export ANON_PI_PROXY=socks5h://127.0.0.1:9050\n' +
|
|
244
|
-
'\n' +
|
|
245
|
-
'# wireproxy -> a WireGuard VPN (Mullvad, Proton, ...); use YOUR configured\n' +
|
|
246
|
-
'# [Socks5] BindAddress port (1080 in wireproxy examples):\n' +
|
|
247
|
-
'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
|
|
248
|
-
'\n' +
|
|
249
|
-
'# an SSH dynamic-forward (ssh -D 1080 host) or any other socks5h endpoint\n' +
|
|
250
|
-
'export ANON_PI_PROXY=socks5h://127.0.0.1:1080\n' +
|
|
251
|
-
'\n' +
|
|
252
|
-
'Only socks5h:// is accepted (plain socks5:// resolves DNS locally and leaks).');
|
|
253
|
-
}
|
|
254
|
-
const home = env.home;
|
|
255
|
-
if (!home || home.trim() === '') {
|
|
256
|
-
throw new AnonPiError('anon-pi: could not resolve HOME.');
|
|
257
|
-
}
|
|
258
|
-
const raw = workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
|
|
259
|
-
const workdir = isAbsolute(raw) ? raw : resolve(raw);
|
|
260
|
-
// Persistent per-workdir state home, unless --ephemeral (no writable mount).
|
|
261
|
-
const ephemeral = env.ephemeral === true;
|
|
262
|
-
const stateDir = ephemeral ? '' : stateAgentDir(env, workdir);
|
|
263
|
-
// Ephemeral home is always fresh (the container's throwaway layer); a
|
|
264
|
-
// persistent home is fresh iff its dir is absent.
|
|
265
|
-
const fresh = ephemeral ? true : !stateExists(stateDir);
|
|
266
|
-
// The canonical imported models.json is mounted (read-only) for the seed only
|
|
267
|
-
// when it exists; pi can also start with no models and you add them in-session.
|
|
268
|
-
const modelsSeed = join(resolveConfigSeed(env), MODELS_FILE);
|
|
269
|
-
const haveModelsSeed = modelsSeedExists(modelsSeed);
|
|
270
|
-
const proxy = env.proxy.trim();
|
|
271
|
-
// netcage's --allow-direct wants a bare IP[:port]/CIDR (no scheme/path), but a
|
|
272
|
-
// user naturally sets ANON_PI_LLM to a URL (http://192.168.1.150:8080). Strip
|
|
273
|
-
// it to host:port with the same helper `import` uses to match providers, so a
|
|
274
|
-
// URL, an ip:port, or a bare ip all work.
|
|
275
|
-
const directTarget = hostPortKey(env.llmDirect);
|
|
276
|
-
const seedVersion = env.seedVersion ?? SEED_VERSION;
|
|
277
|
-
const netcageArgs = [
|
|
278
|
-
'run',
|
|
279
|
-
'--proxy',
|
|
280
|
-
proxy,
|
|
281
|
-
'--allow-direct',
|
|
282
|
-
directTarget,
|
|
283
|
-
'-it',
|
|
284
|
-
'-v',
|
|
285
|
-
workdir, // netcage defaults a target-less -v to /work and cwd to /work
|
|
286
|
-
];
|
|
287
|
-
// Persistent mode ONLY: mount the per-workdir state home at ~/.pi/agent
|
|
288
|
-
// (Model B). --ephemeral mounts nothing writable: pi writes to the container's
|
|
289
|
-
// own --rm layer, gone on exit, no host state.
|
|
290
|
-
if (!ephemeral) {
|
|
291
|
-
netcageArgs.push('-v', `${stateDir}:${CONTAINER_AGENT_DIR}`);
|
|
292
|
-
}
|
|
293
|
-
// Mount the imported models.json read-only for the first-launch seed, if any.
|
|
294
|
-
if (haveModelsSeed) {
|
|
295
|
-
netcageArgs.push('-v', `${modelsSeed}:${CONTAINER_MODELS_SEED}:ro`);
|
|
1047
|
+
* Findings in -> display string out; the socket probes are cli.ts's job.
|
|
1048
|
+
*/
|
|
1049
|
+
export function formatProxyFindings(findings, processNote) {
|
|
1050
|
+
if (findings.length === 0) {
|
|
1051
|
+
return 'No SOCKS ports responded on the probed set. Enter your proxy as host:port.';
|
|
296
1052
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1053
|
+
const lines = [];
|
|
1054
|
+
for (const f of findings) {
|
|
1055
|
+
const where = `${f.host}:${f.port}`;
|
|
1056
|
+
let status;
|
|
1057
|
+
if (!f.open) {
|
|
1058
|
+
status = 'closed (no TCP connection)';
|
|
1059
|
+
}
|
|
1060
|
+
else if (f.handshake && f.handshake.socks5) {
|
|
1061
|
+
status = 'open, SOCKS5 handshake OK';
|
|
1062
|
+
}
|
|
1063
|
+
else if (f.handshake && !f.handshake.socks5) {
|
|
1064
|
+
status = `open, but NOT SOCKS5 (${f.handshake.reason})`;
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
status = 'open';
|
|
1068
|
+
}
|
|
1069
|
+
const hints = [];
|
|
1070
|
+
if (f.portHint)
|
|
1071
|
+
hints.push(f.portHint);
|
|
1072
|
+
if (f.processHint)
|
|
1073
|
+
hints.push(f.processHint);
|
|
1074
|
+
const hintStr = hints.length > 0 ? ` [${hints.join('; ')}]` : '';
|
|
1075
|
+
lines.push(`${where}: ${status}${hintStr}`);
|
|
1076
|
+
}
|
|
1077
|
+
// The host-wide process observation, shown ONCE (not per port). It is a weak
|
|
1078
|
+
// LOCAL hint, never an exit-provider label.
|
|
1079
|
+
if (processNote && processNote.trim() !== '') {
|
|
1080
|
+
lines.push(`Note: ${processNote.trim()}`);
|
|
1081
|
+
}
|
|
1082
|
+
lines.push('These are EVIDENCE only (open ports + a real SOCKS5 handshake). A SOCKS ' +
|
|
1083
|
+
'proxy does not announce its exit provider, so none is claimed here; the ' +
|
|
1084
|
+
'`netcage verify` step below shows the real exit IP as proof.');
|
|
1085
|
+
return lines.join('\n');
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* PURE: the `socks5h://<host:port>` URL `init` hands to `netcage verify` and
|
|
1089
|
+
* writes into config.json. Only socks5h:// is accepted downstream (plain
|
|
1090
|
+
* socks5:// resolves DNS locally and leaks), so `init` always emits socks5h.
|
|
1091
|
+
* A value that already carries a scheme is normalised to its host:port first
|
|
1092
|
+
* (via hostPortKey) so `socks5h://socks5h://...` can never be produced.
|
|
1093
|
+
*/
|
|
1094
|
+
export function socks5hUrl(hostPort) {
|
|
1095
|
+
return `socks5h://${hostPortKey(hostPort)}`;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* PURE: extract the exit IP `netcage verify` reported from its combined output.
|
|
1099
|
+
* `netcage verify` prints the jail's forced-egress exit IP (an IPv4/IPv6 line)
|
|
1100
|
+
* as PROOF the egress leaves via the proxy (not the host IP). We scan the output
|
|
1101
|
+
* for the first plausible IP literal and return it; undefined if none is found
|
|
1102
|
+
* (the caller then shows the raw output and lets the user judge). This is a
|
|
1103
|
+
* best-effort PARSE of another tool's text, kept pure + tested so a format tweak
|
|
1104
|
+
* is caught by a unit test, not only in the field.
|
|
1105
|
+
*/
|
|
1106
|
+
export function parseVerifyExitIp(output) {
|
|
1107
|
+
// IPv4 first (the common case: ipify returns an IPv4 for most exits).
|
|
1108
|
+
const v4 = output.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
|
|
1109
|
+
if (v4) {
|
|
1110
|
+
const ip = v4[0];
|
|
1111
|
+
if (ip.split('.').every((o) => Number(o) <= 255))
|
|
1112
|
+
return ip;
|
|
1113
|
+
}
|
|
1114
|
+
// IPv6 (a loose match: at least two groups and a colon-run), best-effort.
|
|
1115
|
+
const v6 = output.match(/\b(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4}\b/);
|
|
1116
|
+
if (v6 && v6[0].includes('::'))
|
|
1117
|
+
return v6[0];
|
|
1118
|
+
if (v6 && v6[0].split(':').filter(Boolean).length >= 3)
|
|
1119
|
+
return v6[0];
|
|
1120
|
+
return undefined;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* PURE: the ordered image-menu entries `init` shows. `[1]` basic pi
|
|
1124
|
+
* (Dockerfile.pi), `[2]` pi + webveil/SearXNG (examples/Dockerfile.pi-webveil),
|
|
1125
|
+
* `[3]` an existing image ref, `[4]` skip. A single source so the prompt and its
|
|
1126
|
+
* test agree on the order + wording.
|
|
1127
|
+
*/
|
|
1128
|
+
export function initImageMenu() {
|
|
1129
|
+
return [
|
|
1130
|
+
{ choice: 'basic', label: 'basic pi (build the shipped Dockerfile.pi)' },
|
|
1131
|
+
{
|
|
1132
|
+
choice: 'webveil',
|
|
1133
|
+
label: 'pi + webveil/SearXNG (build the shipped examples/Dockerfile.pi-webveil)',
|
|
1134
|
+
},
|
|
1135
|
+
{ choice: 'existing', label: 'an existing image ref (I already have one)' },
|
|
1136
|
+
{
|
|
1137
|
+
choice: 'skip',
|
|
1138
|
+
label: 'skip (create the machine imageless; pin it later)',
|
|
1139
|
+
},
|
|
1140
|
+
];
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* PURE: build the `config.json` body `init` writes, keeping only the non-empty
|
|
1144
|
+
* fields (a skipped image / llm is simply omitted, never written as ""). Emits
|
|
1145
|
+
* pretty-printed JSON (tab indent, trailing newline) matching
|
|
1146
|
+
* serializeMachineJson, so a browsed ~/.anon-pi/config.json reads cleanly. The
|
|
1147
|
+
* proxy is REQUIRED (init only reaches here after a verified proxy), so it is
|
|
1148
|
+
* always present; llm / defaultMachine / projects are included when set.
|
|
1149
|
+
*/
|
|
1150
|
+
export function serializeConfigJson(config) {
|
|
1151
|
+
const out = {};
|
|
1152
|
+
const proxy = nonEmpty(config.proxy);
|
|
1153
|
+
if (proxy !== undefined)
|
|
1154
|
+
out.proxy = proxy;
|
|
1155
|
+
const llm = nonEmpty(config.llm);
|
|
1156
|
+
if (llm !== undefined)
|
|
1157
|
+
out.llm = llm;
|
|
1158
|
+
const defaultMachine = nonEmpty(config.defaultMachine);
|
|
1159
|
+
if (defaultMachine !== undefined)
|
|
1160
|
+
out.defaultMachine = defaultMachine;
|
|
1161
|
+
const projects = nonEmpty(config.projects);
|
|
1162
|
+
if (projects !== undefined)
|
|
1163
|
+
out.projects = projects;
|
|
1164
|
+
return JSON.stringify(out, null, '\t') + '\n';
|
|
305
1165
|
}
|
|
306
1166
|
/**
|
|
307
1167
|
* Absolute path to the Dockerfile.pi that ships with anon-pi, resolved from this
|
|
@@ -337,82 +1197,184 @@ function shippedFile(rel) {
|
|
|
337
1197
|
}
|
|
338
1198
|
return undefined;
|
|
339
1199
|
}
|
|
1200
|
+
/**
|
|
1201
|
+
* PURE: parse the tokens AFTER `machine` into a MachineCommand. Validates the
|
|
1202
|
+
* machine name via validateName (the reserved-name / traversal guard) so the CLI
|
|
1203
|
+
* only ever joins a safe segment under the machines dir. Throws AnonPiError
|
|
1204
|
+
* (printed verbatim, exit 1) for an unknown/missing verb, a missing or extra
|
|
1205
|
+
* positional, an unknown flag, or a bad name.
|
|
1206
|
+
*
|
|
1207
|
+
* The grammar is deliberately small and flag-light (mirrors the launch grammar's
|
|
1208
|
+
* `--yes` / `--image` shape): `--image <ref>` on create, `--yes` on rm; no other
|
|
1209
|
+
* flags. This keeps `machine` a thin, predictable dispatch surface.
|
|
1210
|
+
*/
|
|
1211
|
+
export function parseMachineArgs(args) {
|
|
1212
|
+
const fail = (msg) => {
|
|
1213
|
+
throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi machine --help\` or \`anon-pi --help\`.`);
|
|
1214
|
+
};
|
|
1215
|
+
const verb = args[0];
|
|
1216
|
+
if (verb === undefined) {
|
|
1217
|
+
fail('`machine` needs a subcommand: create | list | set-image | rm');
|
|
1218
|
+
}
|
|
1219
|
+
const rest = args.slice(1);
|
|
1220
|
+
if (verb === 'list') {
|
|
1221
|
+
if (rest.length > 0)
|
|
1222
|
+
fail(`machine list takes no arguments, got: ${rest.join(' ')}`);
|
|
1223
|
+
return { verb: 'list' };
|
|
1224
|
+
}
|
|
1225
|
+
if (verb === 'create') {
|
|
1226
|
+
let name;
|
|
1227
|
+
let image;
|
|
1228
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1229
|
+
const a = rest[i];
|
|
1230
|
+
if (a === '--image') {
|
|
1231
|
+
const v = rest[++i];
|
|
1232
|
+
if (v === undefined)
|
|
1233
|
+
fail('--image needs an image ref');
|
|
1234
|
+
image = v;
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (a.startsWith('-'))
|
|
1238
|
+
fail(`unknown option: ${a}`);
|
|
1239
|
+
if (name !== undefined)
|
|
1240
|
+
fail(`machine create takes one name, got extra: ${a}`);
|
|
1241
|
+
name = validateName(a, 'machine');
|
|
1242
|
+
}
|
|
1243
|
+
if (name === undefined)
|
|
1244
|
+
fail('machine create needs a <name>');
|
|
1245
|
+
return { verb: 'create', name: name, image: nonEmpty(image) };
|
|
1246
|
+
}
|
|
1247
|
+
if (verb === 'set-image') {
|
|
1248
|
+
let name;
|
|
1249
|
+
let image;
|
|
1250
|
+
for (const a of rest) {
|
|
1251
|
+
if (a.startsWith('-'))
|
|
1252
|
+
fail(`unknown option: ${a}`);
|
|
1253
|
+
if (name === undefined) {
|
|
1254
|
+
name = validateName(a, 'machine');
|
|
1255
|
+
}
|
|
1256
|
+
else if (image === undefined) {
|
|
1257
|
+
image = a;
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
fail(`machine set-image takes <name> <ref>, got extra: ${a}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (name === undefined)
|
|
1264
|
+
fail('machine set-image needs a <name> and an <image-ref>');
|
|
1265
|
+
if (nonEmpty(image) === undefined)
|
|
1266
|
+
fail('machine set-image needs an <image-ref>');
|
|
1267
|
+
return {
|
|
1268
|
+
verb: 'set-image',
|
|
1269
|
+
name: name,
|
|
1270
|
+
image: image.trim(),
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (verb === 'rm') {
|
|
1274
|
+
let name;
|
|
1275
|
+
let yes = false;
|
|
1276
|
+
for (const a of rest) {
|
|
1277
|
+
if (a === '--yes' || a === '-y') {
|
|
1278
|
+
yes = true;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (a.startsWith('-'))
|
|
1282
|
+
fail(`unknown option: ${a}`);
|
|
1283
|
+
if (name !== undefined)
|
|
1284
|
+
fail(`machine rm takes one name, got extra: ${a}`);
|
|
1285
|
+
name = validateName(a, 'machine');
|
|
1286
|
+
}
|
|
1287
|
+
if (name === undefined)
|
|
1288
|
+
fail('machine rm needs a <name>');
|
|
1289
|
+
return { verb: 'rm', name: name, yes };
|
|
1290
|
+
}
|
|
1291
|
+
return fail(`unknown machine subcommand: ${verb} (create | list | set-image | rm)`);
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* PURE: the JSON body a machine.json carries, given the pinned image (and an
|
|
1295
|
+
* optional per-machine projects override, preserved on a re-pin). A single
|
|
1296
|
+
* source so create + set-image write byte-identical, pretty-printed JSON (tab
|
|
1297
|
+
* indent, trailing newline) that reads cleanly when the user browses
|
|
1298
|
+
* ~/.anon-pi/machines/<M>/machine.json.
|
|
1299
|
+
*/
|
|
1300
|
+
export function serializeMachineJson(config) {
|
|
1301
|
+
const out = {};
|
|
1302
|
+
if (nonEmpty(config.image) !== undefined)
|
|
1303
|
+
out.image = config.image.trim();
|
|
1304
|
+
if (nonEmpty(config.projects) !== undefined)
|
|
1305
|
+
out.projects = config.projects.trim();
|
|
1306
|
+
return JSON.stringify(out, null, '\t') + '\n';
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* PURE: the compatibility WARNING `machine set-image` prints after re-pinning
|
|
1310
|
+
* the image. Re-pinning does NOT reseed or touch the home: the home's pi
|
|
1311
|
+
* extensions / downloaded bin were built against the OLD image, so a mismatched
|
|
1312
|
+
* new image may misbehave. The message tells the user the two remedies (re-run
|
|
1313
|
+
* `pi install` inside the machine, or delete the home to reseed) WITHOUT doing
|
|
1314
|
+
* either automatically. See the ## Decisions note (set-image warning wording).
|
|
1315
|
+
*/
|
|
1316
|
+
export function setImageWarning(name, oldImage, newImage) {
|
|
1317
|
+
const from = oldImage === undefined ? '(none)' : oldImage;
|
|
1318
|
+
return (`anon-pi: re-pinned machine ${JSON.stringify(name)} image ${from} -> ${newImage}.\n` +
|
|
1319
|
+
'WARNING: the home was NOT reseeded. Its pi extensions and downloaded tools\n' +
|
|
1320
|
+
'were built for the old image; if they misbehave on the new one, re-run\n' +
|
|
1321
|
+
'`pi install` inside the machine, or delete + reseed the home with\n' +
|
|
1322
|
+
`\`anon-pi --delete-home ${name}\` (then relaunch to seed fresh).`);
|
|
1323
|
+
}
|
|
340
1324
|
/** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
|
|
341
1325
|
export function envFromProcess(penv) {
|
|
342
1326
|
return {
|
|
343
1327
|
home: penv.HOME ?? homedir(),
|
|
344
1328
|
proxy: penv.ANON_PI_PROXY,
|
|
345
1329
|
anonPiHome: penv.ANON_PI_HOME,
|
|
346
|
-
|
|
1330
|
+
projects: penv.ANON_PI_PROJECTS,
|
|
347
1331
|
image: penv.ANON_PI_IMAGE,
|
|
348
1332
|
llmDirect: penv.ANON_PI_LLM,
|
|
349
1333
|
xdgConfigHome: penv.XDG_CONFIG_HOME,
|
|
350
1334
|
dockerfilePath: shippedDockerfilePath(),
|
|
351
1335
|
webveilDockerfilePath: shippedWebveilDockerfilePath(),
|
|
352
|
-
sourceModels: penv.ANON_PI_SOURCE_MODELS,
|
|
353
|
-
piAgentDir: penv.PI_CODING_AGENT_DIR,
|
|
354
|
-
ephemeral: isTruthy(penv.ANON_PI_EPHEMERAL),
|
|
355
1336
|
};
|
|
356
1337
|
}
|
|
357
|
-
/** Whether an env-var string is set to a truthy value (1/true/yes, any case). */
|
|
358
|
-
function isTruthy(v) {
|
|
359
|
-
if (!v)
|
|
360
|
-
return false;
|
|
361
|
-
const s = v.trim().toLowerCase();
|
|
362
|
-
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
|
363
|
-
}
|
|
364
1338
|
/** The --help text (kept here so it is covered by the same module). */
|
|
365
|
-
export const HELP = `anon-pi -
|
|
1339
|
+
export const HELP = `anon-pi - run pi on anonymized, jailed machines (netcage: forced egress + one direct local model)
|
|
366
1340
|
|
|
367
1341
|
USAGE
|
|
368
|
-
anon-pi
|
|
369
|
-
anon-pi
|
|
1342
|
+
anon-pi MENU: pick a project (pi), a shell, or a new project
|
|
1343
|
+
anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
|
|
1344
|
+
anon-pi <project> <pi-args…> forward args to pi (headless/one-shot; no TTY needed)
|
|
1345
|
+
anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
|
|
1346
|
+
anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
|
|
1347
|
+
anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
|
|
1348
|
+
anon-pi init onboard: verify your proxy, capture your local model, pick an image
|
|
1349
|
+
anon-pi machine … manage machines (create / list / set-image / rm)
|
|
1350
|
+
anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
|
|
1351
|
+
anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
|
|
370
1352
|
|
|
371
|
-
|
|
372
|
-
|
|
1353
|
+
<project> a folder under the projects root (mounted at ${CONTAINER_PROJECTS_ROOT}; pi's cwd). \`.\` means
|
|
1354
|
+
the root itself (a scratch pi at ${CONTAINER_PROJECTS_ROOT}, ${CONTAINER_MOUNT_ROOT} for --mount, or ~).
|
|
373
1355
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1356
|
+
[--rm] throwaway container this run (the DEFAULT; deleted on exit).
|
|
1357
|
+
[--keep] leave the container KEPT so its filesystem survives (apt install,
|
|
1358
|
+
quit, re-enter). anon-pi finds it by netcage's managed label and
|
|
1359
|
+
\`netcage start\`s it on re-entry.
|
|
377
1360
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
home
|
|
385
|
-
|
|
386
|
-
--ephemeral (or ANON_PI_EPHEMERAL=1): mount NO writable state; pi writes to the
|
|
387
|
-
container's own --rm layer, gone on exit. Nothing writable touches the host,
|
|
388
|
-
no cleanup, no leftover-on-crash.
|
|
389
|
-
|
|
390
|
-
--fresh: delete this workdir's persistent state home first, so the (possibly
|
|
391
|
-
rebuilt) image's defaults + your imported models.json are re-seeded. Use it
|
|
392
|
-
after rebuilding your image to pick up new extensions/config.
|
|
393
|
-
|
|
394
|
-
import
|
|
395
|
-
Reads your host ~/.pi/agent/models.json, picks the provider whose baseUrl
|
|
396
|
-
serves ANON_PI_LLM, and writes JUST that provider to the canonical seed
|
|
397
|
-
(<ANON_PI_CONFIG>/models.json). No other provider's API keys, no sessions, no
|
|
398
|
-
identity. It SEEDS a fresh home; models you later add inside pi persist and are
|
|
399
|
-
never clobbered. Re-run with --force to overwrite the canonical seed.
|
|
1361
|
+
WHAT IT DOES
|
|
1362
|
+
Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy
|
|
1363
|
+
(fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE
|
|
1364
|
+
is an image + a persistent HOST home (bind-mounted at ${CONTAINER_HOME_ROOT}) holding your pi
|
|
1365
|
+
config, extensions, and conversations; the container is disposable, so \`--rm\`
|
|
1366
|
+
loses nothing. Files (projects) are global by default; conversations are
|
|
1367
|
+
per-machine. On a FRESH machine home the image's staged defaults + your
|
|
1368
|
+
models.json are seeded in once; after that pi owns the home. Requires \`netcage\`.
|
|
400
1369
|
|
|
401
1370
|
ENVIRONMENT
|
|
402
|
-
ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
|
|
403
|
-
Running anon-pi without it prints a ready-to-build
|
|
404
|
-
Dockerfile.pi recipe; see the README (Providing a pi image).
|
|
405
|
-
ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
|
|
406
1371
|
ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).
|
|
407
1372
|
No default: the proxy is what anonymizes, so it is never guessed.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
RESET A SESSION
|
|
414
|
-
anon-pi --fresh [WORKDIR] drop the session home and re-seed on this launch.
|
|
415
|
-
Or delete it by hand: rm -rf <ANON_PI_HOME>/state/<workdir-slug>/agent
|
|
1373
|
+
ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
|
|
1374
|
+
ANON_PI_IMAGE image with \`pi\` on PATH, used when a machine has no image set.
|
|
1375
|
+
No image yet? See the README (Providing a pi image).
|
|
1376
|
+
ANON_PI_HOME anon-pi workspace dir (default ~/.anon-pi; NOT under ~/.config)
|
|
1377
|
+
ANON_PI_PROJECTS projects root override (host dir mounted at ${CONTAINER_PROJECTS_ROOT})
|
|
416
1378
|
|
|
417
1379
|
PLATFORM
|
|
418
1380
|
Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
|