cc-viewer 1.9.15 → 1.9.17

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 (49) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BlNbziAQ.js +2 -0
  21. package/dist/assets/App-BvVdj099.css +1 -0
  22. package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-C9cr-WBH.js} +1 -1
  23. package/dist/assets/Mobile-CDQPYYQo.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-BEqrTR-F.js} +1 -1
  25. package/dist/assets/index-B4J-uwYG.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/server/lib/ask/perm-bridge.js +4 -1
  31. package/server/lib/auto-approve.js +160 -0
  32. package/server/lib/file-access-policy.js +132 -5
  33. package/server/lib/os-open.js +66 -0
  34. package/server/lib/sdk-manager.js +96 -9
  35. package/server/lib/target-scope.js +174 -0
  36. package/server/routes/ask-perm.js +54 -3
  37. package/server/routes/files-content.js +174 -8
  38. package/server/routes/files-fs.js +8 -54
  39. package/server/routes/skills.js +19 -8
  40. package/server/routes/target-files-fs.js +332 -0
  41. package/server/server.js +89 -14
  42. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  43. package/ultraAgents/code-expert.json +1 -1
  44. package/dist/assets/App-BCDnm2Lh.js +0 -2
  45. package/dist/assets/App-CCnPkf2_.css +0 -1
  46. package/dist/assets/Mobile-C8VJqBwa.js +0 -1
  47. package/dist/assets/index-Sp3JYYKo.js +0 -2
  48. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
  49. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +0 -2
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-Sp3JYYKo.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-B4J-uwYG.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-I6I2F7Rv.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Zkm6UHQg.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CC09MDyZ.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.9.15",
3
+ "version": "1.9.17",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -164,7 +164,10 @@ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
164
164
 
