anon-pi 0.8.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,
@@ -87,6 +91,7 @@ import {
87
91
  type MenuEntry,
88
92
  type SessionDirListing,
89
93
  type ProxyFinding,
94
+ type NetcageDetectProxy,
90
95
  type SocksHandshake,
91
96
  type InitImageChoice,
92
97
  type AnonPiConfig,
@@ -252,7 +257,13 @@ function runLaunch(parsed: ParsedLaunch): number {
252
257
  const image = machineConf.image ?? env.image ?? '';
253
258
 
254
259
  // --mount re-roots at a HOST parent; otherwise the resolved projects root.
255
- 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;
256
267
  const projectsRoot = resolveProjectsRoot({
257
268
  env,
258
269
  config,
@@ -1207,25 +1218,28 @@ function initProxyStep(currentProxy: string | undefined): string | undefined {
1207
1218
  }
1208
1219
  process.stdout.write(' Probing common SOCKS ports (evidence only)...\n');
1209
1220
 
1210
- // Probe each default port: TCP-open + a real SOCKS5 handshake. The weak
1211
- // process hint (a running `tor`/`wireproxy` LOCAL process, never the exit
1212
- // provider) is HOST-WIDE, so gather it ONCE and pass it as the formatter's
1213
- // single general note rather than gluing it onto every port line. The display
1214
- // is the PURE formatter's job.
1215
- const runningProcesses = observeRunningProcesses();
1216
- const processNote = matchProcessHint(runningProcesses);
1217
- const findings: ProxyFinding[] = DEFAULT_SOCKS_PROBE_PORTS.map(
1218
- ({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}) => {
1219
1239
  const {open, handshake} = probeSocks5('127.0.0.1', port);
1220
- return {
1221
- host: '127.0.0.1',
1222
- port,
1223
- open,
1224
- handshake,
1225
- portHint: hint,
1226
- };
1227
- },
1228
- );
1240
+ return {host: '127.0.0.1', port, open, handshake, portHint: hint};
1241
+ });
1242
+ }
1229
1243
  process.stdout.write(
1230
1244
  '\n' + formatProxyFindings(findings, processNote) + '\n\n',
1231
1245
  );
@@ -1566,8 +1580,14 @@ function initImageStep(): string | undefined | typeof ABORT {
1566
1580
  );
1567
1581
  continue;
1568
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.
1569
1587
  const tag =
1570
- 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';
1571
1591
  const built = buildImage(dockerfile, tag);
1572
1592
  if (!built) {
1573
1593
  process.stdout.write(' Build failed; pick another option.\n');
@@ -1608,9 +1628,10 @@ function initProjectsStep(
1608
1628
  if (ans === undefined) return currentProjects;
1609
1629
  const trimmed = ans.trim();
1610
1630
  if (trimmed === '') return currentProjects;
1611
- // Store the built-in as "unset" (undefined) so config.json stays clean when
1612
- // the user just accepts the default path explicitly.
1613
- 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));
1614
1635
  if (chosen === builtin) return undefined;
1615
1636
  return chosen;
1616
1637
  }
@@ -1789,29 +1810,128 @@ function matchProcessHint(processes: readonly string[]): string | undefined {
1789
1810
  return undefined;
1790
1811
  }
1791
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
+
1792
1843
  /**
1793
- * Build a shipped Dockerfile into `tag` via `podman build`. Streams podman's
1794
- * output (inherited stdio) so the user sees the build. Returns true on success.
1795
- * The build CONTEXT is the Dockerfile's own directory (the shipped examples/
1796
- * 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.
1797
1858
  */
1798
1859
  function buildImage(dockerfile: string, tag: string): boolean {
1799
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.
1800
1881
  process.stdout.write(
1801
1882
  ` Building ${tag} from ${dockerfile} (podman build)...\n`,
1802
1883
  );
1803
- const res = spawnSync(
1884
+ const build = spawnSync(
1804
1885
  'podman',
1805
1886
  ['build', '-t', tag, '-f', dockerfile, context],
1806
1887
  {stdio: 'inherit'},
1807
1888
  );
1808
- if (res.error) {
1889
+ if (build.error) {
1809
1890
  process.stderr.write(
1810
- ` 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`,
1811
1892
  );
1812
1893
  return false;
1813
1894
  }
1814
- 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, `'\\''`)}'`;
1815
1935
  }
1816
1936
 
1817
1937
  /** List machine names (readdir of machines/), or [] if the dir is absent. */