anon-pi 0.1.0 → 0.2.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 +20 -2
- package/README.md +47 -24
- package/dist/anon-pi.d.ts +88 -44
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +176 -80
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +75 -14
- package/dist/cli.js.map +1 -1
- package/examples/Dockerfile.pi-webveil +94 -0
- package/package.json +3 -2
- package/src/anon-pi.ts +224 -106
- package/src/cli.ts +110 -17
package/src/anon-pi.ts
CHANGED
|
@@ -18,24 +18,37 @@
|
|
|
18
18
|
// - Session identity = the ABSOLUTE workdir path (hashed). Same folder resumes
|
|
19
19
|
// the same session config+state; reseed is manual (delete the session dir).
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {existsSync} from 'node:fs';
|
|
22
22
|
import {homedir} from 'node:os';
|
|
23
|
-
import {isAbsolute, join, resolve} from 'node:path';
|
|
23
|
+
import {dirname, isAbsolute, join, resolve} from 'node:path';
|
|
24
|
+
import {fileURLToPath} from 'node:url';
|
|
24
25
|
|
|
25
26
|
/** The container path the workdir is mounted at (pi's cwd). */
|
|
26
27
|
export const CONTAINER_WORKDIR = '/work';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* Where the seed (just models.json) is mounted read-only in the container. The
|
|
31
|
+
* run command copies models.json FROM here INTO the container's own
|
|
32
|
+
* ~/.pi/agent, so it LAYERS onto the image's config (extensions/skills the image
|
|
33
|
+
* installed survive) instead of replacing it. This is why we mount+copy rather
|
|
34
|
+
* than mount-as-agent-dir: mounting over ~/.pi/agent would shadow the image's
|
|
35
|
+
* extensions.
|
|
34
36
|
*/
|
|
35
|
-
export const
|
|
37
|
+
export const CONTAINER_SEED_DIR = '/anon-pi-seed';
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The container command: copy the seeded models.json into pi's own config dir
|
|
41
|
+
* (creating it if absent), then exec pi. `$HOME/.pi/agent` is pi's default
|
|
42
|
+
* config dir when PI_CODING_AGENT_DIR is unset, i.e. exactly where the image
|
|
43
|
+
* installed pi + any extensions, so the copy augments rather than shadows.
|
|
44
|
+
*/
|
|
45
|
+
export const CONTAINER_RUN_CMD =
|
|
46
|
+
`mkdir -p "$HOME/.pi/agent" && ` +
|
|
47
|
+
`cp ${CONTAINER_SEED_DIR}/models.json "$HOME/.pi/agent/models.json" && ` +
|
|
48
|
+
`exec pi`;
|
|
49
|
+
|
|
50
|
+
/** The single file the seed carries: pi's model/provider registry. */
|
|
51
|
+
export const MODELS_FILE = 'models.json';
|
|
39
52
|
|
|
40
53
|
/** Inputs resolved from the environment + argv, injected so this stays pure. */
|
|
41
54
|
export interface AnonPiEnv {
|
|
@@ -54,27 +67,24 @@ export interface AnonPiEnv {
|
|
|
54
67
|
/** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
|
|
55
68
|
xdgConfigHome?: string;
|
|
56
69
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* be mounted literally. Rejected if it starts with `~` or is not absolute.
|
|
70
|
+
* Absolute path to the Dockerfile.pi that ships with anon-pi, used only to
|
|
71
|
+
* make the missing-image error's build command concrete. cli.ts resolves it
|
|
72
|
+
* from import.meta.url; when absent the message falls back to a bare
|
|
73
|
+
* `Dockerfile.pi`.
|
|
62
74
|
*/
|
|
63
|
-
|
|
75
|
+
dockerfilePath?: string;
|
|
76
|
+
/** `import` source models.json override (ANON_PI_SOURCE_MODELS). */
|
|
77
|
+
sourceModels?: string;
|
|
78
|
+
/** The host pi agent dir override (PI_CODING_AGENT_DIR), used to find models.json. */
|
|
79
|
+
piAgentDir?: string;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
/** The fully-resolved run plan cli.ts executes. */
|
|
67
83
|
export interface RunPlan {
|
|
68
84
|
/** Absolute workdir on the host (mounted at /work). */
|
|
69
85
|
workdir: string;
|
|
70
|
-
/** The
|
|
71
|
-
sessionAgentDir: string;
|
|
72
|
-
/** The canonical seed dir to copy FROM (read-only by convention). */
|
|
86
|
+
/** The seed dir on the host (holds models.json), mounted read-only at /anon-pi-seed. */
|
|
73
87
|
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
88
|
/** The argv passed to `netcage` (after the `netcage` program name). */
|
|
79
89
|
netcageArgs: string[];
|
|
80
90
|
}
|
|
@@ -84,33 +94,7 @@ const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
|
|
|
84
94
|
/** A user-facing error whose message is meant to be printed verbatim (no stack). */
|
|
85
95
|
export class AnonPiError extends Error {}
|
|
86
96
|
|
|
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). */
|
|
97
|
+
/** Resolve the anon-pi home dir (holds the seed). */
|
|
114
98
|
export function resolveAnonPiHome(env: AnonPiEnv): string {
|
|
115
99
|
if (env.anonPiHome) return resolve(env.anonPiHome);
|
|
116
100
|
const base =
|
|
@@ -120,48 +104,152 @@ export function resolveAnonPiHome(env: AnonPiEnv): string {
|
|
|
120
104
|
return join(base, 'anon-pi');
|
|
121
105
|
}
|
|
122
106
|
|
|
123
|
-
/** The
|
|
107
|
+
/** The seed dir (holds models.json), mounted read-only into the container. */
|
|
124
108
|
export function resolveConfigSeed(env: AnonPiEnv): string {
|
|
125
109
|
if (env.configSeed) return resolve(env.configSeed);
|
|
126
110
|
return join(resolveAnonPiHome(env), 'agent');
|
|
127
111
|
}
|
|
128
112
|
|
|
129
113
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
114
|
+
* Normalise a proxy-less host:port key from an ANON_PI_LLM value or a provider
|
|
115
|
+
* baseUrl, so `192.168.1.150:8080` matches `http://192.168.1.150:8080/v1`.
|
|
116
|
+
* Returns `host` (no port) or `host:port`, lowercased, scheme/path stripped.
|
|
117
|
+
*/
|
|
118
|
+
export function hostPortKey(value: string): string {
|
|
119
|
+
let v = value.trim();
|
|
120
|
+
const scheme = v.indexOf('://');
|
|
121
|
+
if (scheme >= 0) v = v.slice(scheme + 3);
|
|
122
|
+
v = v.split('/')[0]; // drop path (/v1, ...)
|
|
123
|
+
v = v.replace(/^[^@]*@/, ''); // drop any user:pass@
|
|
124
|
+
return v.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A pi provider entry (as it appears under models.json `providers[name]`). Only
|
|
129
|
+
* the fields anon-pi reads are typed; the rest is preserved verbatim.
|
|
133
130
|
*/
|
|
134
|
-
export
|
|
135
|
-
|
|
131
|
+
export interface PiProvider {
|
|
132
|
+
baseUrl?: string;
|
|
133
|
+
apiKey?: string;
|
|
134
|
+
api?: string;
|
|
135
|
+
models?: unknown[];
|
|
136
|
+
[k: string]: unknown;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
/**
|
|
139
|
-
export
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
/** Parsed shape of a pi models.json (only `providers` is required). */
|
|
140
|
+
export interface PiModelsFile {
|
|
141
|
+
providers?: Record<string, PiProvider>;
|
|
142
|
+
[k: string]: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The result of picking the ANON_PI_LLM provider out of a host models.json. */
|
|
146
|
+
export interface ImportResult {
|
|
147
|
+
/** The provider key (e.g. "llamacpp-router"). */
|
|
148
|
+
name: string;
|
|
149
|
+
/** The barebones models.json to write (just the matched provider). */
|
|
150
|
+
models: PiModelsFile;
|
|
151
|
+
/** True if the matched provider's apiKey looks like a REAL secret (warn). */
|
|
152
|
+
apiKeyLooksReal: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** apiKey values that are NOT real secrets (safe to carry into the seed). */
|
|
156
|
+
const BENIGN_API_KEYS = new Set(['', 'none', 'ollama', 'no-key', 'local']);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* PURE: given a parsed host models.json and the ANON_PI_LLM value, select the
|
|
160
|
+
* provider whose baseUrl points at that host:port and return a barebones
|
|
161
|
+
* models.json carrying ONLY that provider (verbatim, with its models). Throws
|
|
162
|
+
* AnonPiError if nothing matches. Carries no other provider (so etherplay /
|
|
163
|
+
* google / paid API keys never enter the seed).
|
|
164
|
+
*/
|
|
165
|
+
export function pickProviderForLlm(
|
|
166
|
+
hostModels: PiModelsFile,
|
|
167
|
+
llmDirect: string,
|
|
168
|
+
): ImportResult {
|
|
169
|
+
const providers = hostModels.providers ?? {};
|
|
170
|
+
const want = hostPortKey(llmDirect);
|
|
171
|
+
|
|
172
|
+
const matches: string[] = [];
|
|
173
|
+
for (const [name, p] of Object.entries(providers)) {
|
|
174
|
+
if (!p || typeof p !== 'object' || !p.baseUrl) continue;
|
|
175
|
+
if (hostPortKey(p.baseUrl) === want) matches.push(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (matches.length === 0) {
|
|
179
|
+
const known = Object.entries(providers)
|
|
180
|
+
.filter(([, p]) => p && p.baseUrl)
|
|
181
|
+
.map(([n, p]) => ` ${n}: ${p.baseUrl}`)
|
|
182
|
+
.join('\n');
|
|
183
|
+
throw new AnonPiError(
|
|
184
|
+
`anon-pi import: no provider in your host models.json points at ANON_PI_LLM (${want}).\n` +
|
|
185
|
+
(known
|
|
186
|
+
? `Providers found:\n${known}\n`
|
|
187
|
+
: 'No providers with a baseUrl were found.\n') +
|
|
188
|
+
'Set ANON_PI_LLM to the host:port of a provider above, or add that provider to pi first.',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const name = matches[0];
|
|
193
|
+
const provider = providers[name];
|
|
194
|
+
const key = (provider.apiKey ?? '').trim().toLowerCase();
|
|
195
|
+
const apiKeyLooksReal = !BENIGN_API_KEYS.has(key);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
name,
|
|
199
|
+
models: {providers: {[name]: provider}},
|
|
200
|
+
apiKeyLooksReal,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The default host models.json path `import` reads FROM. Overridable via
|
|
206
|
+
* ANON_PI_SOURCE_MODELS; defaults to the real pi config (~/.pi/agent/models.json
|
|
207
|
+
* under the container-less host HOME, or PI_CODING_AGENT_DIR if the user set it).
|
|
208
|
+
*/
|
|
209
|
+
export function resolveSourceModelsPath(env: AnonPiEnv): string {
|
|
210
|
+
if (env.sourceModels && env.sourceModels.trim() !== '') {
|
|
211
|
+
return resolve(env.sourceModels);
|
|
212
|
+
}
|
|
213
|
+
const agentDir =
|
|
214
|
+
env.piAgentDir && env.piAgentDir.trim() !== ''
|
|
215
|
+
? env.piAgentDir
|
|
216
|
+
: join(env.home, '.pi', 'agent');
|
|
217
|
+
return join(agentDir, MODELS_FILE);
|
|
146
218
|
}
|
|
147
219
|
|
|
148
220
|
/**
|
|
149
221
|
* Build the run plan from the environment + the (optional) workdir arg. PURE: it
|
|
150
|
-
* resolves paths and composes the netcage argv,
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
222
|
+
* resolves paths and composes the netcage argv, performing NO filesystem writes
|
|
223
|
+
* or spawns. It THROWS AnonPiError for the hard preconditions (missing image,
|
|
224
|
+
* missing llm, missing seed models.json) so the required inputs fail loud.
|
|
225
|
+
*
|
|
226
|
+
* The seed (models.json) is mounted READ-ONLY at /anon-pi-seed and copied into
|
|
227
|
+
* the container's own ~/.pi/agent by the run command, so it LAYERS onto the
|
|
228
|
+
* image's config (image-installed extensions/skills survive) rather than
|
|
229
|
+
* shadowing it.
|
|
155
230
|
*/
|
|
156
231
|
export function buildRunPlan(
|
|
157
232
|
env: AnonPiEnv,
|
|
158
233
|
workdirArg: string | undefined,
|
|
159
|
-
|
|
160
|
-
sessionExists: (dir: string) => boolean,
|
|
234
|
+
seedModelsExists: (modelsJsonPath: string) => boolean,
|
|
161
235
|
): RunPlan {
|
|
162
236
|
if (!env.image || env.image.trim() === '') {
|
|
237
|
+
// dockerfilePath is injected (cli.ts resolves the shipped Dockerfile.pi via
|
|
238
|
+
// import.meta.url; tests pass a fixed path). Every command is emitted
|
|
239
|
+
// flush-left so it copy-pastes cleanly: an indented heredoc would bake
|
|
240
|
+
// leading spaces into the Dockerfile and break the EOF terminator, so we
|
|
241
|
+
// point at the shipped file instead of printing a heredoc.
|
|
242
|
+
const df = env.dockerfilePath ?? 'Dockerfile.pi';
|
|
163
243
|
throw new AnonPiError(
|
|
164
|
-
'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH
|
|
244
|
+
'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH.\n' +
|
|
245
|
+
'\n' +
|
|
246
|
+
'No image yet? A ready Dockerfile.pi ships with anon-pi (it installs the\n' +
|
|
247
|
+
'official @earendil-works/pi-coding-agent). Build it and point at it:\n' +
|
|
248
|
+
'\n' +
|
|
249
|
+
`podman build -t localhost/anon-pi-pi:latest -f "${df}" "$(dirname "${df}")"\n` +
|
|
250
|
+
'export ANON_PI_IMAGE=localhost/anon-pi-pi:latest\n' +
|
|
251
|
+
'\n' +
|
|
252
|
+
'See the README (Providing a pi image) for details and a community-image note.',
|
|
165
253
|
);
|
|
166
254
|
}
|
|
167
255
|
if (!env.llmDirect || env.llmDirect.trim() === '') {
|
|
@@ -180,21 +268,21 @@ export function buildRunPlan(
|
|
|
180
268
|
const workdir = isAbsolute(raw) ? raw : resolve(raw);
|
|
181
269
|
|
|
182
270
|
const configSeed = resolveConfigSeed(env);
|
|
183
|
-
|
|
271
|
+
const modelsJson = join(configSeed, MODELS_FILE);
|
|
272
|
+
if (!seedModelsExists(modelsJson)) {
|
|
184
273
|
throw new AnonPiError(
|
|
185
|
-
`anon-pi:
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
'
|
|
274
|
+
`anon-pi: no seed models.json at ${modelsJson}.\n` +
|
|
275
|
+
'\n' +
|
|
276
|
+
'anon-pi never populates it for you. Generate it from your local model:\n' +
|
|
277
|
+
'\n' +
|
|
278
|
+
'anon-pi import\n' +
|
|
279
|
+
'\n' +
|
|
280
|
+
'`import` reads your host ~/.pi/agent/models.json, picks the provider that\n' +
|
|
281
|
+
'serves ANON_PI_LLM, and writes just that provider here (no auth for other\n' +
|
|
282
|
+
'providers, no sessions, no identity). See the README (Populating the seed).',
|
|
191
283
|
);
|
|
192
284
|
}
|
|
193
285
|
|
|
194
|
-
const sessionDir = sessionAgentDir(env, workdir);
|
|
195
|
-
const needsSeed = !sessionExists(sessionDir);
|
|
196
|
-
|
|
197
|
-
const agentMount = resolveAgentMount(env);
|
|
198
286
|
const proxy =
|
|
199
287
|
env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
|
|
200
288
|
|
|
@@ -208,23 +296,45 @@ export function buildRunPlan(
|
|
|
208
296
|
'-v',
|
|
209
297
|
workdir, // netcage defaults a target-less -v to /work and cwd to /work
|
|
210
298
|
'-v',
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
|
|
299
|
+
// Mount the seed READ-ONLY at a neutral path; the run command copies
|
|
300
|
+
// models.json into the container's own ~/.pi/agent so image extensions
|
|
301
|
+
// survive (see CONTAINER_RUN_CMD).
|
|
302
|
+
`${configSeed}:${CONTAINER_SEED_DIR}:ro`,
|
|
214
303
|
env.image,
|
|
215
|
-
'
|
|
304
|
+
'sh',
|
|
305
|
+
'-c',
|
|
306
|
+
CONTAINER_RUN_CMD,
|
|
216
307
|
];
|
|
217
308
|
|
|
218
309
|
return {
|
|
219
310
|
workdir,
|
|
220
|
-
sessionAgentDir: sessionDir,
|
|
221
311
|
configSeed,
|
|
222
|
-
agentMount,
|
|
223
|
-
needsSeed,
|
|
224
312
|
netcageArgs,
|
|
225
313
|
};
|
|
226
314
|
}
|
|
227
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Absolute path to the Dockerfile.pi that ships with anon-pi, resolved from this
|
|
318
|
+
* module's location (package root, one level up from dist/anon-pi.js), or
|
|
319
|
+
* undefined if it cannot be found. Used only to make the missing-image error's
|
|
320
|
+
* build command concrete.
|
|
321
|
+
*/
|
|
322
|
+
export function shippedDockerfilePath(): string | undefined {
|
|
323
|
+
try {
|
|
324
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
325
|
+
// dist/anon-pi.js -> ../Dockerfile.pi; also try alongside for safety.
|
|
326
|
+
for (const p of [
|
|
327
|
+
join(here, '..', 'Dockerfile.pi'),
|
|
328
|
+
join(here, 'Dockerfile.pi'),
|
|
329
|
+
]) {
|
|
330
|
+
if (existsSync(p)) return p;
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// import.meta.url unavailable (e.g. some test bundlers): fall through.
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
228
338
|
/** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
|
|
229
339
|
export function envFromProcess(
|
|
230
340
|
penv: Record<string, string | undefined>,
|
|
@@ -237,7 +347,9 @@ export function envFromProcess(
|
|
|
237
347
|
image: penv.ANON_PI_IMAGE,
|
|
238
348
|
llmDirect: penv.ANON_PI_LLM,
|
|
239
349
|
xdgConfigHome: penv.XDG_CONFIG_HOME,
|
|
240
|
-
|
|
350
|
+
dockerfilePath: shippedDockerfilePath(),
|
|
351
|
+
sourceModels: penv.ANON_PI_SOURCE_MODELS,
|
|
352
|
+
piAgentDir: penv.PI_CODING_AGENT_DIR,
|
|
241
353
|
};
|
|
242
354
|
}
|
|
243
355
|
|
|
@@ -245,31 +357,37 @@ export function envFromProcess(
|
|
|
245
357
|
export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
|
|
246
358
|
|
|
247
359
|
USAGE
|
|
248
|
-
anon-pi [WORKDIR]
|
|
360
|
+
anon-pi [WORKDIR] launch pi jailed, working in WORKDIR (default: cwd)
|
|
361
|
+
anon-pi import write the seed models.json from your local model
|
|
249
362
|
|
|
250
|
-
WORKDIR the host folder pi works in (mounted at
|
|
251
|
-
|
|
363
|
+
WORKDIR the host folder pi works in (mounted at ${CONTAINER_WORKDIR}; pi's cwd). Files pi
|
|
364
|
+
writes there land on the host.
|
|
252
365
|
|
|
253
366
|
WHAT IT DOES
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
367
|
+
Runs pi inside netcage with all web/DNS egress forced through the socks5h
|
|
368
|
+
proxy (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM).
|
|
369
|
+
The seed models.json is mounted read-only and COPIED into the container's own
|
|
370
|
+
~/.pi/agent at start, so it layers onto the image's config: extensions and
|
|
371
|
+
skills you baked into the image survive. Requires \`netcage\`.
|
|
372
|
+
|
|
373
|
+
import
|
|
374
|
+
Reads your host ~/.pi/agent/models.json, picks the provider whose baseUrl
|
|
375
|
+
serves ANON_PI_LLM, and writes JUST that provider to the seed
|
|
376
|
+
(<ANON_PI_CONFIG>/models.json). No other provider's API keys, no sessions, no
|
|
377
|
+
identity. Re-run with --force to overwrite an existing seed.
|
|
259
378
|
|
|
260
379
|
ENVIRONMENT
|
|
261
|
-
ANON_PI_IMAGE (required) image with \`pi\` on PATH
|
|
380
|
+
ANON_PI_IMAGE (required for run) image with \`pi\` on PATH. No image yet?
|
|
381
|
+
Running anon-pi without it prints a ready-to-build
|
|
382
|
+
Dockerfile.pi recipe; see the README (Providing a pi image).
|
|
262
383
|
ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
|
|
263
384
|
ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
|
|
264
385
|
ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
|
|
265
|
-
ANON_PI_CONFIG
|
|
266
|
-
|
|
267
|
-
${DEFAULT_CONTAINER_AGENT_DIR}; set to your image's ~/.pi/agent, e.g. /root/.pi/agent)
|
|
386
|
+
ANON_PI_CONFIG seed dir holding models.json (default <ANON_PI_HOME>/agent)
|
|
387
|
+
ANON_PI_SOURCE_MODELS (import) host models.json to read (default ~/.pi/agent/models.json)
|
|
268
388
|
|
|
269
389
|
RESEED
|
|
270
|
-
|
|
271
|
-
rm -rf ~/.config/anon-pi/sessions/<hash>/agent
|
|
272
|
-
and the next run re-seeds it from the canonical config.
|
|
390
|
+
anon-pi import --force regenerates the seed models.json.
|
|
273
391
|
|
|
274
392
|
PLATFORM
|
|
275
393
|
Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
|
package/src/cli.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// anon-pi CLI
|
|
3
|
-
//
|
|
4
|
-
// stdio so
|
|
2
|
+
// anon-pi CLI. Two commands:
|
|
3
|
+
// anon-pi [WORKDIR] resolve the run plan (pure) and exec `netcage run ...`
|
|
4
|
+
// with inherited stdio (so -it is a real interactive TTY).
|
|
5
|
+
// The seed models.json is mounted read-only and copied
|
|
6
|
+
// into the container's ~/.pi/agent by the run command, so
|
|
7
|
+
// it layers onto the image's config (extensions survive).
|
|
8
|
+
// anon-pi import generate the seed models.json from the host models.json,
|
|
9
|
+
// carrying only the provider that serves ANON_PI_LLM.
|
|
5
10
|
|
|
6
|
-
import {
|
|
11
|
+
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
|
7
12
|
import {spawnSync} from 'node:child_process';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
13
|
+
import {join} from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
AnonPiError,
|
|
16
|
+
buildRunPlan,
|
|
17
|
+
envFromProcess,
|
|
18
|
+
HELP,
|
|
19
|
+
MODELS_FILE,
|
|
20
|
+
pickProviderForLlm,
|
|
21
|
+
resolveConfigSeed,
|
|
22
|
+
resolveSourceModelsPath,
|
|
23
|
+
type PiModelsFile,
|
|
24
|
+
} from './anon-pi.js';
|
|
10
25
|
|
|
11
26
|
function main(argv: string[]): number {
|
|
12
27
|
const args = argv.slice(2);
|
|
@@ -16,6 +31,16 @@ function main(argv: string[]): number {
|
|
|
16
31
|
return 0;
|
|
17
32
|
}
|
|
18
33
|
|
|
34
|
+
// Subcommand dispatch: the first bare token may be `import`.
|
|
35
|
+
if (args[0] === 'import') {
|
|
36
|
+
return runImport(args.slice(1));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return runLaunch(args);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- anon-pi [WORKDIR] : launch pi jailed -----------------------------------
|
|
43
|
+
function runLaunch(args: string[]): number {
|
|
19
44
|
// The only positional is the optional workdir. Reject stray flags so a typo
|
|
20
45
|
// (e.g. --allow-direct) is not silently swallowed: anon-pi owns the netcage
|
|
21
46
|
// argv, extra flags are not passed through.
|
|
@@ -38,7 +63,7 @@ function main(argv: string[]): number {
|
|
|
38
63
|
|
|
39
64
|
let plan;
|
|
40
65
|
try {
|
|
41
|
-
plan = buildRunPlan(env, positionals[0], existsSync
|
|
66
|
+
plan = buildRunPlan(env, positionals[0], existsSync);
|
|
42
67
|
} catch (e) {
|
|
43
68
|
if (e instanceof AnonPiError) {
|
|
44
69
|
process.stderr.write(e.message + '\n');
|
|
@@ -56,16 +81,6 @@ function main(argv: string[]): number {
|
|
|
56
81
|
return 1;
|
|
57
82
|
}
|
|
58
83
|
|
|
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
84
|
// Ensure the workdir exists (a fresh named folder is fine).
|
|
70
85
|
mkdirSync(plan.workdir, {recursive: true});
|
|
71
86
|
|
|
@@ -81,6 +96,84 @@ function main(argv: string[]): number {
|
|
|
81
96
|
return res.status ?? 1;
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
// --- anon-pi import : write the seed models.json ----------------------------
|
|
100
|
+
function runImport(args: string[]): number {
|
|
101
|
+
const force = args.includes('--force') || args.includes('-f');
|
|
102
|
+
const stray = args.filter(
|
|
103
|
+
(a) => a.startsWith('-') && a !== '--force' && a !== '-f',
|
|
104
|
+
);
|
|
105
|
+
if (stray.length > 0) {
|
|
106
|
+
process.stderr.write(
|
|
107
|
+
`anon-pi import: unknown option(s): ${stray.join(' ')}\nRun \`anon-pi --help\`.\n`,
|
|
108
|
+
);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const env = envFromProcess(process.env);
|
|
113
|
+
|
|
114
|
+
if (!env.llmDirect || env.llmDirect.trim() === '') {
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
'anon-pi import: set ANON_PI_LLM to the RFC1918/link-local IP[:port] of the local\n' +
|
|
117
|
+
'model whose provider should be imported (e.g. ANON_PI_LLM=192.168.1.150:8080).\n',
|
|
118
|
+
);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const source = resolveSourceModelsPath(env);
|
|
123
|
+
if (!existsSync(source)) {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`anon-pi import: host models.json not found at ${source}.\n` +
|
|
126
|
+
'Set ANON_PI_SOURCE_MODELS to your pi models.json, or run pi once to create it.\n',
|
|
127
|
+
);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let hostModels: PiModelsFile;
|
|
132
|
+
try {
|
|
133
|
+
hostModels = JSON.parse(readFileSync(source, 'utf8')) as PiModelsFile;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`anon-pi import: could not parse ${source}: ${(e as Error).message}\n`,
|
|
137
|
+
);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let result;
|
|
142
|
+
try {
|
|
143
|
+
result = pickProviderForLlm(hostModels, env.llmDirect);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e instanceof AnonPiError) {
|
|
146
|
+
process.stderr.write(e.message + '\n');
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const seedDir = resolveConfigSeed(env);
|
|
153
|
+
const dest = join(seedDir, MODELS_FILE);
|
|
154
|
+
if (existsSync(dest) && !force) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`anon-pi import: ${dest} already exists. Re-run with --force to overwrite.\n`,
|
|
157
|
+
);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (result.apiKeyLooksReal) {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`anon-pi import: WARNING: provider "${result.name}" carries a real-looking apiKey; it\n` +
|
|
164
|
+
'will be written into the seed. For a local model this is usually fine, but review\n' +
|
|
165
|
+
`${dest} if that key identifies you.\n`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
mkdirSync(seedDir, {recursive: true});
|
|
170
|
+
writeFileSync(dest, JSON.stringify(result.models, null, 2) + '\n');
|
|
171
|
+
process.stderr.write(
|
|
172
|
+
`anon-pi import: wrote ${dest} (provider "${result.name}"). Run \`anon-pi\` to launch.\n`,
|
|
173
|
+
);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
84
177
|
function hasNetcage(): boolean {
|
|
85
178
|
const which = spawnSync(
|
|
86
179
|
process.platform === 'win32' ? 'where' : 'command',
|