165
165
  function postToViewer() {
166
166
  return new Promise((resolve, reject) => {
167
- const body = JSON.stringify({ toolName, input: toolInput });
167
+ // Forward the session cwd so the server can resolve the project-forked
168
+ // autoApproveSeconds for the right workspace (falls back to the server's
169
+ // current project key when absent — backward compatible).
170
+ const body = JSON.stringify({ toolName, input: toolInput, cwd: typeof payload?.cwd === 'string' ? payload.cwd : undefined });
168
171
  const req = httpClient.request({
169
172
  hostname: '127.0.0.1',
170
173
  port: Number(port),
@@ -0,0 +1,160 @@
1
+ // Server-side auto-approve countdown for plan/permission approvals.
2
+ //
3
+ // The auto-approve countdown used to live entirely in the web client: when the
4
+ // browser tab was closed, backgrounded (timer throttling), or disconnected, no
5
+ // approval ever fired and the pending request degraded to the 5-minute deny
6
+ // timeout. This module moves the deadline to the server: the countdown is armed
7
+ // from the moment the approval request arrives (wall-clock), and the server
8
+ // resolves the pending approval itself at deadline + AUTO_APPROVE_BUFFER_MS.
9
+ // The buffer gives a connected client whose own countdown fires at the exact
10
+ // deadline a head start, so a watched approval is still answered by the client
11
+ // and the server timer is only the fallback.
12
+ //
13
+ // Cancellation is durable while the approval is pending: a client cancel marks
14
+ // the entry (autoCancelled) and clears the server timer, the approval stays
15
+ // pending for a manual decision, and the cancelled state is replayed to newly
16
+ // connected clients so they never re-arm a countdown.
17
+ //
18
+ // Semantics of the settings (shared with the client):
19
+ // plan: prefs.approvalModal.planAutoApproveSeconds (default 0 = off)
20
+ // perm: prefs.autoApproveSeconds (default 3 when unset)
21
+ // 0/off, N>0 = N seconds, -1 = instant (resolve immediately, no buffer).
22
+ // AskUserQuestion approvals are never auto-approved.
23
+
24
+ import { readPrefsRaw } from './prefs-store.js';
25
+ import { getCurrentProjectKey, hasFork, resolveScoped } from './project-prefs.js';
26
+ import { reportSwallowed } from './error-report.js';
27
+
28
+ export const AUTO_APPROVE_BUFFER_MS = 1000;
29
+ export const DEFAULT_PERM_AUTO_APPROVE_SECONDS = 3;
30
+ export const AUTO_APPROVE_INSTANT = -1;
31
+ // A hand-edited preferences.json can hold an absurdly large value. Besides being
32
+ // pointless, a value above the 5-minute hard approval timeout would advertise an
33
+ // autoApproveAt the deny timeout always beats (a lying countdown); and beyond
34
+ // 2^31-1 ms Node's setTimeout clamps the delay to 1ms (instant approval). Cap the
35
+ // countdown at the approval timeout so the advertised deadline is always reachable.
36
+ export const MAX_AUTO_APPROVE_SECONDS = 300;
37
+
38
+ /**
39
+ * Extract the configured seconds for a kind ('plan' | 'perm') from an effective
40
+ * prefs object. Returns null when disabled (0 / invalid / negative other than -1 /
41
+ * absurdly large).
42
+ */
43
+ export function effectiveAutoApproveSeconds(kind, prefs) {
44
+ const raw = kind === 'plan'
45
+ ? prefs?.approvalModal?.planAutoApproveSeconds
46
+ : (prefs?.autoApproveSeconds ?? DEFAULT_PERM_AUTO_APPROVE_SECONDS);
47
+ const seconds = Number(raw);
48
+ if (!Number.isFinite(seconds)) return null;
49
+ if (seconds === AUTO_APPROVE_INSTANT) return AUTO_APPROVE_INSTANT;
50
+ if (seconds <= 0) return null;
51
+ if (seconds > MAX_AUTO_APPROVE_SECONDS) return null;
52
+ return seconds;
53
+ }
54
+
55
+ /**
56
+ * Read prefs (project-fork aware) and compute the auto-approve deadline for a
57
+ * pending approval. `projectKey` is the session cwd (SDK) or hook-reported cwd
58
+ * (PTY hook); falls back to the server's current project key.
59
+ *
60
+ * Returns:
61
+ * { enabled:false } — no auto-approve
62
+ * { enabled:true, instant, seconds, autoApproveAt } — absolute epoch ms deadline
63
+ */
64
+ export function computeAutoApprove(kind, projectKey, { now = Date.now(), readPrefs = readPrefsRaw, file } = {}) {
65
+ let prefs = {};
66
+ try {
67
+ prefs = readPrefs(file) || {};
68
+ } catch (err) {
69
+ reportSwallowed('auto-approve.read-prefs', err);
70
+ return { enabled: false };
71
+ }
72
+ const key = typeof projectKey === 'string' && projectKey ? projectKey : getCurrentProjectKey();
73
+ const effective = hasFork(prefs, key) ? resolveScoped(prefs, prefs.prefsByProject[key]) : prefs;
74
+ const seconds = effectiveAutoApproveSeconds(kind, effective);
75
+ if (seconds === null) return { enabled: false };
76
+ const instant = seconds === AUTO_APPROVE_INSTANT;
77
+ return {
78
+ enabled: true,
79
+ instant,
80
+ seconds,
81
+ autoApproveAt: instant ? now : now + seconds * 1000,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Delay for the server-side fallback timer: the user-visible deadline plus the
87
+ * request buffer, never negative. Instant fires on the same tick (0).
88
+ */
89
+ export function autoApproveFireDelayMs(computed, now = Date.now()) {
90
+ if (!computed?.enabled) return null;
91
+ if (computed.instant) return 0;
92
+ return Math.max(0, computed.autoApproveAt - now) + AUTO_APPROVE_BUFFER_MS;
93
+ }
94
+
95
+ /**
96
+ * Arm the server-side fallback timer. Returns the auto state stored on the
97
+ * pending entry: { autoApproveAt, seconds, cancelled, timer }.
98
+ * `onFire` is invoked once when the deadline (+buffer) passes; callers must
99
+ * re-check the entry is still pending and not cancelled inside onFire.
100
+ * Instant (-1) fires SYNCHRONOUSLY — the caller MUST register the pending entry
101
+ * (and emit the pending frame) before calling this.
102
+ */
103
+ export function createAutoApproveState(computed, onFire) {
104
+ const delay = autoApproveFireDelayMs(computed);
105
+ if (delay === null) return null; // disabled — never arm
106
+ const state = {
107
+ autoApproveAt: computed.autoApproveAt,
108
+ seconds: computed.seconds,
109
+ cancelled: false,
110
+ timer: null,
111
+ };
112
+ if (delay === 0) {
113
+ onFire();
114
+ return state;
115
+ }
116
+ state.timer = setTimeout(onFire, delay);
117
+ return state;
118
+ }
119
+
120
+ /**
121
+ * Cancel an armed auto-approve state: clears the timer and marks it cancelled.
122
+ * Accepts either the auto state itself or the whole pending entry ({ auto,
123
+ * autoCancelled }) — the entry form also stamps the durable entry-level flag so
124
+ * replays never re-arm. Returns true only on the first cancellation (so callers
125
+ * broadcast once).
126
+ */
127
+ export function cancelAutoApproveState(autoOrEntry) {
128
+ if (!autoOrEntry) return false;
129
+ // Entry form: { auto, autoCancelled }
130
+ if (Object.prototype.hasOwnProperty.call(autoOrEntry, 'autoCancelled')) {
131
+ const entry = autoOrEntry;
132
+ if (entry.autoCancelled) return false;
133
+ entry.autoCancelled = true;
134
+ if (entry.auto) {
135
+ entry.auto.cancelled = true;
136
+ if (entry.auto.timer) {
137
+ clearTimeout(entry.auto.timer);
138
+ entry.auto.timer = null;
139
+ }
140
+ }
141
+ return true;
142
+ }
143
+ const auto = autoOrEntry;
144
+ if (auto.cancelled) return false;
145
+ auto.cancelled = true;
146
+ if (auto.timer) {
147
+ clearTimeout(auto.timer);
148
+ auto.timer = null;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ /** Fields merged into pending/replay frames for the client countdown UI. */
154
+ export function replayAutoApproveFields(auto, autoCancelled) {
155
+ return {
156
+ autoApproveAt: autoCancelled ? null : (auto?.autoApproveAt ?? null),
157
+ autoApproveSeconds: auto?.seconds ?? null,
158
+ autoCancelled: autoCancelled === true,
159
+ };
160
+ }
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * 不替代写路径的业务校验(见 server/lib/file-api.js writeFileContent),但同样会被 endpoint 用作首道关卡。
12
12
  */
13
- import { realpathSync } from 'node:fs';
14
- import { resolve, basename, sep, join } from 'node:path';
13
+ import { realpathSync, lstatSync } from 'node:fs';
14
+ import { resolve, basename, dirname, sep, join } from 'node:path';
15
15
  import { homedir, platform, tmpdir } from 'node:os';
16
16
  import { getClaudeConfigDir, onLogDirChange, LOG_DIR } from '../../findcc.js';
17
17
  import { loadWorkspaces } from '../workspace-registry.js';
@@ -122,7 +122,14 @@ function _logDirSensitiveFiles() {
122
122
  files.add(norm(join(LOG_DIR, name)));
123
123
  files.add(norm(join(rootReal, name)));
124
124
  }
125
- _logDirSensitiveCache = { root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)), files };
125
+ // `root`/`rootReal` keep a trailing separator for startsWith() prefix matching;
126
+ // `rootBare`/`rootRealBare` (no trailing sep) let isWriteAllowed also deny the
127
+ // LOG_DIR directory ITSELF (delete/rename), which the prefix match would miss.
128
+ _logDirSensitiveCache = {
129
+ root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)),
130
+ rootBare: norm(LOG_DIR), rootRealBare: norm(rootReal),
131
+ files,
132
+ };
126
133
  return _logDirSensitiveCache;
127
134
  }
128
135
  onLogDirChange(() => { _logDirSensitiveCache = null; });
@@ -215,6 +222,16 @@ export function isReadAllowed(absPath) {
215
222
  let real;
216
223
  try { real = realpathSync(absPath); } catch { return { ok: false, reason: 'realpath-failed' }; }
217
224
 
225
+ return evaluateResolvedPath(real);
226
+ }
227
+
228
+ // Shared verdict for a realpath-resolved path — the allowlist + sensitive-file
229
+ // + sensitive-prefix checks that isReadAllowed (existing files) and
230
+ // isWriteAllowed (existing or to-be-created files) both apply. Keeping this the
231
+ // single implementation prevents read/write policy drift: a path the user can
232
+ // read but must not overwrite (e.g. ~/.claude/settings.json) is denied here for
233
+ // both directions.
234
+ function evaluateResolvedPath(real) {
218
235
  // 1) Allowlist 命中
219
236
  const roots = getAllowedRoots();
220
237
  let hitRoot = null;
@@ -233,8 +250,30 @@ export function isReadAllowed(absPath) {
233
250
  if (SENSITIVE_CLAUDE_FILES.has(last)) {
234
251
  return { ok: false, reason: 'sensitive-claude-config' };
235
252
  }
236
- // 任意子目录下的 settings.json / settings.local.json 也拦
237
- if (/^settings(\.local)?\.json$/i.test(last)) {
253
+ // settings.json / settings.local.json at any depth, plus all backups/variants
254
+ // (settings.json.bk*, settings_*.json, etc.) — rolling backup files carry the
255
+ // same env/api-key content and must not slip past the exact-name match.
256
+ if (/^settings(\.local)?\.json($|\.)|^settings_.*\.json$/i.test(last)) {
257
+ return { ok: false, reason: 'sensitive-claude-config' };
258
+ }
259
+ // Prompt history, project transcripts, shell snapshots, file history, and
260
+ // paste cache contain user prompt content and (in the case of shell snapshots)
261
+ // live env vars. Block the whole subtree EXCEPT projects/<encoded>/memory/**,
262
+ // which is the documented per-project memory dir served by /api/project-memory.
263
+ const rel = norm(real.slice(norm(withSep(claudeReal)).length));
264
+ const firstSeg = rel.split(sep)[0];
265
+ if (firstSeg === 'todos' || firstSeg === 'shell-snapshots'
266
+ || firstSeg === 'file-history' || firstSeg === 'paste-cache' || firstSeg === 'session-env') {
267
+ return { ok: false, reason: 'sensitive-claude-config' };
268
+ }
269
+ if (firstSeg === 'projects') {
270
+ const parts = rel.split(sep);
271
+ const isMemoryDir = parts.length >= 3 && parts[2] === 'memory';
272
+ if (!isMemoryDir) {
273
+ return { ok: false, reason: 'sensitive-claude-config' };
274
+ }
275
+ }
276
+ if (last === 'history.jsonl') {
238
277
  return { ok: false, reason: 'sensitive-claude-config' };
239
278
  }
240
279
  }
@@ -288,6 +327,94 @@ export function isReadAllowed(absPath) {
288
327
  return { ok: true, real };
289
328
  }
290
329
 
330
+ /**
331
+ * Write-access verdict for the file browser's target mode (the CLAUDE.md / skill
332
+ * folder modal, where the user may browse up to an ancestor of the target). The
333
+ * target file itself may not exist yet (create-file / create-dir / rename
334
+ * destination), so unlike isReadAllowed we cannot realpath it directly. Instead:
335
+ * the parent directory must exist and pass the read policy, and the candidate
336
+ * path (parent's real path + basename) must pass the SAME shared verdict — so a
337
+ * location the user can read but must not overwrite (settings.json, credentials,
338
+ * master.key, backups) is rejected identically in both directions.
339
+ *
340
+ * SECURITY / INTENTIONAL DESIGN (do NOT weaken or remove in review): the write
341
+ * surface intentionally spans the whole navigation root (see
342
+ * lib/target-scope.js resolveTargetWriteScope). This function is the backstop
343
+ * that keeps secrets unwritable inside that wider surface.
344
+ *
345
+ * Two write-only denials layered on top of the read policy:
346
+ * - The ENTIRE LOG_DIR tree (~/.claude/cc-viewer/**): the read policy only
347
+ * blocks specific data files in it, but a write/delete could rmdir the whole
348
+ * dir (wiping the preferences + scheduled-task store), so writes deny the
349
+ * whole subtree.
350
+ * - The config-backup root is already denied for reads (2b) and stays denied.
351
+ */
352
+ export function isWriteAllowed(absPath) {
353
+ if (typeof absPath !== 'string' || !absPath) return { ok: false, reason: 'invalid' };
354
+ if (absPath.indexOf('\x00') !== -1) return { ok: false, reason: 'null-byte' };
355
+
356
+ // Existing file: realpath it and run the shared verdict directly.
357
+ // New file: realpath the nearest existing parent, then re-evaluate the
358
+ // candidate (parentReal + basename) so symlink/denylist checks apply to the
359
+ // resolved location, not the spelled path.
360
+ let real;
361
+ let existed = true;
362
+ try { real = realpathSync(absPath); } catch { existed = false; }
363
+ if (!existed) {
364
+ // Dangling-symlink guard: realpath failed but the leaf may still be a symlink
365
+ // whose target is missing/outside the allowlist. writeFileSync would FOLLOW it
366
+ // and create/overwrite the link's target outside the navigation root — a
367
+ // containment bypass. lstat does not follow the link, so an isSymbolicLink()
368
+ // hit here refuses the write. Do NOT remove (TOCTOU-class escape).
369
+ try { if (lstatSync(absPath).isSymbolicLink()) return { ok: false, reason: 'symlink' }; } catch { /* truly absent leaf */ }
370
+ let cursor = resolve(absPath, '..');
371
+ const descent = [basename(absPath)];
372
+ let parentReal = null;
373
+ for (let depth = 0; depth < 32; depth++) {
374
+ try { parentReal = realpathSync(cursor); break; } catch {
375
+ const parent = resolve(cursor, '..');
376
+ if (parent === cursor) break;
377
+ descent.unshift(basename(cursor));
378
+ cursor = parent;
379
+ }
380
+ }
381
+ if (parentReal === null) return { ok: false, reason: 'realpath-failed' };
382
+ // Every ancestor on the way down must be inside the allowlist / not denied;
383
+ // evaluate the nearest existing ancestor, then rebuild the candidate.
384
+ const ancestorVerdict = evaluateResolvedPath(parentReal);
385
+ if (!ancestorVerdict.ok) return ancestorVerdict;
386
+ real = join(parentReal, ...descent);
387
+ }
388
+
389
+ const verdict = evaluateResolvedPath(real);
390
+ if (!verdict.ok) return verdict;
391
+
392
+ // Write-only: deny the whole LOG_DIR subtree (read policy only blocks named
393
+ // data files in it). Matches both the raw and realpath'd spellings, both the
394
+ // prefix (children) AND the directory itself (bare), so a delete/rename cannot
395
+ // rmdir the whole task/preferences store.
396
+ const sens = _logDirSensitiveFiles();
397
+ const realNorm = norm(real);
398
+ if (realNorm.startsWith(sens.root) || realNorm.startsWith(sens.rootReal)
399
+ || realNorm === sens.rootBare || realNorm === sens.rootRealBare) {
400
+ return { ok: false, reason: 'sensitive-ccv-data' };
401
+ }
402
+
403
+ // Write-only: deny a sensitive-PREFIX directory itself (~/.ssh, ~/.aws, …), not
404
+ // just its contents. The read policy only matches children (startsWith withSep),
405
+ // so renaming the directory to a benign name would expose the whole prefix's
406
+ // contents. Renaming/deleting the dir root is refused here. (Only meaningful
407
+ // outside the project root, matching the prefix rule's own scope.)
408
+ const projectRoot = getProjectRoot();
409
+ if (!isInsideRoot(real, projectRoot)) {
410
+ for (const prefix of SENSITIVE_PATH_PREFIXES) {
411
+ if (realNorm === norm(prefix)) return { ok: false, reason: 'sensitive-prefix' };
412
+ }
413
+ }
414
+
415
+ return { ok: true, real };
416
+ }
417
+
291
418
  /**
292
419
  * 给 endpoint 用的 reason → HTTP status code 映射。
293
420
  * - 路径不存在/realpath 失败 → 404(更友好,不暴露"路径合法但不存在")
@@ -0,0 +1,66 @@
1
+ // Shared OS-integration helpers (open in file manager / terminal) used by both
2
+ // the project-scoped routes (files-fs.js) and the target-mode write routes
3
+ // (target-files-fs.js). Centralizing the platform branches keeps the two call
4
+ // surfaces from drifting apart (a Windows quoting or Linux terminal-fallback fix
5
+ // must apply to both). Pure argv spawning — no shell — so no metachar injection.
6
+ //
7
+ // L1 (lib): must NOT import from routes/ (verify:boundaries).
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import { dirname } from 'node:path';
10
+ import { reportSwallowed } from './error-report.js';
11
+
12
+ // Reveal a file (select it) or open a directory in the OS file manager.
13
+ // - macOS: `open -R <path>` selects files AND opens directories in place.
14
+ // - Windows: `explorer /select,"<path>"` (verbatim args; a dir path opens it).
15
+ // - Linux: no select concept — open the path itself when it is a directory,
16
+ // otherwise its parent. (xdg-open on a FILE would launch the default app, so
17
+ // callers pass `isDir` to pick the directory to show.)
18
+ export function revealInOs(fullPath, { isDir = false } = {}) {
19
+ const plat = process.platform;
20
+ if (plat === 'darwin') {
21
+ execFile('open', ['-R', fullPath], () => {});
22
+ } else if (plat === 'win32') {
23
+ // windowsVerbatimArguments: Node would otherwise re-quote the spaced path into
24
+ // a form explorer cannot parse. No cmd.exe is involved, so no metachar risk;
25
+ // Windows filenames cannot contain `"`.
26
+ const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
27
+ child.on('error', (err) => { reportSwallowed('osOpen.reveal', err); });
28
+ } else {
29
+ execFile('xdg-open', [isDir ? fullPath : dirname(fullPath)], () => {});
30
+ }
31
+ }
32
+
33
+ // Open an OS terminal with cwd = dir. Linux probes common emulators with piped
34
+ // stdio so the next candidate is tried after ENOENT, falling back to xdg-open.
35
+ export function openTerminalAt(dir) {
36
+ const plat = process.platform;
37
+ if (plat === 'darwin') {
38
+ const child = spawn('open', ['-a', 'Terminal', dir], { stdio: 'ignore', detached: true });
39
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'Terminal.app' }); });
40
+ child.unref();
41
+ } else if (plat === 'win32') {
42
+ const child = spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: dir, stdio: 'ignore', detached: true, windowsHide: true });
43
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'cmd.exe' }); });
44
+ child.unref();
45
+ } else {
46
+ const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
47
+ const tryTerminal = (index) => {
48
+ if (index >= terminals.length) {
49
+ const child = spawn('xdg-open', [dir], { stdio: 'ignore', detached: true });
50
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'xdg-open' }); });
51
+ child.unref();
52
+ return;
53
+ }
54
+ const term = terminals[index];
55
+ let child;
56
+ try {
57
+ if (term === 'gnome-terminal') child = spawn(term, ['--working-directory=' + dir], { stdio: 'pipe' });
58
+ else if (term === 'konsole') child = spawn(term, ['--workdir', dir], { stdio: 'pipe' });
59
+ else child = spawn(term, [], { cwd: dir, stdio: 'pipe' });
60
+ } catch { tryTerminal(index + 1); return; }
61
+ child.once('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: term }); tryTerminal(index + 1); });
62
+ child.once('spawn', () => { for (const s of [child.stdin, child.stdout, child.stderr]) s?.destroy(); child.unref(); });
63
+ };
64
+ tryTerminal(0);
65
+ }
66
+ }
@@ -36,6 +36,12 @@ import { createHash, randomBytes } from 'node:crypto';
36
36
  import { registerSdkSessionLister } from './sdk-session-index.js';
37
37
  import { readPrefsRaw } from './prefs-store.js';
38
38
  import { projectKeyForCwd } from './system-prompt-snapshots.js';
39
+ import {
40
+ computeAutoApprove,
41
+ createAutoApproveState,
42
+ cancelAutoApproveState,
43
+ replayAutoApproveFields,
44
+ } from './auto-approve.js';
39
45
 
40
46
  let _query;
41
47
  try {
@@ -1489,11 +1495,24 @@ function _createSession(key, cwd) {
1489
1495
  }
1490
1496
  } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1491
1497
  }
1492
- const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1498
+ // Server-side auto-approve countdown (armed from request arrival, wall-clock):
1499
+ // the pending frame carries the absolute deadline so clients render the remaining
1500
+ // time instead of arming their own full-length timer; the server resolves the
1501
+ // approval itself at deadline + 1s buffer when no client answered.
1502
+ // Unattended scheduled-task runs (ctx != null → origin 'scheduled-task') are exempt
1503
+ // from the server-side auto-approve fallback — with no human watching, an ephemeral
1504
+ // run must wait for a decision (5-min deny), symmetric to the npm-publish floor below.
1505
+ const planAuto = ctx ? { enabled: false } : computeAutoApprove('plan', _cwd);
1506
+ // Always stamp autoApproveSeconds (0 = server says OFF): the client's local pref
1507
+ // can disagree with the fork-resolved server value, and absence would make the
1508
+ // client fall back to its own countdown and defeat a fork-level off.
1509
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1510
+ autoApproveSeconds: planAuto.enabled ? planAuto.seconds : 0,
1511
+ ...(planAuto.enabled ? { autoApproveAt: planAuto.autoApproveAt } : {}) };
1493
1512
  if (_broadcastWs) {
1494
1513
  _broadcastWs(planPayload);
1495
1514
  }
1496
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
1515
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx, planAuto);
1497
1516
  if (result === null) {
1498
1517
  return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
1499
1518
  }
@@ -1523,7 +1542,7 @@ function _createSession(key, cwd) {
1523
1542
  // server/lib/ask/ask-constants.js.
1524
1543
  const askTimeoutMs = ASK_TIMEOUT_MS;
1525
1544
  const askStartedAt = Date.now();
1526
- const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
1545
+ const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1527
1546
  if (_broadcastWs) {
1528
1547
  _broadcastWs(askPayload);
1529
1548
  }
@@ -1599,12 +1618,18 @@ function _createSession(key, cwd) {
1599
1618
  // unknown decision → fall through to normal approval flow
1600
1619
  } catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
1601
1620
  }
1602
- const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1621
+ // npm publish is a hard safety floor: it must always wait for a HUMAN decision
1622
+ // (the 5-minute deny timeout applies), never the server-side auto-approve fallback.
1623
+ // Same exemption for unattended scheduled-task runs (ctx != null).
1624
+ const permAuto = (isPublish || ctx) ? { enabled: false } : computeAutoApprove('perm', _cwd);
1625
+ const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1626
+ autoApproveSeconds: permAuto.enabled ? permAuto.seconds : 0,
1627
+ ...(permAuto.enabled ? { autoApproveAt: permAuto.autoApproveAt } : {}) };
1603
1628
  if (_broadcastWs) {
1604
1629
  _broadcastWs(permPayload);
1605
1630
  }
1606
1631
 
1607
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
1632
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx, permAuto);
1608
1633
  if (result === null) {
1609
1634
  return { behavior: 'deny', message: 'Timeout waiting for user approval' };
1610
1635
  }
@@ -1645,30 +1670,52 @@ function _createSession(key, cwd) {
1645
1670
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1646
1671
  }
1647
1672
 
1648
- function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
1673
+ function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null, autoMeta = null) {
1649
1674
  // Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
1650
1675
  // callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
1651
1676
  // tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
1652
1677
  if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
1653
1678
  return new Promise((resolve) => {
1654
1679
  const timer = setTimeout(() => {
1680
+ const cur = _pendingApprovals.get(id);
1681
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1655
1682
  _pendingApprovals.delete(id);
1656
1683
  // Close the modal on every client — a silent deny would leave it hanging forever.
1657
1684
  _broadcastApprovalDismiss(kind, id, 'timeout');
1658
1685
  resolve(null);
1659
1686
  }, timeoutMs);
1660
- _pendingApprovals.set(id, {
1687
+ const entry = {
1661
1688
  kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
1662
1689
  // ask-cancel does not collide with a plan/perm id
1663
1690
  replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
1664
1691
  startedAt: Date.now(),
1665
1692
  timeoutMs,
1693
+ // Durable auto-approve cancellation: a client cancel marks this and clears the
1694
+ // server fallback timer, the approval stays pending for a manual decision, and
1695
+ // the flag is replayed to fresh clients so they never re-arm the countdown.
1696
+ autoCancelled: false,
1697
+ auto: null,
1666
1698
  resolve: (value) => {
1667
1699
  clearTimeout(timer);
1700
+ const cur = _pendingApprovals.get(id);
1701
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1668
1702
  _pendingApprovals.delete(id);
1669
1703
  resolve(value);
1670
1704
  },
1671
- });
1705
+ };
1706
+ _pendingApprovals.set(id, entry);
1707
+ // Arm the server-side fallback AFTER the entry is in the map: instant (-1) fires
1708
+ // synchronously and resolves through the same first-wins wrapper.
1709
+ if (autoMeta?.enabled) {
1710
+ entry.auto = createAutoApproveState(autoMeta, () => {
1711
+ const p = _pendingApprovals.get(id);
1712
+ if (!p || p.autoCancelled) return;
1713
+ p.resolve(kind === 'plan' ? { approve: true, feedback: '' } : { decision: 'allow' });
1714
+ try {
1715
+ _broadcastWs?.({ type: kind === 'plan' ? 'sdk-plan-resolved' : 'perm-hook-resolved', id, reason: 'auto-approved', cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1716
+ } catch (err) { reportSwallowed('sdk-manager.auto-approve-broadcast', err); }
1717
+ });
1718
+ }
1672
1719
  // CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
1673
1720
  // open): settle through the same first-wins path as a timeout so the promise resolves
1674
1721
  // to deny and the modal closes everywhere. { once: true } prevents listener leaks.
@@ -1711,11 +1758,37 @@ function _createSession(key, cwd) {
1711
1758
  for (const [id, p] of _pendingApprovals) {
1712
1759
  const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
1713
1760
  if (remaining <= 0) continue;
1714
- out.push({ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining });
1761
+ out.push({
1762
+ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining,
1763
+ ...replayAutoApproveFields(p.auto, p.autoCancelled),
1764
+ });
1715
1765
  }
1716
1766
  return out;
1717
1767
  }
1718
1768
 
1769
+ /**
1770
+ * Cancel the SERVER-SIDE auto-approve countdown for a pending plan/perm approval
1771
+ * (client clicked the countdown-cancel button). Unlike cancelApproval this does NOT
1772
+ * resolve the approval — it stays pending for a manual decision; only the fallback
1773
+ * timer is destroyed and the cancelled flag is replayed to fresh clients so they
1774
+ * never re-arm. Returns true when the id matched a pending approval of that kind
1775
+ * (even on a repeat cancel — the state change is durable while pending); false when
1776
+ * nothing matched (caller falls back to the legacy relay-only behavior).
1777
+ */
1778
+ function _autoCancelApproval(id, kind) {
1779
+ // plan/perm only — ask approvals never arm a server auto-approve timer.
1780
+ if (kind !== 'plan' && kind !== 'perm') return false;
1781
+ const pending = _pendingApprovals.get(id);
1782
+ if (!pending || pending.kind !== kind) return false;
1783
+ const first = cancelAutoApproveState(pending);
1784
+ if (first && _broadcastWs) {
1785
+ try {
1786
+ _broadcastWs({ type: kind === 'plan' ? 'sdk-plan-auto-cancel' : 'perm-hook-auto-cancel', id, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1787
+ } catch (err) { reportSwallowed('sdk-manager.auto-cancel-broadcast', err); }
1788
+ }
1789
+ return true;
1790
+ }
1791
+
1719
1792
  /**
1720
1793
  * Cancel a pending canUseTool approval — used by ask-cancel WS handler
1721
1794
  * (user clicked Cancel button or typed-interrupt in input bar).
@@ -2021,6 +2094,7 @@ function _createSession(key, cwd) {
2021
2094
  getQueueSnapshot: _getQueueSnapshot,
2022
2095
  resolveApproval: _resolveApproval,
2023
2096
  cancelApproval: _cancelApproval,
2097
+ autoCancelApproval: _autoCancelApproval,
2024
2098
  getPendingApprovals: _getPendingApprovals,
2025
2099
  getSdkInitSnapshot: () => _lastInitSnapshot,
2026
2100
  getSessionStateSnapshot: () => {
@@ -2256,6 +2330,19 @@ export function cancelApproval(id, reason) {
2256
2330
  return false;
2257
2331
  }
2258
2332
 
2333
+ /**
2334
+ * Cancel the server-side auto-approve countdown for a plan/perm approval,
2335
+ * scanning EVERY session (auto-cancel WS handlers). The approval stays pending
2336
+ * for a manual decision. Returns false when no pending approval matched, so the
2337
+ * caller can fall back to the legacy relay-only broadcast.
2338
+ */
2339
+ export function autoCancelApproval(id, kind) {
2340
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
2341
+ if (sess.autoCancelApproval(id, kind)) return true;
2342
+ }
2343
+ return false;
2344
+ }
2345
+
2259
2346
  /**
2260
2347
  * Snapshot of pending approvals for WS-reconnect replay (server.js connection
2261
2348
  * handler). WITHOUT an anchor: aggregated across ALL sessions; WITH an anchor: