anon-pi 0.1.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/src/anon-pi.ts ADDED
@@ -0,0 +1,277 @@
1
+ // anon-pi: the PURE logic (no process spawning, no interactive I/O) so every
2
+ // decision is unit-testable. cli.ts wires this to the real filesystem + spawn.
3
+ //
4
+ // What anon-pi does (settled design):
5
+ // - ALWAYS seed a per-workdir writable copy of the canonical anon-pi config
6
+ // (~/.config/anon-pi/agent) into a per-session dir keyed by the workdir, and
7
+ // mount THAT as the container's pi global (PI_CODING_AGENT_DIR). The
8
+ // canonical config is only ever READ (at seed time), never mounted, so the
9
+ // container cannot mutate it.
10
+ // - Mount the workdir separately at /work (pi's cwd; the user's files land on
11
+ // the host). A user-supplied /work/.pi/ override is just pi's own
12
+ // project-over-global layering; anon-pi neither creates nor requires it.
13
+ // - Open exactly ONE direct hole (--allow-direct <ANON_PI_LLM>) so pi can reach
14
+ // a local model while all other egress stays forced through the proxy.
15
+ // - NEVER auto-populate the canonical seed: if it is absent, error and tell the
16
+ // user to populate it (their anon accounts / chosen skills / a valid
17
+ // trust.json that trusts /work). anon-pi does not synthesize pi's trust.json.
18
+ // - Session identity = the ABSOLUTE workdir path (hashed). Same folder resumes
19
+ // the same session config+state; reseed is manual (delete the session dir).
20
+
21
+ import {createHash} from 'node:crypto';
22
+ import {homedir} from 'node:os';
23
+ import {isAbsolute, join, resolve} from 'node:path';
24
+
25
+ /** The container path the workdir is mounted at (pi's cwd). */
26
+ export const CONTAINER_WORKDIR = '/work';
27
+
28
+ /**
29
+ * The DEFAULT container path the seeded pi config is mounted at (the pi global).
30
+ * Absolute and image-independent: both podman (the -v target) and pi (the
31
+ * PI_CODING_AGENT_DIR env) agree on it with no home-resolution guessing. A user
32
+ * who knows their image's home can override it (ANON_PI_AGENT_MOUNT) to the
33
+ * standard `~/.pi/agent` location, e.g. /root/.pi/agent for a root image.
34
+ */
35
+ export const DEFAULT_CONTAINER_AGENT_DIR = '/opt/pi-agent';
36
+
37
+ /** The pi env var that overrides its config dir (see pi config.ts getAgentDir). */
38
+ export const PI_AGENT_DIR_ENV = 'PI_CODING_AGENT_DIR';
39
+
40
+ /** Inputs resolved from the environment + argv, injected so this stays pure. */
41
+ export interface AnonPiEnv {
42
+ /** $HOME (or an override) used to derive default paths. */
43
+ home: string;
44
+ /** socks5h proxy URL. Default socks5h://127.0.0.1:9050. */
45
+ proxy?: string;
46
+ /** The anon-pi home dir. Default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi. */
47
+ anonPiHome?: string;
48
+ /** Override the canonical seed dir. Default <anonPiHome>/agent. */
49
+ configSeed?: string;
50
+ /** The container image that has `pi` on PATH. REQUIRED. */
51
+ image?: string;
52
+ /** The RFC1918/link-local IP[:port] of the local model. REQUIRED. */
53
+ llmDirect?: string;
54
+ /** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
55
+ xdgConfigHome?: string;
56
+ /**
57
+ * The ABSOLUTE container path to mount the seeded config at (and point pi's
58
+ * PI_CODING_AGENT_DIR at). Default /opt/pi-agent. Set it to your image's real
59
+ * `~/.pi/agent` (e.g. /root/.pi/agent) if you want the standard location.
60
+ * MUST be absolute: podman does not expand `~`, so a `~`-relative value would
61
+ * be mounted literally. Rejected if it starts with `~` or is not absolute.
62
+ */
63
+ agentMount?: string;
64
+ }
65
+
66
+ /** The fully-resolved run plan cli.ts executes. */
67
+ export interface RunPlan {
68
+ /** Absolute workdir on the host (mounted at /work). */
69
+ workdir: string;
70
+ /** The per-session seeded config dir on the host (mounted as the pi global). */
71
+ sessionAgentDir: string;
72
+ /** The canonical seed dir to copy FROM (read-only by convention). */
73
+ configSeed: string;
74
+ /** The absolute container path the session config is mounted at (== pi's config dir). */
75
+ agentMount: string;
76
+ /** True iff the session dir does not exist yet and must be seeded from configSeed. */
77
+ needsSeed: boolean;
78
+ /** The argv passed to `netcage` (after the `netcage` program name). */
79
+ netcageArgs: string[];
80
+ }
81
+
82
+ const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
83
+
84
+ /** A user-facing error whose message is meant to be printed verbatim (no stack). */
85
+ export class AnonPiError extends Error {}
86
+
87
+ /**
88
+ * Resolve the container agent-mount path: ANON_PI_AGENT_MOUNT or the default.
89
+ * It MUST be an absolute container path, because it is BOTH the podman `-v`
90
+ * target AND pi's PI_CODING_AGENT_DIR, and podman (unlike pi) does not expand
91
+ * `~`. A `~`-relative or relative value is rejected loudly rather than silently
92
+ * mounted at a literal `~` directory or a cwd-relative path.
93
+ */
94
+ export function resolveAgentMount(env: AnonPiEnv): string {
95
+ const raw =
96
+ env.agentMount && env.agentMount.trim() !== ''
97
+ ? env.agentMount.trim()
98
+ : DEFAULT_CONTAINER_AGENT_DIR;
99
+ if (raw.startsWith('~')) {
100
+ throw new AnonPiError(
101
+ `anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\`. ` +
102
+ 'podman does not expand `~`; use the concrete home, e.g. /root/.pi/agent.',
103
+ );
104
+ }
105
+ if (!raw.startsWith('/')) {
106
+ throw new AnonPiError(
107
+ `anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\` (it is both the mount target and pi's config dir).`,
108
+ );
109
+ }
110
+ return raw;
111
+ }
112
+
113
+ /** Resolve the anon-pi home dir (holds the canonical seed + per-session state). */
114
+ export function resolveAnonPiHome(env: AnonPiEnv): string {
115
+ if (env.anonPiHome) return resolve(env.anonPiHome);
116
+ const base =
117
+ env.xdgConfigHome && env.xdgConfigHome.trim() !== ''
118
+ ? env.xdgConfigHome
119
+ : join(env.home, '.config');
120
+ return join(base, 'anon-pi');
121
+ }
122
+
123
+ /** The canonical seed dir (copied FROM, never mounted). */
124
+ export function resolveConfigSeed(env: AnonPiEnv): string {
125
+ if (env.configSeed) return resolve(env.configSeed);
126
+ return join(resolveAnonPiHome(env), 'agent');
127
+ }
128
+
129
+ /**
130
+ * Session id = a stable short hash of the ABSOLUTE workdir path, so re-running
131
+ * anon-pi on the same folder resumes the same session config+state, and moving
132
+ * the folder starts a new session (documented, accepted).
133
+ */
134
+ export function sessionId(absWorkdir: string): string {
135
+ return createHash('sha256').update(absWorkdir).digest('hex').slice(0, 16);
136
+ }
137
+
138
+ /** The per-session seeded config dir on the host for a given workdir. */
139
+ export function sessionAgentDir(env: AnonPiEnv, absWorkdir: string): string {
140
+ return join(
141
+ resolveAnonPiHome(env),
142
+ 'sessions',
143
+ sessionId(absWorkdir),
144
+ 'agent',
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Build the run plan from the environment + the (optional) workdir arg. PURE: it
150
+ * resolves paths and composes the netcage argv, and reports whether a seed copy
151
+ * is needed, but performs NO filesystem writes or spawns. It THROWS AnonPiError
152
+ * for the two hard preconditions (missing image, missing llm) so the required
153
+ * inputs fail loud; the missing-SEED check is left to cli.ts (it needs a real
154
+ * `existsSync`), but `needsSeed` is derived from the injected `seedExists`.
155
+ */
156
+ export function buildRunPlan(
157
+ env: AnonPiEnv,
158
+ workdirArg: string | undefined,
159
+ seedExists: (dir: string) => boolean,
160
+ sessionExists: (dir: string) => boolean,
161
+ ): RunPlan {
162
+ if (!env.image || env.image.trim() === '') {
163
+ throw new AnonPiError(
164
+ 'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH (e.g. ANON_PI_IMAGE=your/pi-image:tag).',
165
+ );
166
+ }
167
+ if (!env.llmDirect || env.llmDirect.trim() === '') {
168
+ throw new AnonPiError(
169
+ '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.',
170
+ );
171
+ }
172
+
173
+ const home = env.home;
174
+ if (!home || home.trim() === '') {
175
+ throw new AnonPiError('anon-pi: could not resolve HOME.');
176
+ }
177
+
178
+ const raw =
179
+ workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
180
+ const workdir = isAbsolute(raw) ? raw : resolve(raw);
181
+
182
+ const configSeed = resolveConfigSeed(env);
183
+ if (!seedExists(configSeed)) {
184
+ throw new AnonPiError(
185
+ `anon-pi: canonical config not found at ${configSeed}.\n` +
186
+ 'anon-pi never populates it for you. Create it yourself with the pi config you want\n' +
187
+ '(anon accounts, chosen models/skills, and a trust.json that trusts /work), e.g.:\n' +
188
+ ` mkdir -p ${configSeed}\n` +
189
+ ` cp -a ~/.pi/agent/. ${configSeed}/ # then remove any identity you do not want anonymized\n` +
190
+ 'See the README (Populating the seed) for the trust.json requirement.',
191
+ );
192
+ }
193
+
194
+ const sessionDir = sessionAgentDir(env, workdir);
195
+ const needsSeed = !sessionExists(sessionDir);
196
+
197
+ const agentMount = resolveAgentMount(env);
198
+ const proxy =
199
+ env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
200
+
201
+ const netcageArgs = [
202
+ 'run',
203
+ '--proxy',
204
+ proxy,
205
+ '--allow-direct',
206
+ env.llmDirect,
207
+ '-it',
208
+ '-v',
209
+ workdir, // netcage defaults a target-less -v to /work and cwd to /work
210
+ '-v',
211
+ `${sessionDir}:${agentMount}`,
212
+ '-e',
213
+ `${PI_AGENT_DIR_ENV}=${agentMount}`,
214
+ env.image,
215
+ 'pi',
216
+ ];
217
+
218
+ return {
219
+ workdir,
220
+ sessionAgentDir: sessionDir,
221
+ configSeed,
222
+ agentMount,
223
+ needsSeed,
224
+ netcageArgs,
225
+ };
226
+ }
227
+
228
+ /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
229
+ export function envFromProcess(
230
+ penv: Record<string, string | undefined>,
231
+ ): AnonPiEnv {
232
+ return {
233
+ home: penv.HOME ?? homedir(),
234
+ proxy: penv.ANON_PI_PROXY,
235
+ anonPiHome: penv.ANON_PI_HOME,
236
+ configSeed: penv.ANON_PI_CONFIG,
237
+ image: penv.ANON_PI_IMAGE,
238
+ llmDirect: penv.ANON_PI_LLM,
239
+ xdgConfigHome: penv.XDG_CONFIG_HOME,
240
+ agentMount: penv.ANON_PI_AGENT_MOUNT,
241
+ };
242
+ }
243
+
244
+ /** The --help text (kept here so it is covered by the same module). */
245
+ export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
246
+
247
+ USAGE
248
+ anon-pi [WORKDIR]
249
+
250
+ WORKDIR the host folder pi works in (mounted at /work). Defaults to the
251
+ current directory. The session config+state is keyed to this folder.
252
+
253
+ WHAT IT DOES
254
+ Seeds a per-workdir writable copy of your canonical anon-pi config into
255
+ ~/.config/anon-pi/sessions/<hash>/agent, mounts it as pi's global config
256
+ (${PI_AGENT_DIR_ENV}=<mount>, default ${DEFAULT_CONTAINER_AGENT_DIR}), mounts WORKDIR at
257
+ ${CONTAINER_WORKDIR}, opens ONE direct hole to your local model, and runs pi with all other
258
+ egress forced through the socks5h proxy, fail-closed. Requires \`netcage\`.
259
+
260
+ ENVIRONMENT
261
+ ANON_PI_IMAGE (required) image with \`pi\` on PATH
262
+ ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
263
+ ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
264
+ ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
265
+ ANON_PI_CONFIG canonical seed dir (default <ANON_PI_HOME>/agent)
266
+ ANON_PI_AGENT_MOUNT absolute container path for pi's config (default
267
+ ${DEFAULT_CONTAINER_AGENT_DIR}; set to your image's ~/.pi/agent, e.g. /root/.pi/agent)
268
+
269
+ RESEED
270
+ Reseed is manual: delete the session dir, e.g.
271
+ rm -rf ~/.config/anon-pi/sessions/<hash>/agent
272
+ and the next run re-seeds it from the canonical config.
273
+
274
+ PLATFORM
275
+ Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
276
+ inside a Linux VM, where --allow-direct to a LAN model is VM-boundary-sensitive.
277
+ `;
package/src/cli.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // anon-pi CLI: resolve the run plan (pure), do the one filesystem side-effect
3
+ // (seed the session config if absent), then exec `netcage run ...` with inherited
4
+ // stdio so the interactive pi session (-it) passes through the terminal cleanly.
5
+
6
+ import {cpSync, existsSync, mkdirSync} from 'node:fs';
7
+ import {spawnSync} from 'node:child_process';
8
+ import {dirname} from 'node:path';
9
+ import {AnonPiError, buildRunPlan, envFromProcess, HELP} from './anon-pi.js';
10
+
11
+ function main(argv: string[]): number {
12
+ const args = argv.slice(2);
13
+
14
+ if (args.includes('--help') || args.includes('-h')) {
15
+ process.stdout.write(HELP);
16
+ return 0;
17
+ }
18
+
19
+ // The only positional is the optional workdir. Reject stray flags so a typo
20
+ // (e.g. --allow-direct) is not silently swallowed: anon-pi owns the netcage
21
+ // argv, extra flags are not passed through.
22
+ const positionals = args.filter((a) => !a.startsWith('-'));
23
+ const flags = args.filter((a) => a.startsWith('-'));
24
+ if (flags.length > 0) {
25
+ process.stderr.write(
26
+ `anon-pi: unknown option(s): ${flags.join(' ')}\nRun \`anon-pi --help\`.\n`,
27
+ );
28
+ return 2;
29
+ }
30
+ if (positionals.length > 1) {
31
+ process.stderr.write(
32
+ 'anon-pi: too many arguments (expected at most one WORKDIR).\nRun `anon-pi --help`.\n',
33
+ );
34
+ return 2;
35
+ }
36
+
37
+ const env = envFromProcess(process.env);
38
+
39
+ let plan;
40
+ try {
41
+ plan = buildRunPlan(env, positionals[0], existsSync, existsSync);
42
+ } catch (e) {
43
+ if (e instanceof AnonPiError) {
44
+ process.stderr.write(e.message + '\n');
45
+ return 1;
46
+ }
47
+ throw e;
48
+ }
49
+
50
+ // Fail loud if netcage is not installed, before we mutate anything.
51
+ if (!hasNetcage()) {
52
+ process.stderr.write(
53
+ 'anon-pi: `netcage` not found on PATH. anon-pi is a launcher for netcage; install it first\n' +
54
+ '(https://github.com/wighawag/netcage). Linux only.\n',
55
+ );
56
+ return 1;
57
+ }
58
+
59
+ // The one side-effect: seed the per-session config from the canonical seed the
60
+ // FIRST time this workdir is used. Reuse-if-present, seed-if-absent.
61
+ if (plan.needsSeed) {
62
+ mkdirSync(dirname(plan.sessionAgentDir), {recursive: true});
63
+ cpSync(plan.configSeed, plan.sessionAgentDir, {recursive: true});
64
+ process.stderr.write(
65
+ `anon-pi: seeded session config -> ${plan.sessionAgentDir}\n`,
66
+ );
67
+ }
68
+
69
+ // Ensure the workdir exists (a fresh named folder is fine).
70
+ mkdirSync(plan.workdir, {recursive: true});
71
+
72
+ // Hand off to netcage with inherited stdio so -it is a real interactive TTY.
73
+ const res = spawnSync('netcage', plan.netcageArgs, {stdio: 'inherit'});
74
+ if (res.error) {
75
+ process.stderr.write(
76
+ `anon-pi: failed to run netcage: ${res.error.message}\n`,
77
+ );
78
+ return 1;
79
+ }
80
+ // Propagate netcage's exit code (which itself propagates the tool's).
81
+ return res.status ?? 1;
82
+ }
83
+
84
+ function hasNetcage(): boolean {
85
+ const which = spawnSync(
86
+ process.platform === 'win32' ? 'where' : 'command',
87
+ ['-v', 'netcage'],
88
+ {
89
+ stdio: 'ignore',
90
+ shell: process.platform !== 'win32',
91
+ },
92
+ );
93
+ if (which.status === 0) return true;
94
+ // Fallback: try running it harmlessly.
95
+ const probe = spawnSync('netcage', ['--help'], {stdio: 'ignore'});
96
+ return !probe.error;
97
+ }
98
+
99
+ process.exit(main(process.argv));
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Programmatic entry: the pure planning logic is reusable/testable. The CLI is
2
+ // dist/cli.js (the `anon-pi` bin); this index is the library surface.
3
+ export * from './anon-pi.js';