saros-proxy 0.10.2 → 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.
Files changed (59) hide show
  1. package/README.md +175 -29
  2. package/dist/cli/daemon.d.ts +38 -6
  3. package/dist/cli/daemon.js +524 -109
  4. package/dist/cli/daemon.js.map +1 -1
  5. package/dist/cli/dsh-config.js +34 -9
  6. package/dist/cli/dsh-config.js.map +1 -1
  7. package/dist/cli/help.js +3 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/key-state.d.ts +21 -0
  10. package/dist/cli/key-state.js +57 -0
  11. package/dist/cli/key-state.js.map +1 -0
  12. package/dist/cli/keys.d.ts +28 -0
  13. package/dist/cli/keys.js +185 -0
  14. package/dist/cli/keys.js.map +1 -0
  15. package/dist/cli/overview.js +6 -1
  16. package/dist/cli/overview.js.map +1 -1
  17. package/dist/cli/secret.d.ts +31 -0
  18. package/dist/cli/secret.js +89 -0
  19. package/dist/cli/secret.js.map +1 -0
  20. package/dist/cli/usage.js +7 -0
  21. package/dist/cli/usage.js.map +1 -1
  22. package/dist/config.d.ts +15 -1
  23. package/dist/config.js +98 -25
  24. package/dist/config.js.map +1 -1
  25. package/dist/constants.d.ts +2 -0
  26. package/dist/constants.js +2 -0
  27. package/dist/constants.js.map +1 -1
  28. package/dist/debug-buffer.d.ts +58 -0
  29. package/dist/debug-buffer.js +42 -0
  30. package/dist/debug-buffer.js.map +1 -0
  31. package/dist/index.js +30 -4
  32. package/dist/index.js.map +1 -1
  33. package/dist/log-redact.d.ts +18 -0
  34. package/dist/log-redact.js +41 -0
  35. package/dist/log-redact.js.map +1 -0
  36. package/dist/logger.d.ts +6 -0
  37. package/dist/logger.js +30 -0
  38. package/dist/logger.js.map +1 -1
  39. package/dist/providers/infer.d.ts +11 -0
  40. package/dist/providers/infer.js.map +1 -1
  41. package/dist/proxy-logic.d.ts +74 -3
  42. package/dist/proxy-logic.js +111 -9
  43. package/dist/proxy-logic.js.map +1 -1
  44. package/dist/proxy.d.ts +33 -1
  45. package/dist/proxy.js +328 -45
  46. package/dist/proxy.js.map +1 -1
  47. package/dist/secrets/boot.d.ts +33 -0
  48. package/dist/secrets/boot.js +65 -0
  49. package/dist/secrets/boot.js.map +1 -0
  50. package/dist/secrets/envelope.d.ts +44 -0
  51. package/dist/secrets/envelope.js +132 -0
  52. package/dist/secrets/envelope.js.map +1 -0
  53. package/dist/secrets/source.d.ts +130 -0
  54. package/dist/secrets/source.js +332 -0
  55. package/dist/secrets/source.js.map +1 -0
  56. package/dist/validation.d.ts +3 -3
  57. package/dist/validation.js +3 -3
  58. package/dist/validation.js.map +1 -1
  59. package/package.json +1 -1
@@ -9,30 +9,70 @@
9
9
  *
10
10
  * Daemon lifecycle:
11
11
  * start → refuses if the port is already serving, then spawns detached
12
- * `node dist/index.js` process, saves PID, logs child stderr
13
- * stop → kills the process (taskkill /T /F on Windows), removes PID file
14
- * restart → kills running instance (if any), waits, then starts fresh
12
+ * `node dist/index.js` process, saves PID, appends child stdout
13
+ * and stderr to daemon.log
14
+ * stop → kills the process (taskkill /T /F on Windows) and removes the
15
+ * PID file only once the process is confirmed gone; exits 1 when
16
+ * something still serves the port
17
+ * restart → kills running instance (if any), waits for it to disappear,
18
+ * then starts fresh
15
19
  * status → checks PID file, falls back to an os-agnostic /health port
16
20
  * probe (instances started outside the daemon have no PID file),
17
21
  * and prints the enabled harnesses
18
22
  */
19
23
  import { spawn, execFileSync } from 'node:child_process';
20
24
  import chalk from 'chalk';
21
- import { existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, copyFileSync, createWriteStream } from 'node:fs';
22
- import { resolve as pathResolve, join as pathJoin } from 'node:path';
25
+ import { existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, copyFileSync, statSync, renameSync, openSync, closeSync, chmodSync, readSync, fstatSync, realpathSync } from 'node:fs';
26
+ import { resolve as pathResolve, join as pathJoin, relative as pathRelative, isAbsolute as pathIsAbsolute, dirname as pathDirname, basename as pathBasename, sep as pathSep, } from 'node:path';
23
27
  import { homedir } from 'node:os';
24
28
  import { fileURLToPath } from 'node:url';
25
29
  import { getModelsJsonPath } from './opencode-config.js';
30
+ import { formatKeyBlock } from './key-state.js';
26
31
  import { checkForUpdate } from './update-check.js';
27
- import { loadConfig } from '../config.js';
32
+ import { loadConfig, getSarosDir } from '../config.js';
28
33
  import { DAEMON_SYNC_TIMEOUT_MS } from '../constants.js';
