anon-pi 0.9.1 → 0.11.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/cli.ts CHANGED
@@ -55,7 +55,18 @@ import {
55
55
  resolveDeleteProject,
56
56
  parseConfigJson,
57
57
  parseLaunchArgs,
58
+ parseForwardArgs,
59
+ parsePortsArgs,
60
+ parsePortArg,
61
+ parseKeptKey,
62
+ keyProject,
63
+ resolveManagedMatches,
64
+ parseNetcagePortsJson,
65
+ forwardablePorts,
66
+ formatPortsHint,
58
67
  isHeadlessPiArgs,
68
+ resumeSessionId,
69
+ sessionHeaderCwd,
59
70
  anonPiVersion,
60
71
  parseMachineArgs,
61
72
  parseMachineJson,
@@ -100,6 +111,8 @@ import {
100
111
  type ModelCandidate,
101
112
  type ModelSelection,
102
113
  type KeptContainer,
114
+ type ManagedContainer,
115
+ type NetcageListener,
103
116
  type LaunchIntent,
104
117
  type Machine,
105
118
  type MachineConfig,
@@ -130,7 +143,7 @@ function main(argv: string[]): number {
130
143
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
131
144
  // subcommands route to runInit / runMachine, which print INIT_HELP /
132
145
  // MACHINE_HELP respectively.
133
- const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine']);
146
+ const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine', 'forward', 'ports']);
134
147
  if (
135
148
  (args.includes('--help') || args.includes('-h')) &&
136
149
  !OWN_HELP_SUBCOMMANDS.has(args[0] ?? '')
@@ -164,6 +177,16 @@ function main(argv: string[]): number {
164
177
  return runInit(args.slice(1));
165
178
  }
166
179
 
180
+ // Host-access verbs (netcage >= 0.9.0). `forward` opens a host->jail port; the
181
+ // `ports` sibling lists a jail's open listeners. Dispatched BEFORE the launch
182
+ // grammar so `forward`/`ports` are never parsed as a project name.
183
+ if (args[0] === 'forward') {
184
+ return runForward(args.slice(1));
185
+ }
186
+ if (args[0] === 'ports') {
187
+ return runPorts(args.slice(1));
188
+ }
189
+
167
190
  let parsed: ParsedLaunch;
168
191
  try {
169
192
  parsed = parseLaunchArgs(args);
@@ -294,6 +317,15 @@ function runLaunch(parsed: ParsedLaunch): number {
294
317
  modelsSeed,
295
318
  settingsSeed,
296
319
  };
320
+
321
+ // RESUME family with NO project: resolve the session's recorded cwd from the
322
+ // host store and cd there, so pi resumes in place (no fork prompt). A given
323
+ // project is trusted verbatim (pi guards a mismatch); an unresolvable id
324
+ // leaves the cwd at the projects root (pi decides), so this is pure upside.
325
+ if (parsed.mode === 'pi' && parsed.project === undefined) {
326
+ const sessionCwd = resolveSessionCwd(env, machineName, parsed.piArgs);
327
+ if (sessionCwd !== undefined) intent.sessionCwd = sessionCwd;
328
+ }
297
329
  } catch (e) {
298
330
  return reportAnonPiError(e);
299
331
  }
@@ -372,6 +404,13 @@ function executeLaunchPlan(
372
404
  mkdirSync(intent.mountParent ?? intent.projectsRoot, {recursive: true});
373
405
  }
374
406
 
407
+ // The anon-pi identity key, stamped on EVERY launch (not just --keep) as an
408
+ // additive netcage label. Under --keep it lets a re-entry find + `netcage
409
+ // start` the kept container; on a throwaway --rm run it lets `anon-pi forward`
410
+ // find the RUNNING container while it is up (the label goes away with the
411
+ // container on exit). It touches NO egress flag (the RunPlan owns those).
412
+ const keyed = withKeyLabel(plan.netcageArgs, keptContainerKey(intent));
413
+
375
414
  // Run-vs-start: under --keep, ask netcage for its kept managed containers and
376
415
  // resume a matching one via `netcage start`; else run the composed argv. A
377
416
  // throwaway (`--rm`) launch is always a fresh run (the pure rule never
@@ -381,14 +420,11 @@ function executeLaunchPlan(
381
420
  if (decision.action === 'start') {
382
421
  return spawnNetcage(['start', '-a', '-i', decision.ref]);
383
422
  }
384
- // A fresh `--keep` run: stamp the identity key so a later re-entry can
385
- // find this container. The RunPlan already omits --rm under --keep.
386
- return spawnNetcage(
387
- withKeyLabel(plan.netcageArgs, keptContainerKey(intent)),
388
- );
423
+ // A fresh `--keep` run: the RunPlan already omits --rm so it is left kept.
424
+ return spawnNetcage(keyed);
389
425
  }
390
426
 
391
- return spawnNetcage(plan.netcageArgs);
427
+ return spawnNetcage(keyed);
392
428
  }
393
429
 
394
430
  // --- the interactive host-side menu (the ONLY untested I/O) -------------------
@@ -1970,6 +2006,36 @@ function promptLine(prompt: string): string | undefined {
1970
2006
  return line === '' ? undefined : line;
1971
2007
  }
1972
2008
 
2009
+ /** The `forward` subcommand help. */
2010
+ const FORWARD_HELP = `anon-pi forward - open a host port onto a running anon-pi container's in-jail server
2011
+
2012
+ USAGE
2013
+ anon-pi forward [<project>] [--port <[hostPort:]jailPort>] [--bind <addr>] [-m <machine>]
2014
+
2015
+ <project> filter to a running container for THIS project (a numeric name is a
2016
+ project, never a port). Omitted => all running anon-pi containers.
2017
+ --port,-p the port to forward, host-first like docker/kubectl: 3001 (host
2018
+ 3001 -> jail 3001) or 8080:3001 (host 8080 -> jail 3001). Omit to be
2019
+ shown the container's open ports and prompted (incl. a different
2020
+ host port). An explicit port may be one not open yet.
2021
+ --bind passed through to netcage (127.0.0.1 default, or 0.0.0.0 for LAN).
2022
+ -m the machine the container runs on (else the default machine).
2023
+
2024
+ Wraps \`netcage forward\` (netcage >= 0.9.0). If several containers match, you pick
2025
+ one (each row shows its open in-jail ports). The forward runs until Ctrl-C.
2026
+ `;
2027
+
2028
+ /** The `ports` subcommand help. */
2029
+ const PORTS_HELP = `anon-pi ports - list a running anon-pi container's open in-jail TCP listeners
2030
+
2031
+ USAGE
2032
+ anon-pi ports [<project>] [-m <machine>]
2033
+
2034
+ Wraps \`netcage ports --json\` (netcage >= 0.9.0), which reads the jail's
2035
+ /proc/net/tcp* image-independently (works even with no ss/netstat in the image).
2036
+ Use it to find which port to \`anon-pi forward\`.
2037
+ `;
2038
+
1973
2039
  /** The `machine` subcommand help. */
1974
2040
  const MACHINE_HELP = `anon-pi machine - manage machines (an image + a persistent host home)
1975
2041
 
@@ -2057,6 +2123,259 @@ function queryKeptContainers(): KeptContainer[] {
2057
2123
  return out;
2058
2124
  }
2059
2125
 
2126
+ // --- `forward` / `ports`: reach an in-jail server from the host --------------
2127
+
2128
+ /**
2129
+ * Query netcage for its RUNNING managed containers (no `-a`, so a stopped kept
2130
+ * container is excluded: forward/ports can only reach a live jail), surfacing
2131
+ * each one's stamped anon-pi key + a display name. Best-effort: [] on any
2132
+ * failure, so the caller reports "nothing running" cleanly.
2133
+ */
2134
+ function queryRunningContainers(): ManagedContainer[] {
2135
+ const res = spawnSync(
2136
+ 'netcage',
2137
+ [
2138
+ 'ps',
2139
+ '--filter',
2140
+ 'label=netcage.managed',
2141
+ '--format',
2142
+ '{{.ID}}\t{{.Names}}\t{{.Labels}}',
2143
+ ],
2144
+ {encoding: 'utf8'},
2145
+ );
2146
+ if (res.error || res.status !== 0 || !res.stdout) return [];
2147
+ const out: ManagedContainer[] = [];
2148
+ for (const line of res.stdout.split('\n')) {
2149
+ const trimmed = line.trim();
2150
+ if (trimmed === '') continue;
2151
+ const cols = trimmed.split('\t');
2152
+ if (cols.length < 3) continue;
2153
+ const ref = cols[0].trim();
2154
+ const name = cols[1].trim();
2155
+ const key = extractKeyLabel(cols.slice(2).join('\t'));
2156
+ if (ref !== '' && key !== undefined) {
2157
+ out.push({key, ref, name: name || ref});
2158
+ }
2159
+ }
2160
+ return out;
2161
+ }
2162
+
2163
+ /**
2164
+ * Best-effort: the in-jail TCP listeners of a container via `netcage ports
2165
+ * <ref> --json` (netcage >= 0.9.0). [] on any failure (older netcage without
2166
+ * the verb, a parse miss), so the port hint is purely additive and never blocks
2167
+ * a forward.
2168
+ */
2169
+ function queryNetcagePorts(ref: string): NetcageListener[] {
2170
+ const res = spawnSync('netcage', ['ports', ref, '--json'], {
2171
+ encoding: 'utf8',
2172
+ });
2173
+ if (res.error || res.status !== 0 || !res.stdout) return [];
2174
+ return parseNetcagePortsJson(res.stdout);
2175
+ }
2176
+
2177
+ /**
2178
+ * Resolve the ONE running anon-pi container a forward/ports should act on:
2179
+ * filter the running managed containers by machine (+ project if given), then
2180
+ * 0 => error (nothing running), 1 => it, many => an arrow-key picker annotated
2181
+ * with each container's open in-jail ports. Returns undefined on no-match or a
2182
+ * cancelled pick (with the reason printed). TTY is required only for the picker.
2183
+ */
2184
+ function resolveForwardTarget(
2185
+ machine: string,
2186
+ project: string | undefined,
2187
+ verb: string,
2188
+ ): ManagedContainer | undefined {
2189
+ const running = queryRunningContainers();
2190
+ const matches = resolveManagedMatches({
2191
+ containers: running,
2192
+ machine,
2193
+ project,
2194
+ });
2195
+ if (matches.length === 0) {
2196
+ const scope =
2197
+ project !== undefined
2198
+ ? `project ${JSON.stringify(project)} on machine ${JSON.stringify(machine)}`
2199
+ : `machine ${JSON.stringify(machine)}`;
2200
+ process.stderr.write(
2201
+ `anon-pi: no running anon-pi container for ${scope}. ` +
2202
+ `Start one first (e.g. \`anon-pi ${project ?? '<project>'}\`) and run its ` +
2203
+ `server, then \`anon-pi ${verb}\` again.\n`,
2204
+ );
2205
+ return undefined;
2206
+ }
2207
+ if (matches.length === 1) return matches[0];
2208
+
2209
+ // Many: pick one. Each row is annotated with the container's open ports (a
2210
+ // best-effort hint), so the user can tell the sessions apart.
2211
+ if (!process.stdin.isTTY) {
2212
+ process.stderr.write(
2213
+ `anon-pi: ${matches.length} running containers match; a terminal is needed to ` +
2214
+ `pick one. Narrow with a project (\`anon-pi ${verb} <project>\`) or -m <machine>.\n`,
2215
+ );
2216
+ return undefined;
2217
+ }
2218
+ const entries: MenuEntry[] = matches.map((c) => {
2219
+ const proj = keyProject(parseKeptKey(c.key));
2220
+ const label = proj === '' ? '(shell)' : proj;
2221
+ const hint = formatPortsHint(queryNetcagePorts(c.ref));
2222
+ return {
2223
+ kind: 'project',
2224
+ project: c.ref,
2225
+ label: `${label} [${c.name}] ${hint}`,
2226
+ };
2227
+ });
2228
+ const picked = select(entries, {
2229
+ header: `anon-pi: pick a container to ${verb} (\u2191/\u2193 move, Enter select, Ctrl-C quit)`,
2230
+ });
2231
+ if (picked === undefined) {
2232
+ process.stderr.write('anon-pi: cancelled; nothing forwarded.\n');
2233
+ return undefined;
2234
+ }
2235
+ return matches.find((c) => c.ref === picked.project);
2236
+ }
2237
+
2238
+ /**
2239
+ * `anon-pi forward [<project>] [--port <[hostPort:]jailPort>] [--bind <addr>]
2240
+ * [-m <machine>]`: open a host->jail port on a running anon-pi container. Wraps
2241
+ * `netcage forward`. When --port is omitted, it lists the container's open
2242
+ * in-jail ports and prompts for the jail port + an optional different host port.
2243
+ */
2244
+ function runForward(forwardArgs: string[]): number {
2245
+ if (forwardArgs.includes('--help') || forwardArgs.includes('-h')) {
2246
+ process.stdout.write(FORWARD_HELP);
2247
+ return 0;
2248
+ }
2249
+ if (!hasNetcage()) return netcageMissing();
2250
+
2251
+ let cmd;
2252
+ try {
2253
+ cmd = parseForwardArgs(forwardArgs);
2254
+ } catch (e) {
2255
+ return reportAnonPiError(e);
2256
+ }
2257
+
2258
+ const machine = resolveVerbMachine(cmd.machine, cmd.machineExplicit);
2259
+ const target = resolveForwardTarget(machine, cmd.project, 'forward');
2260
+ if (target === undefined) return 1;
2261
+
2262
+ // Resolve the port: --port wins; else prompt from the container's listeners.
2263
+ let portRaw: string;
2264
+ if (cmd.port !== undefined) {
2265
+ portRaw = cmd.port.raw;
2266
+ } else {
2267
+ const prompted = promptForwardPort(target.ref);
2268
+ if (prompted === undefined) return 1;
2269
+ portRaw = prompted;
2270
+ }
2271
+
2272
+ const argv = ['forward'];
2273
+ if (cmd.bind !== undefined) argv.push('--bind', cmd.bind);
2274
+ argv.push(target.ref, portRaw);
2275
+ process.stderr.write(
2276
+ `anon-pi: forwarding to ${target.name} (${portRaw}); Ctrl-C to stop\u2026\n`,
2277
+ );
2278
+ return spawnNetcage(argv);
2279
+ }
2280
+
2281
+ /**
2282
+ * Interactive port resolution when `--port` is omitted: show the container's
2283
+ * open in-jail listeners, prompt for the jail port (defaulting to the sole
2284
+ * obvious one), then ask whether to bind it on a DIFFERENT host port and let the
2285
+ * user type it. Returns the netcage port token (`<jail>` or `<host>:<jail>`), or
2286
+ * undefined on cancel / a bad entry (reported). Requires a TTY.
2287
+ */
2288
+ function promptForwardPort(ref: string): string | undefined {
2289
+ if (!process.stdin.isTTY) {
2290
+ process.stderr.write(
2291
+ 'anon-pi: no TTY. Pass the port explicitly, e.g. `anon-pi forward --port 3001`.\n',
2292
+ );
2293
+ return undefined;
2294
+ }
2295
+ const listeners = queryNetcagePorts(ref);
2296
+ const open = forwardablePorts(listeners);
2297
+ process.stderr.write(` in-jail listeners: ${formatPortsHint(listeners)}\n`);
2298
+
2299
+ const def = open.length === 1 ? String(open[0]) : undefined;
2300
+ const jailAns = promptLine(
2301
+ ` jail port to forward${def ? ` [${def}]` : ''}: `,
2302
+ );
2303
+ const jailStr =
2304
+ jailAns === undefined || jailAns.trim() === '' ? def : jailAns.trim();
2305
+ if (jailStr === undefined) {
2306
+ process.stderr.write('anon-pi: no port given; nothing forwarded.\n');
2307
+ return undefined;
2308
+ }
2309
+
2310
+ const hostAns = promptLine(
2311
+ ` host port (Enter for same as jail, or type a different one): `,
2312
+ );
2313
+ const hostStr = hostAns === undefined ? '' : hostAns.trim();
2314
+ const token = hostStr === '' ? jailStr : `${hostStr}:${jailStr}`;
2315
+ try {
2316
+ return parsePortArg(token).raw; // validate 1..65535 + shape
2317
+ } catch (e) {
2318
+ reportAnonPiError(e);
2319
+ return undefined;
2320
+ }
2321
+ }
2322
+
2323
+ /**
2324
+ * `anon-pi ports [<project>] [-m <machine>]`: list a running anon-pi container's
2325
+ * open in-jail TCP listeners (via `netcage ports --json`), image-independent.
2326
+ * Disambiguates the same way as forward.
2327
+ */
2328
+ function runPorts(portsArgs: string[]): number {
2329
+ if (portsArgs.includes('--help') || portsArgs.includes('-h')) {
2330
+ process.stdout.write(PORTS_HELP);
2331
+ return 0;
2332
+ }
2333
+ if (!hasNetcage()) return netcageMissing();
2334
+
2335
+ let cmd;
2336
+ try {
2337
+ cmd = parsePortsArgs(portsArgs);
2338
+ } catch (e) {
2339
+ return reportAnonPiError(e);
2340
+ }
2341
+
2342
+ const machine = resolveVerbMachine(cmd.machine, cmd.machineExplicit);
2343
+ const target = resolveForwardTarget(machine, cmd.project, 'ports');
2344
+ if (target === undefined) return 1;
2345
+
2346
+ const listeners = queryNetcagePorts(target.ref);
2347
+ if (listeners.length === 0) {
2348
+ process.stdout.write(
2349
+ `${target.name}: no in-jail TCP listeners detected ` +
2350
+ `(the server may not be up yet, or netcage < 0.9.0).\n`,
2351
+ );
2352
+ return 0;
2353
+ }
2354
+ process.stdout.write(`${target.name}: in-jail TCP listeners\n`);
2355
+ for (const l of listeners) {
2356
+ const scope = l.loopbackOnly ? 'loopback' : 'all-interfaces';
2357
+ const note = l.port === 53 && l.loopbackOnly ? ' (netcage DNS)' : '';
2358
+ process.stdout.write(` ${l.address}:${l.port}\t${scope}${note}\n`);
2359
+ }
2360
+ return 0;
2361
+ }
2362
+
2363
+ /** Resolve the machine a verb targets: explicit -m wins, else config.defaultMachine. */
2364
+ function resolveVerbMachine(machine: string, explicit: boolean): string {
2365
+ if (explicit) return machine;
2366
+ const config = readJsonConfig(envFromProcess(process.env));
2367
+ return config.defaultMachine ?? DEFAULT_MACHINE;
2368
+ }
2369
+
2370
+ /** The shared "netcage not on PATH" error (exit 1). */
2371
+ function netcageMissing(): number {
2372
+ process.stderr.write(
2373
+ 'anon-pi: `netcage` not found on PATH. anon-pi is a launcher for netcage; install it first\n' +
2374
+ '(https://github.com/wighawag/netcage). Linux only.\n',
2375
+ );
2376
+ return 1;
2377
+ }
2378
+
2060
2379
  /**
2061
2380
  * Pull the anon-pi key out of a podman `{{.Labels}}` rendering (a
2062
2381
  * comma-separated `k=v` list). The key is stamped as `anon-pi.key=<opaque>`;
@@ -2093,6 +2412,56 @@ function withKeyLabel(netcageArgs: string[], key: string): string[] {
2093
2412
  return out;
2094
2413
  }
2095
2414
 
2415
+ /**
2416
+ * Best-effort: resolve a RESUME-family launch's session cwd from the host
2417
+ * session store, so the CLI can cd there (intent.sessionCwd) and pi resumes in
2418
+ * place instead of prompting to fork. Globs the machine's sessions dir for a
2419
+ * file whose name carries `<id>` (pi names them `<ts>_<id>.jsonl`), reads the
2420
+ * HEADER line, and returns its recorded cwd (pure sessionHeaderCwd). Returns
2421
+ * undefined on any miss (no id, id not found, unreadable, no cwd): the caller
2422
+ * then leaves the cwd at the projects root and lets pi decide, as before. NEVER
2423
+ * throws (a resume must not fail on a store-read hiccup).
2424
+ */
2425
+ function resolveSessionCwd(
2426
+ env: AnonPiEnv,
2427
+ machine: string,
2428
+ piArgs: readonly string[] | undefined,
2429
+ ): string | undefined {
2430
+ const id = resumeSessionId(piArgs);
2431
+ if (id === undefined) return undefined;
2432
+ const sessionsRoot = machineSessionsDir(env, machine);
2433
+ if (!existsSync(sessionsRoot)) return undefined;
2434
+ try {
2435
+ // sessions/<slug>/<ts>_<id>.jsonl: scan each slug dir for a file whose name
2436
+ // contains the id. The id is a UUID (no path/glob metachars), so a substring
2437
+ // match is safe and cheap for the short session lists here.
2438
+ for (const slug of readdirSync(sessionsRoot, {withFileTypes: true})) {
2439
+ if (!slug.isDirectory()) continue;
2440
+ const slugDir = join(sessionsRoot, slug.name);
2441
+ for (const f of readdirSync(slugDir)) {
2442
+ if (!f.endsWith('.jsonl') || !f.includes(id)) continue;
2443
+ const header = firstLine(join(slugDir, f));
2444
+ if (header === undefined) return undefined;
2445
+ return sessionHeaderCwd(header);
2446
+ }
2447
+ }
2448
+ } catch {
2449
+ return undefined;
2450
+ }
2451
+ return undefined;
2452
+ }
2453
+
2454
+ /** Read a file's FIRST line (up to a newline), or undefined if unreadable. */
2455
+ function firstLine(path: string): string | undefined {
2456
+ try {
2457
+ const text = readFileSync(path, 'utf8');
2458
+ const nl = text.indexOf('\n');
2459
+ return nl === -1 ? text : text.slice(0, nl);
2460
+ } catch {
2461
+ return undefined;
2462
+ }
2463
+ }
2464
+
2096
2465
  /** Spawn netcage with inherited stdio; propagate its exit code. */
2097
2466
  function spawnNetcage(netcageArgs: string[]): number {
2098
2467
  // Explain the pause: netcage sets up the jail (netns, firewall, DNS, container