claude-flow 3.32.2 → 3.32.9

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.
Files changed (50) hide show
  1. package/.claude/helpers/statusline.cjs +93 -39
  2. package/.claude-plugin/hooks/hooks.json +3 -1
  3. package/.claude-plugin/scripts/ruflo-hook.cjs +2 -2
  4. package/.claude-plugin/scripts/ruflo-hook.sh +17 -17
  5. package/package.json +9 -3
  6. package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
  7. package/v3/@claude-flow/cli/dist/src/auth/client.d.ts +89 -0
  8. package/v3/@claude-flow/cli/dist/src/auth/client.js +242 -0
  9. package/v3/@claude-flow/cli/dist/src/auth/constants.d.ts +7 -0
  10. package/v3/@claude-flow/cli/dist/src/auth/constants.js +7 -0
  11. package/v3/@claude-flow/cli/dist/src/auth/scopes.d.ts +14 -0
  12. package/v3/@claude-flow/cli/dist/src/auth/scopes.js +21 -0
  13. package/v3/@claude-flow/cli/dist/src/auth/security-bridge.d.ts +36 -0
  14. package/v3/@claude-flow/cli/dist/src/auth/security-bridge.js +42 -0
  15. package/v3/@claude-flow/cli/dist/src/auth/session.d.ts +20 -0
  16. package/v3/@claude-flow/cli/dist/src/auth/session.js +32 -0
  17. package/v3/@claude-flow/cli/dist/src/auth/state.d.ts +19 -0
  18. package/v3/@claude-flow/cli/dist/src/auth/state.js +53 -0
  19. package/v3/@claude-flow/cli/dist/src/auth/types.d.ts +27 -0
  20. package/v3/@claude-flow/cli/dist/src/auth/types.js +11 -0
  21. package/v3/@claude-flow/cli/dist/src/commands/auth.d.ts +15 -0
  22. package/v3/@claude-flow/cli/dist/src/commands/auth.js +244 -0
  23. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +478 -24
  24. package/v3/@claude-flow/cli/dist/src/commands/hooks.js +43 -1
  25. package/v3/@claude-flow/cli/dist/src/commands/index.js +2 -0
  26. package/v3/@claude-flow/cli/dist/src/commands/proxy-lifecycle.d.ts +20 -0
  27. package/v3/@claude-flow/cli/dist/src/commands/proxy-lifecycle.js +277 -0
  28. package/v3/@claude-flow/cli/dist/src/commands/proxy.js +97 -4
  29. package/v3/@claude-flow/cli/dist/src/funnel/message-transport.d.ts +11 -4
  30. package/v3/@claude-flow/cli/dist/src/funnel/message-transport.js +11 -4
  31. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +11 -3
  32. package/v3/@claude-flow/cli/dist/src/memory/graph-edge-writer.d.ts +9 -0
  33. package/v3/@claude-flow/cli/dist/src/memory/graph-edge-writer.js +13 -0
  34. package/v3/@claude-flow/cli/dist/src/memory/memory-bridge.js +32 -5
  35. package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +72 -0
  36. package/v3/@claude-flow/cli/dist/src/proxy/install.d.ts +29 -0
  37. package/v3/@claude-flow/cli/dist/src/proxy/install.js +135 -0
  38. package/v3/@claude-flow/cli/dist/src/proxy/lifecycle.d.ts +61 -0
  39. package/v3/@claude-flow/cli/dist/src/proxy/lifecycle.js +249 -0
  40. package/v3/@claude-flow/cli/dist/src/proxy/paths.d.ts +34 -0
  41. package/v3/@claude-flow/cli/dist/src/proxy/paths.js +70 -0
  42. package/v3/@claude-flow/cli/dist/src/proxy/release.d.ts +47 -0
  43. package/v3/@claude-flow/cli/dist/src/proxy/release.js +138 -0
  44. package/v3/@claude-flow/cli/dist/src/proxy/token-bridge.d.ts +5 -0
  45. package/v3/@claude-flow/cli/dist/src/proxy/token-bridge.js +61 -0
  46. package/v3/@claude-flow/cli/dist/src/proxy/verify.d.ts +44 -0
  47. package/v3/@claude-flow/cli/dist/src/proxy/verify.js +68 -0
  48. package/v3/@claude-flow/cli/package.json +6 -4
  49. package/.claude/.proven-config-version +0 -1
  50. package/.claude/proven-config.json +0 -42
@@ -35,6 +35,33 @@ function isRuvectorCoreResolvable() {
35
35
  }
36
36
  return _ruvectorCoreResolvable;
37
37
  }