29
34
  import { syncModelsInAllHarnesses, readHarnessSettings } from './harness-sync.js';
30
35
  // ---------------------------------------------------------------------------
31
36
  // Constants
32
37
  // ---------------------------------------------------------------------------
33
- const PID_DIR = pathJoin(homedir(), '.config', 'saros');
38
+ /**
39
+ * State dir: same resolver as config.yaml, so on Windows everything (config,
40
+ * pid, log) lives under %LOCALAPPDATA%\saros — the pid file used to sit in
41
+ * ~/.config/saros there while the config sat elsewhere.
42
+ */
43
+ function resolveStateDir() {
44
+ try {
45
+ return getSarosDir();
46
+ }
47
+ catch {
48
+ // No determinable home (headless test env): the legacy shape keeps the
49
+ // CLI importable instead of failing before it can report anything.
50
+ return pathJoin(homedir(), '.config', 'saros');
51
+ }
52
+ }
53
+ const PID_DIR = resolveStateDir();
34
54
  const PID_FILE = pathJoin(PID_DIR, 'daemon.pid');
35
55
  const DAEMON_LOG = pathJoin(PID_DIR, 'daemon.log');
56
+ /**
57
+ * Pre-0.11 location. Transition path, and OPT-IN: this path sits in the
58
+ * user's real home, so an isolated run (a test with a temp XDG_CONFIG_HOME but
59
+ * the real HOME) would otherwise act on the developer's live daemon — it did,
60
+ * killing a running proxy from a test. Reading, migrating and deleting it all
61
+ * require SAROS_ALLOW_LEGACY_PID=1; without the flag the file is inert.
62
+ * null when the state dir already *is* that location (POSIX default).
63
+ */
64
+ const LEGACY_PID_FILE = resolveLegacyPidFile();
65
+ /** Legacy PID handling is explicit operator intent, never a silent fallback. */
66
+ function legacyPidEnabled() {
67
+ return process.env.SAROS_ALLOW_LEGACY_PID === '1';
68
+ }
69
+ function resolveLegacyPidFile() {
70
+ const legacy = pathJoin(homedir(), '.config', 'saros', 'daemon.pid');
71
+ return pathResolve(legacy) === pathResolve(PID_FILE) ? null : legacy;
72
+ }
73
+ /** daemon.log is rotated at start: 10 MB per file, 5 kept. */
74
+ const LOG_MAX_BYTES = 10 * 1024 * 1024;
75
+ const LOG_KEEP = 5;
36
76
  const PACKAGE_ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
37
77
  const ENTRY_POINT = pathResolve(PACKAGE_ROOT, 'dist', 'index.js');
38
78
  // ---------------------------------------------------------------------------
@@ -75,6 +115,94 @@ function getEnv(name) {
75
115
  return process.env[name] ?? readWindowsUserEnv(name);
76
116
  }
77
117
  // ---------------------------------------------------------------------------
