anon-pi 0.7.0 → 0.9.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
@@ -35,6 +35,10 @@ import {
35
35
  buildMenuEntries,
36
36
  builtinProjectsRoot,
37
37
  deriveProjectUsage,
38
+ expandTilde,
39
+ findingsFromNetcageDetect,
40
+ processNoteFromNetcageDetect,
41
+ resolveNetcageGraphroot,
38
42
  globalModelsSeedPath,
39
43
  globalSettingsSeedPath,
40
44
  machineAgentDir,
@@ -51,6 +55,8 @@ import {
51
55
  resolveDeleteProject,
52
56
  parseConfigJson,
53
57
  parseLaunchArgs,
58
+ isHeadlessPiArgs,
59
+ anonPiVersion,
54
60
  parseMachineArgs,
55
61
  parseMachineJson,
56
62
  projectHostDir,
@@ -85,6 +91,7 @@ import {
85
91
  type MenuEntry,
86
92
  type SessionDirListing,
87
93
  type ProxyFinding,
94
+ type NetcageDetectProxy,
88
95
  type SocksHandshake,
89
96
  type InitImageChoice,
90
97
  type AnonPiConfig,
@@ -110,6 +117,14 @@ const ANON_PI_KEY_LABEL = 'anon-pi.key';
110
117
  function main(argv: string[]): number {
111
118
  const args = argv.slice(2);
112
119
 
120
+ // `--version`/`-V` prints anon-pi's own version and exits (before the launch
121
+ // grammar, so it is never parsed as a project/flag). For pi's version inside
122
+ // the jail, forward it: `anon-pi pi --version`.
123
+ if (args[0] === '--version' || args[0] === '-V') {
124
+ process.stdout.write(`anon-pi ${anonPiVersion() ?? '(unknown)'}\n`);
125
+ return 0;
126
+ }
127
+
113
128
  // The global `--help`/`-h` prints the top-level HELP, EXCEPT when the first
114
129
  // token is a subcommand that owns its own `--help` (so `anon-pi init --help`
115
130
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
@@ -242,7 +257,13 @@ function runLaunch(parsed: ParsedLaunch): number {
242
257
  const image = machineConf.image ?? env.image ?? '';
243
258
 
244
259
  // --mount re-roots at a HOST parent; otherwise the resolved projects root.
245
- const mountParent = parsed.mountParent;
260
+ // Expand a leading `~` + absolutize the mount path so it is a real host dir
261
+ // everywhere it is used (the mount, the mkdir, the intent). path.resolve
262
+ // alone would leave `~/x` as a literal `~` dir.
263
+ const mountParent =
264
+ parsed.mountParent !== undefined
265
+ ? resolve(expandTilde(parsed.mountParent, env.home))
266
+ : undefined;
246
267
  const projectsRoot = resolveProjectsRoot({
247
268
  env,
248
269
  config,
@@ -278,10 +299,10 @@ function runLaunch(parsed: ParsedLaunch): number {
278
299
  }
279
300
 
280
301
  // No-TTY discipline: the bare MENU and every INTERACTIVE launch (interactive
281
- // pi, or a shell) need a TTY; a HEADLESS pi run (`<project> <pi-args…>`) does
282
- // NOT. Check BEFORE we mutate anything or spawn.
283
- const headless =
284
- parsed.mode === 'pi' && !!parsed.piArgs && parsed.piArgs.length > 0;
302
+ // pi, or a shell) need a TTY; only a HEADLESS pi run (forwarded `-p`/`--print`)
303
+ // does NOT. Forwarded args that stay interactive (e.g. `--session <id>`,
304
+ // `--model x`) still require a TTY. Check BEFORE we mutate anything or spawn.
305
+ const headless = parsed.mode === 'pi' && isHeadlessPiArgs(parsed.piArgs);
285
306
  if (!headless && !process.stdin.isTTY) {
286
307
  if (parsed.mode === 'menu') {
287
308
  process.stderr.write(
@@ -1197,25 +1218,28 @@ function initProxyStep(currentProxy: string | undefined): string | undefined {
1197
1218
  }
1198
1219
  process.stdout.write(' Probing common SOCKS ports (evidence only)...\n');
1199
1220
 
1200
- // Probe each default port: TCP-open + a real SOCKS5 handshake. The weak
1201
- // process hint (a running `tor`/`wireproxy` LOCAL process, never the exit
1202
- // provider) is HOST-WIDE, so gather it ONCE and pass it as the formatter's
1203
- // single general note rather than gluing it onto every port line. The display
1204
- // is the PURE formatter's job.
1205
- const runningProcesses = observeRunningProcesses();
1206
- const processNote = matchProcessHint(runningProcesses);
1207
- const findings: ProxyFinding[] = DEFAULT_SOCKS_PROBE_PORTS.map(
1208
- ({port, hint}) => {
1221
+ // REUSE netcage's SOCKS scanner when available: `netcage detect-proxy --json`
1222
+ // probes the same ports + does the SOCKS5 handshake + the weak process hint,
1223
+ // and is the same evidence engine that backs `netcage setup-default`. Falling
1224
+ // back to anon-pi's own probe keeps init working on an older netcage (or if
1225
+ // the verb errors). Either way the findings render through the same PURE
1226
+ // formatter, so the honesty invariant (never label the provider) is identical.
1227
+ const detected = detectProxyViaNetcage();
1228
+ let findings: ProxyFinding[] = detected
1229
+ ? findingsFromNetcageDetect(detected)
1230
+ : [];
1231
+ let processNote: string | undefined = detected
1232
+ ? processNoteFromNetcageDetect(detected)
1233
+ : undefined;
1234
+ if (findings.length === 0) {
1235
+ // Local probe fallback: TCP-open + a real SOCKS5 handshake per default
1236
+ // port. The weak process hint is HOST-WIDE, gathered once as the note.
1237
+ processNote = matchProcessHint(observeRunningProcesses());
1238
+ findings = DEFAULT_SOCKS_PROBE_PORTS.map(({port, hint}) => {
1209
1239
  const {open, handshake} = probeSocks5('127.0.0.1', port);
1210
- return {
1211
- host: '127.0.0.1',
1212
- port,
1213
- open,
1214
- handshake,
1215
- portHint: hint,
1216
- };
1217
- },
1218
- );
1240
+ return {host: '127.0.0.1', port, open, handshake, portHint: hint};
1241
+ });
1242
+ }
1219
1243
  process.stdout.write(
1220
1244
  '\n' + formatProxyFindings(findings, processNote) + '\n\n',
1221
1245
  );
@@ -1556,8 +1580,14 @@ function initImageStep(): string | undefined | typeof ABORT {
1556
1580
  );
1557
1581
  continue;
1558
1582
  }
1583
+ // Fully-qualified `localhost/` tag: podman refuses an UNQUALIFIED short name
1584
+ // at run time ("did not resolve to an alias and no unqualified-search
1585
+ // registries defined"), so a locally-built image MUST carry the localhost/
1586
+ // prefix to be runnable by name.
1559
1587
  const tag =
1560
- choice === 'basic' ? 'anon-pi/pi:latest' : 'anon-pi/pi-webveil:latest';
1588
+ choice === 'basic'
1589
+ ? 'localhost/anon-pi/pi:latest'
1590
+ : 'localhost/anon-pi/pi-webveil:latest';
1561
1591
  const built = buildImage(dockerfile, tag);
1562
1592
  if (!built) {
1563
1593
  process.stdout.write(' Build failed; pick another option.\n');
@@ -1598,9 +1628,10 @@ function initProjectsStep(
1598
1628
  if (ans === undefined) return currentProjects;
1599
1629
  const trimmed = ans.trim();
1600
1630
  if (trimmed === '') return currentProjects;
1601
- // Store the built-in as "unset" (undefined) so config.json stays clean when
1602
- // the user just accepts the default path explicitly.
1603
- const chosen = resolve(trimmed);
1631
+ // Expand a leading `~` (path.resolve does NOT it would make a literal `~`
1632
+ // dir), then absolutize. Store the built-in as "unset" (undefined) so
1633
+ // config.json stays clean when the user just accepts the default path.
1634
+ const chosen = resolve(expandTilde(trimmed, env.home));
1604
1635
  if (chosen === builtin) return undefined;
1605
1636
  return chosen;
1606
1637
  }
@@ -1779,29 +1810,128 @@ function matchProcessHint(processes: readonly string[]): string | undefined {
1779
1810
  return undefined;
1780
1811
  }
1781
1812
 
1813
+ /** Whether `netcage <verb>` exists (probe its help; false on any spawn error). */
1814
+ function hasNetcageVerb(verb: string): boolean {
1815
+ const res = spawnSync('netcage', [verb, '--help'], {stdio: 'ignore'});
1816
+ if (res.error) return false;
1817
+ // netcage prints an "unknown subcommand" error (non-zero) for a missing verb,
1818
+ // and help (exit 0) for a real one. Treat exit 0 as "exists".
1819
+ return res.status === 0;
1820
+ }
1821
+
1822
+ /**
1823
+ * Run `netcage detect-proxy --json` and parse it, to REUSE netcage's SOCKS
1824
+ * scanner (probe + handshake + process hint + exit-IP verify) in `init`. Returns
1825
+ * the parsed result, or undefined when netcage lacks the verb / errors / emits
1826
+ * non-JSON — in which case init falls back to its own local probe. Best-effort;
1827
+ * never throws.
1828
+ */
1829
+ function detectProxyViaNetcage(): NetcageDetectProxy | undefined {
1830
+ const res = spawnSync('netcage', ['detect-proxy', '--json'], {
1831
+ encoding: 'utf8',
1832
+ timeout: 20000,
1833
+ });
1834
+ if (res.error || res.status !== 0 || !res.stdout) return undefined;
1835
+ try {
1836
+ const parsed = JSON.parse(res.stdout) as NetcageDetectProxy;
1837
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
1838
+ } catch {
1839
+ return undefined;
1840
+ }
1841
+ }
1842
+
1782
1843
  /**
1783
- * Build a shipped Dockerfile into `tag` via `podman build`. Streams podman's
1784
- * output (inherited stdio) so the user sees the build. Returns true on success.
1785
- * The build CONTEXT is the Dockerfile's own directory (the shipped examples/
1786
- * dir or the package root), which is where its COPY sources live.
1844
+ * Build a shipped Dockerfile into `tag`, landing it in the SAME store
1845
+ * `netcage run` reads.
1846
+ *
1847
+ * Since netcage v0.7.0 that store is netcage's private podman graphroot
1848
+ * (`--root <graphroot>`), NOT the operator's default rootless store, so a plain
1849
+ * `podman build` would put the image where `netcage run` cannot see it (it would
1850
+ * try to pull the `localhost/…` ref and fail). We therefore:
1851
+ * 1. PREFER `netcage build` when netcage exposes it (future-proof: netcage owns
1852
+ * its store + graphroot, no path hardcoded here); else
1853
+ * 2. `podman build` into the default store, then `podman save | podman --root
1854
+ * <graphroot> load` to copy it into netcage's store (the interim workaround
1855
+ * until netcage ships a build/load verb).
1856
+ * Streams output (inherited stdio) so the user sees the build. Returns true on
1857
+ * success. The build CONTEXT is the Dockerfile's own directory.
1787
1858
  */
1788
1859
  function buildImage(dockerfile: string, tag: string): boolean {
1789
1860
  const context = dirname(dockerfile);
1861
+
1862
+ // 1) Prefer a native `netcage build` (it targets netcage's own store).
1863
+ if (hasNetcageVerb('build')) {
1864
+ process.stdout.write(` Building ${tag} via \`netcage build\`...\n`);
1865
+ const res = spawnSync(
1866
+ 'netcage',
1867
+ ['build', '-t', tag, '-f', dockerfile, context],
1868
+ {stdio: 'inherit'},
1869
+ );
1870
+ if (res.error) {
1871
+ process.stderr.write(
1872
+ ` anon-pi: failed to run netcage build: ${res.error.message}\n`,
1873
+ );
1874
+ return false;
1875
+ }
1876
+ return res.status === 0;
1877
+ }
1878
+
1879
+ // 2) Interim: podman build into the default store, then load into netcage's
1880
+ // graphroot so `netcage run` can find it.
1790
1881
  process.stdout.write(
1791
1882
  ` Building ${tag} from ${dockerfile} (podman build)...\n`,
1792
1883
  );
1793
- const res = spawnSync(
1884
+ const build = spawnSync(
1794
1885
  'podman',
1795
1886
  ['build', '-t', tag, '-f', dockerfile, context],
1796
1887
  {stdio: 'inherit'},
1797
1888
  );
1798
- if (res.error) {
1889
+ if (build.error) {
1799
1890
  process.stderr.write(
1800
- ` anon-pi: failed to run podman: ${res.error.message}. Is podman installed?\n`,
1891
+ ` anon-pi: failed to run podman: ${build.error.message}. Is podman installed?\n`,
1801
1892
  );
1802
1893
  return false;
1803
1894
  }
1804
- return res.status === 0;
1895
+ if (build.status !== 0) return false;
1896
+
1897
+ return loadImageIntoNetcageStore(tag);
1898
+ }
1899
+
1900
+ /**
1901
+ * Copy a locally-built image (in the default podman store) INTO netcage's
1902
+ * private graphroot store, so `netcage run <tag>` finds it without a pull. Uses
1903
+ * `podman save <tag> | podman --root <graphroot> load`. Best-effort: on failure
1904
+ * it warns (the image still exists in the default store; a future `netcage
1905
+ * build`/`load` verb removes this dance). Returns true on success.
1906
+ */
1907
+ function loadImageIntoNetcageStore(tag: string): boolean {
1908
+ const graphroot = resolveNetcageGraphroot(process.env);
1909
+ process.stdout.write(
1910
+ ` Loading ${tag} into netcage's store (${graphroot}) so \`netcage run\` sees it...\n`,
1911
+ );
1912
+ // `podman save <tag> | podman --root <graphroot> load`, via a shell so the
1913
+ // pipe is a single spawn (both ends inherit stderr for progress).
1914
+ const cmd =
1915
+ `podman save ${shQuote(tag)} | ` +
1916
+ `podman --root ${shQuote(graphroot)} load`;
1917
+ const res = spawnSync('sh', ['-c', cmd], {
1918
+ stdio: ['ignore', 'inherit', 'inherit'],
1919
+ });
1920
+ if (res.error || res.status !== 0) {
1921
+ process.stderr.write(
1922
+ ` anon-pi: could not load ${tag} into netcage's store (${graphroot}).\n` +
1923
+ ` The image is built in your default podman store, but \`netcage run\` reads\n` +
1924
+ ` ${graphroot}. Load it by hand:\n` +
1925
+ ` podman save ${tag} | podman --root ${graphroot} load\n`,
1926
+ );
1927
+ return false;
1928
+ }
1929
+ return true;
1930
+ }
1931
+
1932
+ /** Minimal POSIX single-quote shell-quoting for a token embedded in `sh -c`. */
1933
+ function shQuote(s: string): string {
1934
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1805
1935
  }
1806
1936
 
1807
1937
  /** List machine names (readdir of machines/), or [] if the dir is absent. */