38
+ /**
39
+ * #2735 — before a whole-image sql.js read-modify-persist (export() +
40
+ * rename over the live database path), refuse if there is evidence of a
41
+ * live native (better-sqlite3) WAL connection: `-wal`/`-shm` sidecar files
42
+ * on disk. A native connection in WAL mode keeps its sidecars present for
43
+ * its entire lifetime (removed only on the last connection's clean close),
44
+ * so their presence is strong evidence of a live native holder — and their
45
+ * absence means the image is a clean, checkpointed, standalone file that
46
+ * sql.js can safely read-modify-write.
47
+ *
48
+ * This is a scoped-down version of the fuller "scan live process holders"
49
+ * design discussed in #2735: it does not close the narrow assess-then-write
50
+ * race (a native opener could still attach in the gap between this check
51
+ * and the write), but it directly closes the demonstrated corruption
52
+ * mechanism — a whole-image write proceeding while an ALREADY-OPEN native
53
+ * connection's sidecars are on disk — with no platform-specific process
54
+ * scanning. Fails closed (treats a stat error as "unsafe") because this is
55
+ * a safety gate, not a best-effort probe.
56
+ */
57
+ function hasNativeWalSidecars(dbPath) {
58
+ try {
59
+ return fs.existsSync(`${dbPath}-wal`) || fs.existsSync(`${dbPath}-shm`);
60
+ }
61
+ catch {
62
+ return true;
63
+ }
64
+ }
38
65
  /**
39
66
  * #1854: previously every site that needed the memory directory hardcoded
40
67
  * `getMemoryRoot()`, so the documented config entry
@@ -2304,6 +2331,22 @@ export async function storeEntry(options) {
2304
2331
  if (!fs.existsSync(dbPath)) {
2305
2332
  return { success: false, id: '', error: 'Database not initialized. Run: claude-flow memory init' };
2306
2333
  }
2334
+ // #2735 — refuse an unsafe whole-image write while a native WAL
2335
+ // connection appears to be attached (sidecars on disk). See
2336
+ // hasNativeWalSidecars()'s doc comment for the corruption mechanism
2337
+ // this closes and its known residual (the narrow assess-then-write
2338
+ // race). This check gates ensureSchemaColumns()'s own whole-image
2339
+ // write below too, not just this function's.
2340
+ if (hasNativeWalSidecars(dbPath)) {
2341
+ return {
2342
+ success: false,
2343
+ id: '',
2344
+ error: 'memory database has an active native WAL connection ' +
2345
+ '(found -wal/-shm sidecar files) — refusing an unsafe sql.js ' +
2346
+ 'whole-image write. Retry once the native writer completes, or ' +
2347
+ 'restore the native better-sqlite3 bridge.',
2348
+ };
2349
+ }
2307
2350
  // Ensure schema has all required columns (migration for older DBs)
2308
2351
  await ensureSchemaColumns(dbPath);
2309
2352
  const initSqlJs = (await import('sql.js')).default;
@@ -2672,6 +2715,20 @@ export async function getEntry(options) {
2672
2715
  if (!fs.existsSync(dbPath)) {
2673
2716
  return { success: false, found: false, error: 'Database not found' };
2674
2717
  }
2718
+ // #2735 — see storeEntry's identical gate for the corruption mechanism
2719
+ // this closes. Applies here too because the fallback's access_count
2720
+ // bump is itself a whole-image write, not a lightweight read, even
2721
+ // though this function's contract reads as a "get".
2722
+ if (hasNativeWalSidecars(dbPath)) {
2723
+ return {
2724
+ success: false,
2725
+ found: false,
2726
+ error: 'memory database has an active native WAL connection ' +
2727
+ '(found -wal/-shm sidecar files) — refusing an unsafe sql.js ' +
2728
+ 'whole-image read/write. Retry once the native writer completes, ' +
2729
+ 'or restore the native better-sqlite3 bridge.',
2730
+ };
2731
+ }
2675
2732
  // Ensure schema has all required columns (migration for older DBs)
2676
2733
  await ensureSchemaColumns(dbPath);
2677
2734
  const initSqlJs = (await import('sql.js')).default;
@@ -2783,6 +2840,21 @@ export async function deleteEntry(options) {
2783
2840
  error: 'Database not found'
2784
2841
  };
2785
2842
  }
2843
+ // #2735 — see storeEntry's identical gate for the corruption mechanism
2844
+ // this closes.
2845
+ if (hasNativeWalSidecars(dbPath)) {
2846
+ return {
2847
+ success: false,
2848
+ deleted: false,
2849
+ key,
2850
+ namespace,
2851
+ remainingEntries: 0,
2852
+ error: 'memory database has an active native WAL connection ' +
2853
+ '(found -wal/-shm sidecar files) — refusing an unsafe sql.js ' +
2854
+ 'whole-image write. Retry once the native writer completes, or ' +
2855
+ 'restore the native better-sqlite3 bridge.',
2856
+ };
2857
+ }
2786
2858
  // Ensure schema has all required columns (migration for older DBs)
2787
2859
  await ensureSchemaColumns(dbPath);
2788
2860
  const initSqlJs = (await import('sql.js')).default;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `ruflo proxy install`/`update` orchestration (ADR-307): download -> verify
3
+ * -> extract -> place -> record. The per-user bearer token
4
+ * (`~/.ruflo/proxy-token`) is NOT generated here — confirmed empirically
5
+ * (2026-07-16) that the meta-proxy binary itself creates it on first launch
6
+ * (`load_or_create_token()`), so this module's job ends at a verified binary
7
+ * on disk plus an install manifest doctor can check against.
8
+ *
9
+ * @module proxy/install
10
+ */
11
+ export declare class ExtractionError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ export interface InstallOptions {
15
+ version: string;
16
+ log?: (line: string) => void;
17
+ }
18
+ export interface InstallResult {
19
+ version: string;
20
+ binaryPath: string;
21
+ sha256: string;
22
+ }
23
+ /**
24
+ * Full install pipeline. Refuses (throws) on any verification failure —
25
+ * never writes a partially-verified binary into the live install path.
26
+ */
27
+ export declare function installProxy(opts: InstallOptions): Promise<InstallResult>;
28
+ export declare function uninstallProxy(): Promise<boolean>;
29
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `ruflo proxy install`/`update` orchestration (ADR-307): download -> verify
3
+ * -> extract -> place -> record. The per-user bearer token
4
+ * (`~/.ruflo/proxy-token`) is NOT generated here — confirmed empirically
5
+ * (2026-07-16) that the meta-proxy binary itself creates it on first launch
6
+ * (`load_or_create_token()`), so this module's job ends at a verified binary
7
+ * on disk plus an install manifest doctor can check against.
8
+ *
9
+ * @module proxy/install
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { fetchReleaseAssets, detectTargetTriple, releaseArchiveExtension, releaseAssetFilename } from './release.js';
15
+ import { verifyRelease, sha256Hex, PROXY_RELEASE_PUBKEY_PEM } from './verify.js';
16
+ import { proxyBinaryPath, proxyInstallManifestPath } from './paths.js';
17
+ export class ExtractionError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'ExtractionError';
21
+ }
22
+ }
23
+ function binaryNameInArchive() {
24
+ return process.platform === 'win32' ? 'meta-proxy.exe' : 'meta-proxy';
25
+ }
26
+ /**
27
+ * Extracts the archive via the OS's own tools — `tar` for `.tar.gz`
28
+ * (present on macOS/Linux/Windows 10+), PowerShell `Expand-Archive`
29
+ * specifically for `.zip` on Windows (not tar's bsdtar zip support — not
30
+ * reliable enough to lean on). Zero new archive-parsing dependency, matching
31
+ * this repo's existing taste for shelling out over adding a parser dep.
32
+ */
33
+ async function extractArchive(archivePath, extractDir, ext) {
34
+ const { SafeExecutor } = await import('@claude-flow/security');
35
+ fs.mkdirSync(extractDir, { recursive: true });
36
+ if (ext === 'tar.gz') {
37
+ const exec = new SafeExecutor({ allowedCommands: ['tar'], timeout: 60_000 });
38
+ const result = await exec.execute('tar', ['xzf', archivePath, '-C', extractDir]);
39
+ if (result.exitCode !== 0) {
40
+ throw new ExtractionError(`tar extraction failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
41
+ }
42
+ return;
43
+ }
44
+ // .zip — PowerShell Expand-Archive, single-quoted literal paths (doubling
45
+ // any embedded single quote per PowerShell string-literal escaping) passed
46
+ // as ONE argv element to -Command. shell:false means no OS shell ever
47
+ // tokenizes this string — only powershell.exe's own parser does.
48
+ const escape = (p) => p.replace(/'/g, "''");
49
+ const command = `Expand-Archive -LiteralPath '${escape(archivePath)}' -DestinationPath '${escape(extractDir)}' -Force`;
50
+ const exec = new SafeExecutor({ allowedCommands: ['powershell', 'powershell.exe'], timeout: 60_000 });
51
+ const result = await exec.execute('powershell', ['-NoProfile', '-NonInteractive', '-Command', command]);
52
+ if (result.exitCode !== 0) {
53
+ throw new ExtractionError(`Expand-Archive failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
54
+ }
55
+ }
56
+ /**
57
+ * Full install pipeline. Refuses (throws) on any verification failure —
58
+ * never writes a partially-verified binary into the live install path.
59
+ */
60
+ export async function installProxy(opts) {
61
+ const log = opts.log ?? (() => { });
62
+ const triple = detectTargetTriple();
63
+ const archiveFilename = releaseAssetFilename(opts.version, triple);
64
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruflo-proxy-install-'));
65
+ try {
66
+ log(`Fetching meta-proxy ${opts.version} (${triple})...`);
67
+ const assets = await fetchReleaseAssets(opts.version, triple, workDir, log);
68
+ log('Verifying release signature and checksum...');
69
+ const { sha256 } = verifyRelease({
70
+ sumsBytes: assets.sumsBytes,
71
+ sigBase64: assets.sigBase64,
72
+ assetBytes: assets.archiveBytes,
73
+ assetFilename: assets.archiveFilename,
74
+ });
75
+ log(`Verified — sha256 ${sha256.slice(0, 16)}…`);
76
+ // fetchReleaseAssets's dev (gh) path already wrote the archive to workDir
77
+ // under archiveFilename; ensure it's there regardless of source so
78
+ // extraction always has a real file to operate on.
79
+ const archivePath = path.join(workDir, archiveFilename);
80
+ if (!fs.existsSync(archivePath)) {
81
+ fs.writeFileSync(archivePath, assets.archiveBytes);
82
+ }
83
+ const extractDir = path.join(workDir, 'extracted');
84
+ await extractArchive(archivePath, extractDir, releaseArchiveExtension(triple));
85
+ const extractedBinaryPath = path.join(extractDir, binaryNameInArchive());
86
+ if (!fs.existsSync(extractedBinaryPath)) {
87
+ throw new ExtractionError(`archive did not contain the expected binary at its root: ${binaryNameInArchive()}`);
88
+ }
89
+ // Defense in depth: confirm the extracted binary genuinely resolves
90
+ // inside extractDir (catches a symlink swap or similar), even though
91
+ // we only ever read one specific expected relative path, never an
92
+ // archive-listed one (so "zip slip" via arbitrary archive paths isn't
93
+ // reachable here in the first place).
94
+ const { PathValidator } = await import('@claude-flow/security');
95
+ const validator = new PathValidator({ allowedPrefixes: [extractDir] });
96
+ const validation = await validator.validate(extractedBinaryPath);
97
+ if (!validation.isValid) {
98
+ throw new ExtractionError(`extracted binary path failed validation: ${validation.errors.join('; ') || 'unknown'}`);
99
+ }
100
+ const finalPath = proxyBinaryPath();
101
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true, mode: 0o700 });
102
+ const tmp = `${finalPath}.tmp`;
103
+ fs.copyFileSync(extractedBinaryPath, tmp);
104
+ fs.chmodSync(tmp, 0o755);
105
+ fs.renameSync(tmp, finalPath);
106
+ const liveSha = sha256Hex(fs.readFileSync(finalPath));
107
+ const manifest = {
108
+ version: opts.version,
109
+ sha256: liveSha,
110
+ verifiedAt: new Date().toISOString(),
111
+ pubkeyFingerprint: sha256Hex(Buffer.from(PROXY_RELEASE_PUBKEY_PEM)).slice(0, 16),
112
+ };
113
+ fs.mkdirSync(path.dirname(proxyInstallManifestPath()), { recursive: true });
114
+ fs.writeFileSync(proxyInstallManifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
115
+ log(`meta-proxy ${opts.version} installed at ${finalPath}`);
116
+ return { version: opts.version, binaryPath: finalPath, sha256: liveSha };
117
+ }
118
+ finally {
119
+ fs.rmSync(workDir, { recursive: true, force: true });
120
+ }
121
+ }
122
+ export async function uninstallProxy() {
123
+ const binPath = proxyBinaryPath();
124
+ const existed = fs.existsSync(binPath);
125
+ if (existed)
126
+ fs.unlinkSync(binPath);
127
+ try {
128
+ fs.unlinkSync(proxyInstallManifestPath());
129
+ }
130
+ catch {
131
+ /* absent — fine */
132
+ }
133
+ return existed;
134
+ }
135
+ //# sourceMappingURL=install.js.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * meta-proxy process lifecycle (ADR-307) — start/stop/status/logs.
3
+ *
4
+ * Adapts daemon.ts's proven pattern (PID file, O_EXCL lockfile for atomic
5
+ * check-then-start, signal-0 liveness, SIGTERM->1000ms->SIGKILL) to a
6
+ * native binary instead of a forked Node process. The binary itself takes
7
+ * no CLI flags — confirmed empirically (2026-07-16): `meta-proxy.exe` has
8
+ * no `--version`/`--help`, and any invocation just starts the server reading
9
+ * its own config file — so `spawn()` here passes zero arguments, always.
10
+ *
11
+ * Foreground `start` (the ADR-307 default) uses `stdio: 'inherit'` and
12
+ * blocks directly — simplest and safest, no log-file redirection needed.
13
+ * `start --service` needs REAL file-descriptor redirection
14
+ * (`stdio: ['ignore', fd, fd]`) + `detached: true` + `unref()` via
15
+ * `child_process.spawn()` directly — `SafeExecutor.executeStreaming()`
16
+ * buffers output in-process, which is wrong for a process meant to outlive
17
+ * the `ruflo` invocation that started it.
18
+ *
19
+ * @module proxy/lifecycle
20
+ */
21
+ import * as fs from 'node:fs';
22
+ export declare class ProxyNotInstalledError extends Error {
23
+ constructor();
24
+ }
25
+ export declare class ProxyAlreadyRunningError extends Error {
26
+ readonly pid: number;
27
+ constructor(pid: number);
28
+ }
29
+ export interface ProxyStatus {
30
+ installed: boolean;
31
+ running: boolean;
32
+ pid: number | null;
33
+ stalePidFile: boolean;
34
+ }
35
+ export declare function getProxyStatus(): ProxyStatus;
36
+ /**
37
+ * Foreground start (ADR-307 default) — blocks the caller until the process
38
+ * exits or is interrupted. `stdio: 'inherit'` passes the proxy's own output
39
+ * straight through to the terminal; signals (Ctrl+C) propagate naturally to
40
+ * the child, no manual forwarding needed.
41
+ */
42
+ export declare function startForeground(supervised?: boolean): Promise<never>;
43
+ /**
44
+ * Background/`--service` start — detaches so the process outlives this
45
+ * `ruflo` invocation, redirecting stdout/stderr to a real log file (not
46
+ * buffered in-process). Returns once the child's PID is confirmed written,
47
+ * without waiting for the process to exit.
48
+ */
49
+ export declare function startBackground(): Promise<{
50
+ pid: number;
51
+ }>;
52
+ export interface StopResult {
53
+ wasRunning: boolean;
54
+ pid: number | null;
55
+ }
56
+ /** SIGTERM -> 1000ms -> SIGKILL if still alive, mirroring daemon.ts's killBackgroundDaemon. */
57
+ export declare function stopProxy(): Promise<StopResult>;
58
+ export declare function readProxyLogTail(maxBytes?: number): string;
59
+ /** Streams new log lines as they're appended, starting from the current end of file. */
60
+ export declare function watchProxyLog(onLine: (line: string) => void): fs.FSWatcher;
61
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1,249 @@
1
+ /**
2
+ * meta-proxy process lifecycle (ADR-307) — start/stop/status/logs.
3
+ *
4
+ * Adapts daemon.ts's proven pattern (PID file, O_EXCL lockfile for atomic
5
+ * check-then-start, signal-0 liveness, SIGTERM->1000ms->SIGKILL) to a
6
+ * native binary instead of a forked Node process. The binary itself takes
7
+ * no CLI flags — confirmed empirically (2026-07-16): `meta-proxy.exe` has
8
+ * no `--version`/`--help`, and any invocation just starts the server reading
9
+ * its own config file — so `spawn()` here passes zero arguments, always.
10
+ *
11
+ * Foreground `start` (the ADR-307 default) uses `stdio: 'inherit'` and
12
+ * blocks directly — simplest and safest, no log-file redirection needed.
13
+ * `start --service` needs REAL file-descriptor redirection
14
+ * (`stdio: ['ignore', fd, fd]`) + `detached: true` + `unref()` via
15
+ * `child_process.spawn()` directly — `SafeExecutor.executeStreaming()`
16
+ * buffers output in-process, which is wrong for a process meant to outlive
17
+ * the `ruflo` invocation that started it.
18
+ *
19
+ * @module proxy/lifecycle
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import * as fs from 'node:fs';
23
+ import { proxyBinaryPath, proxyPidFilePath, proxyLockFilePath, proxyLogFilePath } from './paths.js';
24
+ export class ProxyNotInstalledError extends Error {
25
+ constructor() {
26
+ super('meta-proxy is not installed. Run: ruflo proxy install');
27
+ this.name = 'ProxyNotInstalledError';
28
+ }
29
+ }
30
+ export class ProxyAlreadyRunningError extends Error {
31
+ pid;
32
+ constructor(pid) {
33
+ super(`meta-proxy is already running (pid ${pid}). Stop it first with: ruflo proxy stop`);
34
+ this.pid = pid;
35
+ this.name = 'ProxyAlreadyRunningError';
36
+ }
37
+ }
38
+ function requireBinary() {
39
+ const bin = proxyBinaryPath();
40
+ if (!fs.existsSync(bin))
41
+ throw new ProxyNotInstalledError();
42
+ return bin;
43
+ }
44
+ /** Signal-0 liveness probe — cross-platform in Node, throws if the process is dead. */
45
+ function isProcessRunning(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export function getProxyStatus() {
55
+ const installed = fs.existsSync(proxyBinaryPath());
56
+ const pidPath = proxyPidFilePath();
57
+ if (!fs.existsSync(pidPath)) {
58
+ return { installed, running: false, pid: null, stalePidFile: false };
59
+ }
60
+ const raw = fs.readFileSync(pidPath, 'utf-8').trim();
61
+ const pid = parseInt(raw, 10);
62
+ if (!Number.isFinite(pid)) {
63
+ return { installed, running: false, pid: null, stalePidFile: true };
64
+ }
65
+ const running = isProcessRunning(pid);
66
+ return { installed, running, pid: running ? pid : null, stalePidFile: !running };
67
+ }
68
+ function writePidFile(pid) {
69
+ fs.writeFileSync(proxyPidFilePath(), String(pid), 'utf-8');
70
+ }
71
+ function clearStalePidFile() {
72
+ try {
73
+ fs.unlinkSync(proxyPidFilePath());
74
+ }
75
+ catch {
76
+ // already absent
77
+ }
78
+ }
79
+ /**
80
+ * Atomic check-then-start via an O_EXCL lockfile — the same fix daemon.ts
81
+ * applies for the identical race (#2407/#2484: without this, N concurrent
82
+ * `start` calls all see "not running" and all spawn their own process).
83
+ * Returns the lock fd to release once the PID file has been written (or on
84
+ * any early-return/throw), never before.
85
+ */
86
+ function acquireStartLock() {
87
+ const lockFile = proxyLockFilePath();
88
+ try {
89
+ return fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
90
+ }
91
+ catch (e) {
92
+ if (e.code === 'EEXIST')
93
+ return null; // another start is mid-spawn
94
+ throw e;
95
+ }
96
+ }
97
+ function releaseStartLock(fd) {
98
+ if (fd === null)
99
+ return;
100
+ try {
101
+ fs.closeSync(fd);
102
+ }
103
+ catch {
104
+ /* ignore */
105
+ }
106
+ try {
107
+ fs.unlinkSync(proxyLockFilePath());
108
+ }
109
+ catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+ /**
114
+ * Foreground start (ADR-307 default) — blocks the caller until the process
115
+ * exits or is interrupted. `stdio: 'inherit'` passes the proxy's own output
116
+ * straight through to the terminal; signals (Ctrl+C) propagate naturally to
117
+ * the child, no manual forwarding needed.
118
+ */
119
+ export async function startForeground(supervised = false) {
120
+ const bin = requireBinary();
121
+ const status = getProxyStatus();
122
+ // In service mode startBackground has already written this supervisor's
123
+ // PID. Treating it as a competing proxy makes the supervisor immediately
124
+ // exit before it can spawn meta-proxy.
125
+ if (!supervised && status.running && status.pid)
126
+ throw new ProxyAlreadyRunningError(status.pid);
127
+ if (status.stalePidFile)
128
+ clearStalePidFile();
129
+ const child = spawn(bin, [], { stdio: 'inherit', windowsHide: false });
130
+ if (!supervised && child.pid)
131
+ writePidFile(child.pid);
132
+ const cleanup = () => clearStalePidFile();
133
+ process.on('exit', cleanup);
134
+ if (supervised) {
135
+ const forwardSignal = (signal) => {
136
+ if (!child.killed)
137
+ child.kill(signal);
138
+ };
139
+ process.once('SIGTERM', () => forwardSignal('SIGTERM'));
140
+ process.once('SIGINT', () => forwardSignal('SIGINT'));
141
+ }
142
+ await new Promise((resolve) => {
143
+ child.on('exit', () => {
144
+ cleanup();
145
+ resolve();
146
+ });
147
+ });
148
+ process.exit(0);
149
+ }
150
+ /**
151
+ * Background/`--service` start — detaches so the process outlives this
152
+ * `ruflo` invocation, redirecting stdout/stderr to a real log file (not
153
+ * buffered in-process). Returns once the child's PID is confirmed written,
154
+ * without waiting for the process to exit.
155
+ */
156
+ export async function startBackground() {
157
+ requireBinary();
158
+ const status = getProxyStatus();
159
+ if (status.running && status.pid)
160
+ throw new ProxyAlreadyRunningError(status.pid);
161
+ const lockFd = acquireStartLock();
162
+ try {
163
+ // Re-check under the lock — the dedup above raced a concurrent starter.
164
+ const rechecked = getProxyStatus();
165
+ if (rechecked.running && rechecked.pid)
166
+ throw new ProxyAlreadyRunningError(rechecked.pid);
167
+ if (rechecked.stalePidFile)
168
+ clearStalePidFile();
169
+ const logFd = fs.openSync(proxyLogFilePath(), 'a');
170
+ const cliEntry = process.argv[1];
171
+ if (!cliEntry)
172
+ throw new Error('cannot locate the ruflo CLI entrypoint for supervised service mode');
173
+ const child = spawn(process.execPath, [cliEntry, 'proxy', 'supervise'], {
174
+ stdio: ['ignore', logFd, logFd],
175
+ detached: true,
176
+ windowsHide: true,
177
+ });
178
+ fs.closeSync(logFd); // the child holds its own duplicated fd; safe to close ours
179
+ if (!child.pid)
180
+ throw new Error('ruflo proxy supervisor failed to spawn — no PID returned');
181
+ writePidFile(child.pid);
182
+ child.unref();
183
+ return { pid: child.pid };
184
+ }
185
+ finally {
186
+ releaseStartLock(lockFd);
187
+ }
188
+ }
189
+ /** SIGTERM -> 1000ms -> SIGKILL if still alive, mirroring daemon.ts's killBackgroundDaemon. */
190
+ export async function stopProxy() {
191
+ const status = getProxyStatus();
192
+ if (!status.running || !status.pid) {
193
+ if (status.stalePidFile)
194
+ clearStalePidFile();
195
+ return { wasRunning: false, pid: null };
196
+ }
197
+ const pid = status.pid;
198
+ process.kill(pid, 'SIGTERM');
199
+ await new Promise((r) => setTimeout(r, 1000));
200
+ if (isProcessRunning(pid)) {
201
+ process.kill(pid, 'SIGKILL');
202
+ }
203
+ clearStalePidFile();
204
+ return { wasRunning: true, pid };
205
+ }
206
+ const TAIL_BYTES = 64 * 1024; // bounded read — a long-running proxy's log grows over weeks
207
+ export function readProxyLogTail(maxBytes = TAIL_BYTES) {
208
+ const logPath = proxyLogFilePath();
209
+ if (!fs.existsSync(logPath))
210
+ return '';
211
+ const { size } = fs.statSync(logPath);
212
+ const start = Math.max(0, size - maxBytes);
213
+ const fd = fs.openSync(logPath, 'r');
214
+ try {
215
+ const buf = Buffer.alloc(size - start);
216
+ fs.readSync(fd, buf, 0, buf.length, start);
217
+ return buf.toString('utf-8');
218
+ }
219
+ finally {
220
+ fs.closeSync(fd);
221
+ }
222
+ }
223
+ /** Streams new log lines as they're appended, starting from the current end of file. */
224
+ export function watchProxyLog(onLine) {
225
+ const logPath = proxyLogFilePath();
226
+ if (!fs.existsSync(logPath)) {
227
+ throw new Error('no log file yet — meta-proxy has never been started in --service mode');
228
+ }
229
+ let offset = fs.statSync(logPath).size;
230
+ return fs.watch(logPath, () => {
231
+ const { size } = fs.statSync(logPath);
232
+ if (size <= offset)
233
+ return;
234
+ const fd = fs.openSync(logPath, 'r');
235
+ try {
236
+ const buf = Buffer.alloc(size - offset);
237
+ fs.readSync(fd, buf, 0, buf.length, offset);
238
+ offset = size;
239
+ for (const line of buf.toString('utf-8').split('\n')) {
240
+ if (line.length > 0)
241
+ onLine(line);
242
+ }
243
+ }
244
+ finally {
245
+ fs.closeSync(fd);
246
+ }
247
+ });
248
+ }
249
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Filesystem layout for the managed meta-proxy binary (ADR-307), all under
3
+ * the same `~/.ruflo` state dir every other funnel/proxy/auth file uses
4
+ * (src/funnel/state.ts's `funnelStateDir()` — respects RUFLO_STATE_DIR for
5
+ * tests). Centralized here so doctor.ts, install.ts, and lifecycle.ts share
6
+ * one definition instead of three copies drifting apart.
7
+ */
8
+ export declare function proxyBinaryPath(): string;
9
+ export declare function proxyPidFilePath(): string;
10
+ export declare function proxyLockFilePath(): string;
11
+ export declare function proxyLogFilePath(): string;
12
+ export declare function proxyInstallManifestPath(): string;
13
+ export declare function proxyConfigPath(): string;
14
+ export declare function proxyTokenPath(): string;
15
+ export declare function proxyInjectedTokenPath(): string;
16
+ export interface InstallManifest {
17
+ version: string;
18
+ sha256: string;
19
+ verifiedAt: string;
20
+ pubkeyFingerprint: string;
21
+ }
22
+ /**
23
+ * True if `bind` (a `host:port` string from proxy-config.toml) targets a
24
+ * loopback address — decides whether the non-loopback exposure warning
25
+ * fires (ADR-307). Recognizes the whole IPv4 127.0.0.0/8 block, IPv6 `::1`,
26
+ * IPv4-mapped IPv6 loopback, and the `localhost` hostname.
27
+ *
28
+ * A plain `bind.startsWith('127.0.0.1')` check (the obvious first draft)
29
+ * misclassifies `[::1]:port` as non-loopback — a real bug meta-proxy's own
30
+ * Rust `is_loopback_bind` fixed for the identical reason; this mirrors that
31
+ * fix rather than repeating the mistake on the TypeScript side.
32
+ */
33
+ export declare function isLoopbackBind(bind: string): boolean;
34
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Filesystem layout for the managed meta-proxy binary (ADR-307), all under
3
+ * the same `~/.ruflo` state dir every other funnel/proxy/auth file uses
4
+ * (src/funnel/state.ts's `funnelStateDir()` — respects RUFLO_STATE_DIR for
5
+ * tests). Centralized here so doctor.ts, install.ts, and lifecycle.ts share
6
+ * one definition instead of three copies drifting apart.
7
+ */
8
+ import { join } from 'node:path';
9
+ import { funnelStateDir } from '../funnel/index.js';
10
+ const BINARY_NAME = process.platform === 'win32' ? 'meta-proxy.exe' : 'meta-proxy';
11
+ export function proxyBinaryPath() {
12
+ return join(funnelStateDir(), 'bin', BINARY_NAME);
13
+ }
14
+ export function proxyPidFilePath() {
15
+ return join(funnelStateDir(), 'proxy.pid');
16
+ }
17
+ export function proxyLockFilePath() {
18
+ return join(funnelStateDir(), 'proxy.lock');
19
+ }
20
+ export function proxyLogFilePath() {
21
+ return join(funnelStateDir(), 'proxy.log');
22
+ }
23
+ export function proxyInstallManifestPath() {
24
+ return join(funnelStateDir(), 'proxy', 'install-manifest.json');
25
+ }
26
+ export function proxyConfigPath() {
27
+ return join(funnelStateDir(), 'proxy-config.toml');
28
+ }
29
+ export function proxyTokenPath() {
30
+ return join(funnelStateDir(), 'proxy-token');
31
+ }
32
+ export function proxyInjectedTokenPath() {
33
+ return join(funnelStateDir(), 'proxy-injected-token.json');
34
+ }
35
+ /**
36
+ * True if `bind` (a `host:port` string from proxy-config.toml) targets a
37
+ * loopback address — decides whether the non-loopback exposure warning
38
+ * fires (ADR-307). Recognizes the whole IPv4 127.0.0.0/8 block, IPv6 `::1`,
39
+ * IPv4-mapped IPv6 loopback, and the `localhost` hostname.
40
+ *
41
+ * A plain `bind.startsWith('127.0.0.1')` check (the obvious first draft)
42
+ * misclassifies `[::1]:port` as non-loopback — a real bug meta-proxy's own
43
+ * Rust `is_loopback_bind` fixed for the identical reason; this mirrors that
44
+ * fix rather than repeating the mistake on the TypeScript side.
45
+ */
46
+ export function isLoopbackBind(bind) {
47
+ const hostPart = (host) => {
48
+ const stripped = host.replace(/^\[/, '').replace(/\]$/, '');
49
+ if (stripped.toLowerCase() === 'localhost')
50
+ return true;
51
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(stripped))
52
+ return true; // 127.0.0.0/8
53
+ if (stripped === '::1')
54
+ return true;
55
+ if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/i.test(stripped))
56
+ return true; // IPv4-mapped loopback
57
+ return false;
58
+ };
59
+ // IPv6 bracketed form: [::1]:11435
60
+ const bracketed = bind.match(/^\[([^\]]+)\]:\d+$/);
61
+ if (bracketed)
62
+ return hostPart(bracketed[1]);
63
+ // host:port (IPv4 or hostname) — split on the LAST colon so a bare IPv6
64
+ // address without brackets (ambiguous, shouldn't occur in practice) isn't
65
+ // misparsed as host:port.
66
+ const lastColon = bind.lastIndexOf(':');
67
+ const host = lastColon === -1 ? bind : bind.slice(0, lastColon);
68
+ return hostPart(host);
69
+ }
70
+ //# sourceMappingURL=paths.js.map