118
+ // Test-run guard
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * test/setup-env.ts publishes the account's real home here before any src
122
+ * module is evaluated. `userInfo().homedir` is the one value a suite cannot
123
+ * fake, which makes the guard below independent of environment isolation.
124
+ */
125
+ const REAL_HOME_GLOBAL = '__SAROS_REAL_HOME__';
126
+ /** A vitest / `NODE_ENV=test` run: the real installation's handles are not ours. */
127
+ const TEST_RUN = process.env.NODE_ENV === 'test' || process.env.VITEST !== undefined;
128
+ /**
129
+ * Refuse a PID path inside a real installation's state dir while under test.
130
+ *
131
+ * #104: a suite with a temporary config dir but the real HOME read
132
+ * `~/.config/saros/daemon.pid`, and `stop` killed the live daemon. The env
133
+ * isolation in test/setup-env.ts covers the normal path, but any suite that
134
+ * re-registers `node:os` (`test/config.test.ts` did) or drops
135
+ * XDG_CONFIG_HOME resolves the real home again. This makes the incident
136
+ * structural: under test, a file in the real installation's state dir is
137
+ * never read, never removed and never written, and a PID only reaches
138
+ * killProcess through this read.
139
+ *
140
+ * The two dirs are the ones a real installation can use (`~/.config/saros`,
141
+ * `~/AppData/Local/saros`), not "anything under $HOME": temp dirs can live
142
+ * under the home (TMPDIR=/home/<user>/.cache/...), and refusing those would
143
+ * only break the isolation it is meant to prove.
144
+ */
145
+ function pidPathRefused(file) {
146
+ if (!TEST_RUN)
147
+ return false;
148
+ const realHome = globalThis[REAL_HOME_GLOBAL];
149
+ if (typeof realHome !== 'string' || realHome === '')
150
+ return false;
151
+ // Compare real locations, not spellings: an XDG_CONFIG_HOME symlinked into
152
+ // the real install passed the lexical check and the test happily killed the
153
+ // live daemon.
154
+ const target = canonicalPath(file);
155
+ const realDirs = [
156
+ pathJoin(realHome, '.config', 'saros'),
157
+ pathJoin(realHome, 'AppData', 'Local', 'saros'),
158
+ ];
159
+ return realDirs.some((dir) => isBelow(canonicalPath(dir), target));
160
+ }
161
+ /**
162
+ * `file` with symlinks resolved, or the longest existing prefix of it with
163
+ * the missing tail kept lexical. A pid or log file usually does not exist
164
+ * yet, and `realpathSync` throws on a missing file — but its directory does,
165
+ * and that is what a symlinked state dir hides behind.
166
+ */
167
+ function canonicalPath(file) {
168
+ let current = pathResolve(file);
169
+ let rest = '';
170
+ for (;;) {
171
+ try {
172
+ const real = realpathSync(current);
173
+ return rest === '' ? real : pathJoin(real, rest);
174
+ }
175
+ catch {
176
+ const parent = pathDirname(current);
177
+ // The root always exists: a failure there means realpathSync refused
178
+ // for another reason (EACCES, ELOOP), so keep the lexical path instead
179
+ // of walking in circles.
180
+ if (parent === current)
181
+ return pathResolve(file);
182
+ rest = rest === '' ? pathBasename(current) : pathJoin(pathBasename(current), rest);
183
+ current = parent;
184
+ }
185
+ }
186
+ }
187
+ /** Is `target` strictly below `dir`? Both are absolute, canonical paths. */
188
+ function isBelow(dir, target) {
189
+ const rel = pathRelative(dir, target);
190
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${pathSep}`) && !pathIsAbsolute(rel);
191
+ }
192
+ /**
193
+ * The one gate for every path this module touches — pid file, daemon.log,
194
+ * models.json, and the legacy pid file on the opt-in upgrade path. Read,
195
+ * remove and write all pass through here; returning false means the caller
196
+ * must leave the path alone and take its no-op branch (null, silent return or
197
+ * exit 1). Inert outside a test run, where pidPathRefused is always false.
198
+ */
199
+ function assertStateDir(file, action) {
200
+ if (!pidPathRefused(file))
201
+ return true;
202
+ console.error(chalk.red(`✗ Refusing to ${action} ${file}: a test run must never touch the real home. Point HOME and XDG_CONFIG_HOME at a temp dir.`));
203
+ return false;
204
+ }
205
+ // ---------------------------------------------------------------------------
78
206
  // PID file management
79
207
  // ---------------------------------------------------------------------------
80
208
  function ensurePidDir() {
@@ -82,32 +210,183 @@ function ensurePidDir() {
82
210
  mkdirSync(PID_DIR, { recursive: true });
83
211
  }
84
212
  }
85
- function readPid() {
213
+ /** PID digits, or null when the file is missing/empty/unusable. */
214
+ function parsePid(raw) {
215
+ if (raw === null)
216
+ return null;
217
+ const pid = Number(raw);
218
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
219
+ }
220
+ function readPidFile(file) {
221
+ if (file === null)
222
+ return null;
223
+ if (!assertStateDir(file, 'read'))
224
+ return null;
86
225
  try {
87
- return readFileSync(PID_FILE, 'utf-8').trim();
226
+ return readFileSync(file, 'utf-8').trim() || null;
88
227
  }
89
228
  catch {
90
229
  return null;
91
230
  }
92
231
  }
232
+ /**
233
+ * The running PID: the current state dir first. The pre-0.11 path is consulted
234
+ * only when the operator sets SAROS_ALLOW_LEGACY_PID=1 — it lives in the real
235
+ * home, so treating it as a default fallback made isolated runs kill a live
236
+ * daemon. With the flag on, a live legacy PID is migrated into the state dir.
237
+ */
238
+ function readPid() {
239
+ const current = readPidFile(PID_FILE);
240
+ if (current !== null)
241
+ return { pid: current, file: PID_FILE };
242
+ if (LEGACY_PID_FILE === null || !legacyPidEnabled())
243
+ return null;
244
+ const legacy = readPidFile(LEGACY_PID_FILE);
245
+ if (legacy === null)
246
+ return null;
247
+ return { pid: legacy, file: migrateLegacyPid(legacy, LEGACY_PID_FILE) };
248
+ }
249
+ /**
250
+ * Move an opt-in legacy PID into the state dir. Returns the file that holds
251
+ * the handle afterwards: a read-only state dir leaves it where it was.
252
+ */
253
+ function migrateLegacyPid(pid, legacyFile) {
254
+ if (!legacyPidEnabled())
255
+ return legacyFile;
256
+ if (!assertStateDir(PID_FILE, 'write') || !assertStateDir(legacyFile, 'remove')) {
257
+ return legacyFile;
258
+ }
259
+ const pidNum = parsePid(pid);
260
+ if (pidNum === null || !isProcessAlive(pidNum))
261
+ return legacyFile;
262
+ try {
263
+ ensurePidDir();
264
+ writeFileSync(PID_FILE, pid, 'utf-8');
265
+ rmSync(legacyFile, { force: true });
266
+ return PID_FILE;
267
+ }
268
+ catch {
269
+ return legacyFile;
270
+ }
271
+ }
93
272
  function writePid(pid) {
273
+ if (!assertStateDir(PID_FILE, 'write'))
274
+ return;
94
275
  ensurePidDir();
95
276
  writeFileSync(PID_FILE, String(pid), 'utf-8');
96
277
  }
97
- function deletePid() {
278
+ /**
279
+ * Remove one PID file: the one whose PID this process acted on. Removing both
280
+ * used to drop a live legacy instance's handle whenever the current file was
281
+ * the stale one.
282
+ */
283
+ function removePidFile(file) {
284
+ if (file === null || !assertStateDir(file, 'remove'))
285
+ return;
98
286
  try {
99
- rmSync(PID_FILE);
287
+ rmSync(file, { force: true });
100
288
  }
101
289
  catch {
102
290
  // ignore
103
291
  }
104
292
  }
293
+ /**
294
+ * Rotate daemon.log at start when it grew past LOG_MAX_BYTES:
295
+ * daemon.log.5 is dropped, .4→.5 … .1→.2, and daemon.log becomes .1.
296
+ *
297
+ * Rotation at boot keeps the logging path dependency-free. A local proxy
298
+ * restarts far more often than it writes 10 MB, so mid-run rotation buys
299
+ * nothing but a runtime dependency.
300
+ */
301
+ function rotateDaemonLog() {
302
+ if (!assertStateDir(DAEMON_LOG, 'write'))
303
+ return;
304
+ try {
305
+ if (!existsSync(DAEMON_LOG))
306
+ return;
307
+ if (statSync(DAEMON_LOG).size < LOG_MAX_BYTES)
308
+ return;
309
+ rmSync(`${DAEMON_LOG}.${LOG_KEEP}`, { force: true });
310
+ // Shift newest-first: .4→.5 … .1→.2, then the live log becomes .1. The
311
+ // loop used to stop at i=4, so .4→.5 never happened and the newest
312
+ // generation was silently dropped while .5 disappeared.
313
+ for (let i = LOG_KEEP; i >= 2; i--) {
314
+ const from = `${DAEMON_LOG}.${i - 1}`;
315
+ if (existsSync(from))
316
+ renameSync(from, `${DAEMON_LOG}.${i}`);
317
+ }
318
+ if (existsSync(DAEMON_LOG))
319
+ renameSync(DAEMON_LOG, `${DAEMON_LOG}.1`);
320
+ }
321
+ catch {
322
+ // Diagnostics only — a failed rotation must never block startup.
323
+ }
324
+ }
325
+ /**
326
+ * Open daemon.log for append (0600 — it carries upstream error bodies).
327
+ * Returns null when the file cannot be opened, so callers fall back to
328
+ * discarding output. Used for the child's stdout and stderr, which share this
329
+ * single file. Exported so the wiring can be asserted in tests.
330
+ */
331
+ export function openDaemonLog() {
332
+ if (!assertStateDir(DAEMON_LOG, 'write'))
333
+ return null;
334
+ try {
335
+ ensurePidDir();
336
+ // mode only applies on create; an existing 0644 log from an older version
337
+ // keeps pino output world-readable unless we chmod it explicitly.
338
+ const fd = openSync(DAEMON_LOG, 'a', 0o600);
339
+ chmodSync(DAEMON_LOG, 0o600);
340
+ return fd;
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ }
346
+ /** Enough of the boot tail to hold a stack trace; diagnostics only. */
347
+ const LOG_TAIL_BYTES = 16 * 1024;
348
+ /** Current daemon.log size — the offset past which this boot's output starts. */
349
+ function logSize() {
350
+ try {
351
+ return statSync(DAEMON_LOG).size;
352
+ }
353
+ catch {
354
+ return 0;
355
+ }
356
+ }
357
+ /**
358
+ * Read daemon.log from `offset` to the end, capped at LOG_TAIL_BYTES.
359
+ * The child's stdout and stderr are the same file (no pipe), so a startup
360
+ * failure has to be recovered from what this boot appended to the log.
361
+ */
362
+ function readLogSince(offset) {
363
+ try {
364
+ const fd = openSync(DAEMON_LOG, 'r');
365
+ try {
366
+ const size = fstatSync(fd).size;
367
+ const start = Math.max(offset, size - LOG_TAIL_BYTES);
368
+ if (size <= start)
369
+ return '';
370
+ const buf = Buffer.alloc(size - start);
371
+ readSync(fd, buf, 0, buf.length, start);
372
+ return buf.toString('utf-8');
373
+ }
374
+ finally {
375
+ closeSync(fd);
376
+ }
377
+ }
378
+ catch {
379
+ return '';
380
+ }
381
+ }
105
382
  /**
106
383
  * Ensure models.json exists at ~/.config/saros/models.json.
107
384
  * Always overwrites with the bundled copy on every daemon start.
108
385
  */
109
386
  function ensureModelsJson() {
110
387
  const modelsPath = getModelsJsonPath();
388
+ if (!assertStateDir(modelsPath, 'write'))
389
+ return;
111
390
  const dir = pathJoin(homedir(), '.config', 'saros');
112
391
  if (!existsSync(dir)) {
113
392
  mkdirSync(dir, { recursive: true });
@@ -121,46 +400,75 @@ function ensureModelsJson() {
121
400
  // ---------------------------------------------------------------------------
122
401
  // Process management
123
402
  // ---------------------------------------------------------------------------
124
- /** Check if a process is alive by PID. Cross-platform. */
125
- function isProcessAlive(pid) {
403
+ /**
404
+ * Check if a process is alive by PID.
405
+ *
406
+ * `process.kill(pid, 0)` is the portable liveness probe: Node maps it to
407
+ * OpenProcess on Windows, so a dead PID fails with ESRCH and an EPERM/EACCES
408
+ * answer means "the PID exists, it is just not ours to signal" → alive.
409
+ *
410
+ * The previous win32 path ran `tasklist /FI "PID eq <pid>"`, which exits 0
411
+ * even when no task matches — every PID looked alive (#83).
412
+ */
413
+ export function isProcessAlive(pid) {
414
+ if (!Number.isInteger(pid) || pid <= 0)
415
+ return false;
126
416
  try {
127
- if (process.platform === 'win32') {
128
- execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
129
- windowsHide: true,
130
- timeout: 5000,
131
- stdio: 'pipe',
132
- });
133
- return true;
134
- }
135
417
  process.kill(pid, 0);
136
418
  return true;
137
419
  }
138
- catch {
139
- return false;
420
+ catch (err) {
421
+ const code = err.code;
422
+ return code === 'EPERM' || code === 'EACCES';
140
423
  }
141
424
  }
142
- /** Kill a process and all its children. Cross-platform. */
143
- function killProcess(pid) {
144
- if (process.platform === 'win32') {
145
- try {
425
+ /** taskkill's exit code for "no process with that PID". */
426
+ const TASKKILL_NOT_FOUND = 128;
427
+ /** taskkill's generic failure code, kept only when it carries no diagnostic. */
428
+ const TASKKILL_GENERIC_FAILURE = 1;
429
+ /**
430
+ * Kill a process and all its children. Returns whether the target is gone.
431
+ *
432
+ * Output is piped (never inherited): taskkill's "not found" text used to land
433
+ * in the caller's console and PowerShell surfaced it as a NativeCommandError.
434
+ */
435
+ export function killProcess(pid) {
436
+ try {
437
+ if (process.platform === 'win32') {
146
438
  execFileSync('taskkill', ['/T', '/F', '/PID', String(pid)], {
147
439
  windowsHide: true,
148
440
  timeout: 10000,
441
+ stdio: 'pipe',
149
442
  });
150
443
  }
151
- catch {
152
- // Process may already be dead — ignore
153
- }
154
- }
155
- else {
156
- try {
444
+ else {
157
445
  process.kill(pid, 'SIGKILL');
158
446
  }
159
- catch {
160
- // Process may already be dead — ignore
161
- }
447
+ return true;
448
+ }
449
+ catch (err) {
450
+ // Dying between the liveness check and the kill is success, not failure.
451
+ return isAlreadyExited(err);
162
452
  }
163
453
  }
454
+ /**
455
+ * Did the target already exit? Decided by the exit code, never by the message:
456
+ * taskkill localises "not found" ("não foi encontrado", "no se encuentra"), so
457
+ * text matching only worked in English and Portuguese.
458
+ *
459
+ * A non-zero taskkill exit with no output is a wrapper that swallowed the
460
+ * reason; "Access is denied" and friends always write one, and keeping such a
461
+ * failure visible is the point of the boolean.
462
+ */
463
+ function isAlreadyExited(err) {
464
+ const failure = err;
465
+ if (failure?.code === 'ESRCH')
466
+ return true;
467
+ if (failure?.status === TASKKILL_NOT_FOUND)
468
+ return true;
469
+ const output = `${failure?.stdout ?? ''}${failure?.stderr ?? ''}`.trim();
470
+ return failure?.status === TASKKILL_GENERIC_FAILURE && output === '';
471
+ }
164
472
  // ---------------------------------------------------------------------------
165
473
  // Port probing (os-agnostic — no platform-specific process tools)
166
474
  // ---------------------------------------------------------------------------
@@ -198,33 +506,44 @@ function printHarnesses() {
198
506
  const enabled = readHarnessSettings();
199
507
  console.log(chalk.cyan(`Enabled harnesses: ${enabled.join(', ') || '(none)'}`));
200
508
  }
201
- // ---------------------------------------------------------------------------
202
- // Internal helpers
203
- // ---------------------------------------------------------------------------
204
509
  /**
205
510
  * Synchronously kill the running daemon if its PID file exists and the
206
511
  * process is alive. Cleans up stale PID files. Does NOT call process.exit.
207
- * Returns the PID string found (or null), and whether the process was
208
- * running (killed) or the PID file was stale (cleaned up).
512
+ *
513
+ * The PID file survives a kill that failed: dropping the handle left the next
514
+ * `stop` reporting "no running proxy daemon" (exit 0) for a proxy that was
515
+ * still serving, and `restart` then died on the busy port.
209
516
  */
210
517
  function killRunningDaemon() {
211
- const pid = readPid();
212
- if (!pid)
213
- return { pid: null, wasRunning: false, wasStale: false };
214
- const pidNum = Number(pid);
518
+ const record = readPid();
519
+ if (record === null) {
520
+ return { pid: null, pidFile: null, wasRunning: false, wasStale: false, killed: false };
521
+ }
522
+ const pidNum = parsePid(record.pid);
523
+ if (pidNum === null) {
524
+ // Garbage in the file — nothing to kill, but the file is not evidence of
525
+ // a running daemon either.
526
+ removePidFile(record.file);
527
+ return { pid: null, pidFile: null, wasRunning: false, wasStale: false, killed: false };
528
+ }
529
+ const pid = String(pidNum);
215
530
  if (!isProcessAlive(pidNum)) {
216
531
  // Stale PID file — clean it up
217
- deletePid();
218
- return { pid, wasRunning: false, wasStale: true };
532
+ removePidFile(record.file);
533
+ return { pid, pidFile: record.file, wasRunning: false, wasStale: true, killed: true };
219
534
  }
220
- killProcess(pidNum);
221
- deletePid();
222
- return { pid, wasRunning: true, wasStale: false };
535
+ const killed = killProcess(pidNum) || !isProcessAlive(pidNum);
536
+ return { pid, pidFile: record.file, wasRunning: true, wasStale: false, killed };
223
537
  }
224
538
  // ---------------------------------------------------------------------------
225
539
  // Public API
226
540
  // ---------------------------------------------------------------------------
227
541
  export async function daemonStart(port, configPath) {
542
+ // Before the first directory is created: under test a state dir in the real
543
+ // installation is not ours to populate — not the pid, not daemon.log, not
544
+ // models.json.
545
+ if (!assertStateDir(PID_FILE, 'write'))
546
+ process.exit(1);
228
547
  ensurePidDir();
229
548
  const effectivePort = port ?? getConfigPort(configPath);
230
549
  // Refuse to start when the port is already serving: a second instance
@@ -244,29 +563,47 @@ export async function daemonStart(port, configPath) {
244
563
  args.push('--config', configPath);
245
564
  // Spawn detached child (no console window on Windows)
246
565
  // Read env vars from registry if not in process.env (Windows User-scope fix)
247
- const env = { ...process.env, NODE_ENV: 'production' };
566
+ const env = {
567
+ ...process.env,
568
+ NODE_ENV: 'production',
569
+ // The child has no terminal: never try the TTY passphrase prompt there.
570
+ SAROS_DAEMON: '1',
571
+ };
248
572
  const encKey = getEnv('OPENCODE_GO_ENCRYPTION_KEY');
249
573
  if (encKey)
250
574
  env.OPENCODE_GO_ENCRYPTION_KEY = encKey;
251
- // Capture child stderr (also appended to daemon.log) so startup failures
252
- // can be diagnosed instead of swallowed by stdio ignore.
253
- // Best-effort: an unopenable log (dir deleted under us, permissions) must
254
- // not crash the starter — the stream open is async and can lose the race
255
- // with cleanup in tests.
256
- const logStream = createWriteStream(DAEMON_LOG, { flags: 'a' });
257
- logStream.on('error', () => { });
258
- let stderrBuf = '';
575
+ // Child stdout carries pino, stderr carries node's uncaught-exception
576
+ // reporter and startup errors. Both are the same daemon.log descriptor and
577
+ // no pipe is involved: the parent exits after the models sync, and a pipe's
578
+ // missing reader used to kill the serve process with EPIPE on the next
579
+ // stderr write (#99) — a crash trace could be the thing that killed the proxy.
580
+ rotateDaemonLog();
581
+ // A synchronous append descriptor, not createWriteStream: a stream opens
582
+ // asynchronously and its handle outlived startup verification, so cleanup
583
+ // that removes the directory raced with the pending open (ENOTEMPTY) and a
584
+ // restart left a writer on the old log. openDaemonLog chmods to 0600 (the
585
+ // log carries upstream error bodies) and returns null when the file cannot
586
+ // be opened — logging is diagnostic only.
587
+ const logFd = openDaemonLog();
588
+ // Everything past this offset belongs to this boot; earlier boots must not
589
+ // be able to explain a failure.
590
+ const bootOffset = logSize();
259
591
  const child = spawn('node', args, {
260
592
  cwd: PACKAGE_ROOT,
261
- stdio: ['ignore', 'ignore', 'pipe'],
593
+ stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
262
594
  detached: true,
263
595
  windowsHide: true,
264
596
  env,
265
597
  });
266
- child.stderr?.on('data', (chunk) => {
267
- stderrBuf += chunk.toString();
268
- logStream.write(chunk);
269
- });
598
+ // The child holds its own duplicates of the descriptor.
599
+ if (logFd !== null) {
600
+ try {
601
+ closeSync(logFd);
602
+ }
603
+ catch {
604
+ // ignore
605
+ }
606
+ }
270
607
  // Allow parent to exit independently while child continues
271
608
  child.unref();
272
609
  const pid = child.pid;
@@ -292,71 +629,137 @@ export async function daemonStart(port, configPath) {
292
629
  process.exit(0);
293
630
  }
294
631
  else {
295
- // Surface the real startup failure from the child's stderr.
296
- if (stderrBuf.includes('EADDRINUSE')) {
632
+ // The child had no pipe to capture: recover its failure from whatever
633
+ // this boot appended to daemon.log (stdout and stderr are the same file).
634
+ const tail = readLogSince(bootOffset);
635
+ if (tail.includes('EADDRINUSE')) {
297
636
  console.error(chalk.red(`✗ Port ${effectivePort} is already in use. Choose a different port (\`--port <port>\`), or stop the running proxy first.`));
