cc-viewer 1.9.15 → 1.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +1 -0
- package/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/App-CYdOioAz.js +2 -0
- package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
- package/dist/assets/Mobile-CfNYOgRa.js +1 -0
- package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
- package/dist/assets/index-sgW8MlD3.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/file-access-policy.js +24 -2
- package/server/lib/sdk-manager.js +96 -9
- package/server/routes/ask-perm.js +54 -3
- package/server/routes/files-content.js +245 -3
- package/server/routes/skills.js +19 -8
- package/server/server.js +81 -10
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-BCDnm2Lh.js +0 -2
- package/dist/assets/App-CCnPkf2_.css +0 -1
- package/dist/assets/Mobile-C8VJqBwa.js +0 -1
- package/dist/assets/index-Sp3JYYKo.js +0 -2
- package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
- 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-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-sgW8MlD3.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.
|
|
3
|
+
"version": "1.9.16",
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -233,8 +233,30 @@ export function isReadAllowed(absPath) {
|
|
|
233
233
|
if (SENSITIVE_CLAUDE_FILES.has(last)) {
|
|
234
234
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
235
235
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
236
|
+
// settings.json / settings.local.json at any depth, plus all backups/variants
|
|
237
|
+
// (settings.json.bk*, settings_*.json, etc.) — rolling backup files carry the
|
|
238
|
+
// same env/api-key content and must not slip past the exact-name match.
|
|
239
|
+
if (/^settings(\.local)?\.json($|\.)|^settings_.*\.json$/i.test(last)) {
|
|
240
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
241
|
+
}
|
|
242
|
+
// Prompt history, project transcripts, shell snapshots, file history, and
|
|
243
|
+
// paste cache contain user prompt content and (in the case of shell snapshots)
|
|
244
|
+
// live env vars. Block the whole subtree EXCEPT projects/<encoded>/memory/**,
|
|
245
|
+
// which is the documented per-project memory dir served by /api/project-memory.
|
|
246
|
+
const rel = norm(real.slice(norm(withSep(claudeReal)).length));
|
|
247
|
+
const firstSeg = rel.split(sep)[0];
|
|
248
|
+
if (firstSeg === 'todos' || firstSeg === 'shell-snapshots'
|
|
249
|
+
|| firstSeg === 'file-history' || firstSeg === 'paste-cache' || firstSeg === 'session-env') {
|
|
250
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
251
|
+
}
|
|
252
|
+
if (firstSeg === 'projects') {
|
|
253
|
+
const parts = rel.split(sep);
|
|
254
|
+
const isMemoryDir = parts.length >= 3 && parts[2] === 'memory';
|
|
255
|
+
if (!isMemoryDir) {
|
|
256
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (last === 'history.jsonl') {
|
|
238
260
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
239
261
|
}
|
|
240
262
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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:
|
|
@@ -7,6 +7,8 @@ import { _projectName } from '../interceptor.js';
|
|
|
7
7
|
import { readPrefsRaw } from '../lib/prefs-store.js';
|
|
8
8
|
import { getCurrentProjectKey, hasFork, resolveScoped } from '../lib/project-prefs.js';
|
|
9
9
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
10
|
+
import { computeAutoApprove, createAutoApproveState } from '../lib/auto-approve.js';
|
|
11
|
+
import { isPublishCommand } from '../lib/approval-policy.js';
|
|
10
12
|
|
|
11
13
|
function pendingAsks(req, res, parsedUrl, isLocal, deps) {
|
|
12
14
|
try {
|
|
@@ -394,7 +396,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
394
396
|
return;
|
|
395
397
|
}
|
|
396
398
|
try {
|
|
397
|
-
const { toolName, input } = JSON.parse(body);
|
|
399
|
+
const { toolName, input, cwd } = JSON.parse(body);
|
|
398
400
|
if (!toolName) {
|
|
399
401
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
400
402
|
res.end(JSON.stringify({ error: 'Missing toolName' }));
|
|
@@ -407,6 +409,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
407
409
|
const oldest = deps.pendingPermHooks.get(oldestId);
|
|
408
410
|
if (oldest) {
|
|
409
411
|
clearTimeout(oldest.timer);
|
|
412
|
+
if (oldest.auto?.timer) clearTimeout(oldest.auto.timer);
|
|
410
413
|
try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
|
|
411
414
|
deps.pendingPermHooks.delete(oldestId);
|
|
412
415
|
}
|
|
@@ -431,6 +434,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
431
434
|
const timer = setTimeout(() => {
|
|
432
435
|
const entry = deps.pendingPermHooks.get(id);
|
|
433
436
|
if (entry) {
|
|
437
|
+
if (entry.auto?.timer) clearTimeout(entry.auto.timer);
|
|
434
438
|
deps.pendingPermHooks.delete(id);
|
|
435
439
|
try {
|
|
436
440
|
if (!entry.res.headersSent) {
|
|
@@ -447,11 +451,33 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
447
451
|
}
|
|
448
452
|
}, PERM_HOOK_TIMEOUT);
|
|
449
453
|
|
|
450
|
-
|
|
454
|
+
// Server-side auto-approve countdown (same semantics as the SDK path in
|
|
455
|
+
// sdk-manager.js): armed from request arrival; the server answers the hook
|
|
456
|
+
// itself at deadline + 1s buffer when no client did. autoCancelled is set by
|
|
457
|
+
// the perm-hook-auto-cancel WS handler (the approval then stays pending for
|
|
458
|
+
// a manual decision) and replayed to fresh clients.
|
|
459
|
+
// npm publish is a hard safety floor: always wait for a human decision, never
|
|
460
|
+
// the server-side auto-approve fallback (SDK path parity: sdk-manager.js).
|
|
461
|
+
const autoMeta = isPublishCommand(toolName, input)
|
|
462
|
+
? { enabled: false }
|
|
463
|
+
: computeAutoApprove('perm', typeof cwd === 'string' && cwd ? cwd : undefined, { file: deps.getPrefsFile?.() });
|
|
464
|
+
|
|
465
|
+
const hookEntry = {
|
|
466
|
+
toolName, input, res, timer, createdAt: Date.now(),
|
|
467
|
+
autoCancelled: false,
|
|
468
|
+
auto: null,
|
|
469
|
+
};
|
|
470
|
+
deps.pendingPermHooks.set(id, hookEntry);
|
|
451
471
|
|
|
452
472
|
// Broadcast to all terminal WS clients
|
|
453
473
|
if (deps.terminalWss) {
|
|
454
|
-
const pmsg = JSON.stringify({
|
|
474
|
+
const pmsg = JSON.stringify({
|
|
475
|
+
type: 'perm-hook-pending', id, toolName, input,
|
|
476
|
+
// Always stamp autoApproveSeconds (0 = server says OFF) so a client whose
|
|
477
|
+
// global pref disagrees with the fork-resolved value never re-arms locally.
|
|
478
|
+
autoApproveSeconds: autoMeta.enabled ? autoMeta.seconds : 0,
|
|
479
|
+
...(autoMeta.enabled ? { autoApproveAt: autoMeta.autoApproveAt } : {}),
|
|
480
|
+
});
|
|
455
481
|
deps.terminalWss.clients.forEach((client) => {
|
|
456
482
|
if (client.readyState === 1) {
|
|
457
483
|
try { client.send(pmsg); } catch {}
|
|
@@ -459,11 +485,36 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
459
485
|
});
|
|
460
486
|
}
|
|
461
487
|
|
|
488
|
+
// Arm AFTER the entry is in the map AND the pending frame is out: instant (-1)
|
|
489
|
+
// fires synchronously and resolves through the entry, so clients observe the
|
|
490
|
+
// order pending → resolved (never a ghost panel for an already-answered hook).
|
|
491
|
+
if (autoMeta.enabled) {
|
|
492
|
+
hookEntry.auto = createAutoApproveState(autoMeta, () => {
|
|
493
|
+
const entry = deps.pendingPermHooks.get(id);
|
|
494
|
+
if (!entry || entry.autoCancelled) return;
|
|
495
|
+
clearTimeout(entry.timer);
|
|
496
|
+
deps.pendingPermHooks.delete(id);
|
|
497
|
+
try {
|
|
498
|
+
if (!entry.res.headersSent) {
|
|
499
|
+
entry.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
500
|
+
entry.res.end(JSON.stringify({ decision: 'allow' }));
|
|
501
|
+
}
|
|
502
|
+
} catch {}
|
|
503
|
+
if (deps.terminalWss) {
|
|
504
|
+
const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id, reason: 'auto-approved' });
|
|
505
|
+
deps.terminalWss.clients.forEach((c) => {
|
|
506
|
+
if (c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
462
512
|
// Handle perm-bridge.js disconnection
|
|
463
513
|
res.on('close', () => {
|
|
464
514
|
const entry = deps.pendingPermHooks.get(id);
|
|
465
515
|
if (entry) {
|
|
466
516
|
clearTimeout(entry.timer);
|
|
517
|
+
if (entry.auto?.timer) clearTimeout(entry.auto.timer);
|
|
467
518
|
deps.pendingPermHooks.delete(id);
|
|
468
519
|
if (deps.terminalWss) {
|
|
469
520
|
const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
|