298
637
  }
299
- else if (stderrBuf.includes('No valid API keys')) {
638
+ else if (tail.includes('No valid API keys')) {
300
639
  console.error(chalk.red('✗ No valid API keys found. Ensure keys start with "sk-" and are at least 20 characters.'));
301
640
  }
302
- else if (stderrBuf.trim()) {
303
- const tail = stderrBuf.trim().split('\n').slice(-6).join('\n');
641
+ else if (tail.trim()) {
642
+ const lines = tail.trim().split('\n').slice(-6).join('\n');
304
643
  console.error(chalk.red('✗ Proxy exited shortly after starting. Last output:'));
305
- console.error(chalk.dim(tail));
644
+ console.error(chalk.dim(lines));
306
645
  }
307
646
  else {
308
647
  console.error(chalk.red('✗ Proxy exited shortly after starting. Check your config.'));
309
648
  }
310
- deletePid();
649
+ removePidFile(PID_FILE);
311
650
  process.exit(1);
312
651
  }
313
652
  }, 2000);
314
653
  }
315
- export function daemonStop() {
316
- const { pid, wasStale } = killRunningDaemon();
317
- if (pid === null) {
318
- console.log(chalk.yellow('No running proxy daemon found.'));
319
- process.exit(0);
320
- }
321
- if (wasStale) {
322
- console.log(chalk.yellow(`Daemon process (PID ${pid}) is not running. Cleaning up PID file.`));
323
- process.exit(0);
324
- }
325
- // wasRunning === true — killRunningDaemon already killed the process and
326
- // deleted the PID file. Now verify it's actually gone.
327
- const pidNum = Number(pid);
328
- setTimeout(() => {
654
+ /** How long `stop` waits for the killed process to disappear. */
655
+ const STOP_VERIFY_MS = 2000;
656
+ /** The pre-0.11 PID file the operator has not opted into yet (stat only). */
657
+ function legacyPidFileToAdopt() {
658
+ if (LEGACY_PID_FILE === null || legacyPidEnabled())
659
+ return null;
660
+ return existsSync(LEGACY_PID_FILE) ? LEGACY_PID_FILE : null;
661
+ }
662
+ /**
663
+ * Wait for a kill to land, then remove the PID file and run `onGone` — or
664
+ * report the failure and exit 1. The liveness re-check is the only honest
665
+ * answer: `killed` says the signal was accepted, not that the process died.
666
+ * A refused kill is checked at once, since waiting cannot change it.
667
+ */
668
+ function awaitDaemonGone(result, onGone) {
669
+ const pidNum = Number(result.pid);
670
+ const check = () => {
329
671
  if (isProcessAlive(pidNum)) {
330
- console.error(chalk.red(`✗ Failed to stop daemon (PID ${pid}). Try: taskkill /F /PID ${pid}`));
672
+ const hint = process.platform === 'win32'
673
+ ? `taskkill /T /F /PID ${result.pid}`
674
+ : `kill -9 ${result.pid}`;
675
+ console.error(chalk.red(`✗ Failed to stop daemon (PID ${result.pid}). Try: ${hint}`));
331
676
  process.exit(1);
677
+ return;
332
678
  }
333
- else {
334
- console.log(chalk.green(`✓ Proxy (PID ${pid}) stopped.`));
335
- process.exit(0);
679
+ removePidFile(result.pidFile);
680
+ onGone();
681
+ };
682
+ if (result.killed)
683
+ setTimeout(check, STOP_VERIFY_MS);
684
+ else
685
+ check();
686
+ }
687
+ /**
688
+ * Nothing to stop in the state dir. That is not the whole truth: a 0.10.x
689
+ * installation keeps its PID under `~/.config/saros`, and an instance started
690
+ * outside the daemon never writes a handle at all, so "no pid file" used to
691
+ * print "no daemon" and exit 0 while a proxy was still serving. Probe the
692
+ * configured port: when /health answers, report what is serving — naming the
693
+ * legacy file and the flag when one exists — and exit non-zero.
694
+ *
695
+ * `alreadyReported` is for a caller that just announced a stale handle: the
696
+ * header is redundant, but the port verdict is not.
697
+ */
698
+ async function reportNothingToStop(alreadyReported = false) {
699
+ const legacy = legacyPidFileToAdopt();
700
+ if (legacy === null) {
701
+ if (!alreadyReported)
702
+ console.log(chalk.yellow('No running proxy daemon found.'));
703
+ }
704
+ else {
705
+ if (!alreadyReported) {
706
+ console.log(chalk.yellow(`No running proxy daemon found in ${PID_DIR}.`));
336
707
  }
337
- }, 1000);
708
+ console.log(chalk.yellow(`A pre-0.11 PID file exists at ${legacy}.`));
709
+ console.log(chalk.cyan('Re-run with SAROS_ALLOW_LEGACY_PID=1 to let this command read and stop it.'));
710
+ }
711
+ const port = getConfigPort();
712
+ const probe = await probeProxyHealth(port);
713
+ if (!probe.ok)
714
+ process.exit(0);
715
+ const detail = legacy === null
716
+ ? 'it was started outside `saros-proxy start`'
717
+ : 'the pre-0.11 instance was not stopped';
718
+ console.error(chalk.red(`✗ Something is still serving on port ${port} — ${detail}.`));
719
+ process.exit(1);
720
+ }
721
+ export async function daemonStop() {
722
+ const result = killRunningDaemon();
723
+ if (result.pid === null) {
724
+ await reportNothingToStop();
725
+ return;
726
+ }
727
+ if (result.wasStale) {
728
+ console.log(chalk.yellow(`Daemon process (PID ${result.pid}) is not running. Cleaning up PID file.`));
729
+ // The stale handle was removed; something can still serve — a live legacy
730
+ // instance, or one started outside the daemon. Exiting 0 here hid that.
731
+ await reportNothingToStop(true);
732
+ return;
733
+ }
734
+ awaitDaemonGone(result, () => {
735
+ console.log(chalk.green(`✓ Proxy (PID ${result.pid}) stopped.`));
736
+ process.exit(0);
737
+ });
338
738
  }
339
739
  /**
340
- * Restart the proxy daemon: stop the running instance (if any), wait
341
- * briefly for the port to release, then start a fresh instance.
740
+ * Restart the proxy daemon: stop the running instance (if any), wait for it
741
+ * to actually disappear and the port to release, then start a fresh instance.
342
742
  * Reuses daemonStart which owns its own exit path via setTimeout.
743
+ *
744
+ * "Restarting: stopped daemon" used to print before the kill landed, so a
745
+ * failed stop was followed by a start that died on the busy port.
343
746
  */
344
747
  export function daemonRestart(port, configPath) {
345
- const { pid, wasRunning, wasStale } = killRunningDaemon();
346
- if (wasRunning && pid) {
347
- console.log(chalk.dim(`Restarting: stopped daemon (PID ${pid}), starting new instance...`));
348
- // Wait for the OS to release the port before respawning
349
- setTimeout(() => daemonStart(port, configPath), 1500);
748
+ const result = killRunningDaemon();
749
+ if (result.wasRunning && result.pid) {
750
+ awaitDaemonGone(result, () => {
751
+ console.log(chalk.dim(`Restarting: stopped daemon (PID ${result.pid}), starting new instance...`));
752
+ daemonStart(port, configPath);
753
+ });
754
+ return;
755
+ }
756
+ if (result.wasStale && result.pid) {
757
+ console.log(chalk.dim(`Stale PID file (PID ${result.pid}) cleaned up. Starting new instance...`));
350
758
  }
351
759
  else {
352
- if (wasStale && pid) {
353
- console.log(chalk.dim(`Stale PID file (PID ${pid}) cleaned up. Starting new instance...`));
354
- }
355
- else {
356
- console.log(chalk.dim('No running daemon found, starting new instance...'));
357
- }
358
- daemonStart(port, configPath);
760
+ console.log(chalk.dim('No running daemon found, starting new instance...'));
359
761
  }
762
+ daemonStart(port, configPath);
360
763
  }
361
764
  /**
362
765
  * Resolve the daemon state os-agnostically: PID file first (cleaning up a
@@ -366,15 +769,16 @@ export function daemonRestart(port, configPath) {
366
769
  */
367
770
  export async function getDaemonState() {
368
771
  const port = getConfigPort();
369
- const pid = readPid();
370
- const pidAlive = pid !== null && isProcessAlive(Number(pid));
371
- const stalePid = pid !== null && !pidAlive;
772
+ const record = readPid();
773
+ const pidNum = parsePid(record?.pid ?? null);
774
+ const pidAlive = pidNum !== null && isProcessAlive(pidNum);
775
+ const stalePid = record !== null && !pidAlive;
372
776
  if (stalePid)
373
- deletePid();
777
+ removePidFile(record.file);
374
778
  const probe = await probeProxyHealth(port);
375
779
  return {
376
780
  running: pidAlive || probe.ok,
377
- pid: pidAlive ? pid : null,
781
+ pid: pidAlive ? String(pidNum) : null,
378
782
  port,
379
783
  stalePid,
380
784
  health: probe.ok ? probe.body : undefined,
@@ -382,6 +786,13 @@ export async function getDaemonState() {
382
786
  }
383
787
  export async function daemonStatus() {
384
788
  const state = await getDaemonState();
789
+ // A 0.10.x installation keeps its PID under ~/.config/saros: without the
790
+ // flag `status` cannot see it, so say where it is and how to opt in.
791
+ const legacy = state.pid === null ? legacyPidFileToAdopt() : null;
792
+ if (legacy !== null) {
793
+ console.log(chalk.yellow(`A pre-0.11 PID file exists at ${legacy}.`));
794
+ console.log(chalk.cyan('Re-run with SAROS_ALLOW_LEGACY_PID=1 to let this command read it.'));
795
+ }
385
796
  if (state.running) {
386
797
  if (state.pid) {
387
798
  console.log(chalk.green(`✓ Proxy is running (PID ${state.pid}) on port ${state.port}.`));
@@ -389,6 +800,10 @@ export async function daemonStatus() {
389
800
  else {
390
801
  console.log(chalk.green(`✓ Proxy is running on port ${state.port} (no PID file — started outside \`saros-proxy start\`).`));
391
802
  }
803
+ // Per-key state from the daemon's own report: which key is out, why, and
804
+ // when it retries. Skipped silently when the payload has no keys block.
805
+ for (const line of formatKeyBlock(state.health))
806
+ console.log(line);
392
807
  printHarnesses();
393
808
  process.exit(0);
394
809
  }