cc-viewer 1.9.11 → 1.9.13
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/README.md +8 -0
- package/dist/assets/{App-D3swMyLc.js → App-BzOM1_y-.js} +2 -2
- package/dist/assets/{MdxEditorPanel-DeyoQsxw.js → MdxEditorPanel-Db1nmcJV.js} +1 -1
- package/dist/assets/{Mobile-DvI_Kby2.js → Mobile-rjfABa-U.js} +1 -1
- package/dist/assets/{ProxyStatsModal-CoI275by.js → ProxyStatsModal-Ch1BH0Kp.js} +1 -1
- package/dist/assets/{_baseUniq-BvPjRimO.js → _baseUniq-CsF04anE.js} +1 -1
- package/dist/assets/{arc-BzZyqc6p.js → arc-BOacYe6N.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-B1oszHzA.js → architectureDiagram-Q4EWVU46-BSUEfjC_.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Ndp4o_QB.js → blockDiagram-DXYQGD6D-CC37UGtW.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DlDE-ALt.js → c4Diagram-AHTNJAMY-IzgRD4uO.js} +1 -1
- package/dist/assets/{channel-BhXx9y6q.js → channel-C6PxyQUn.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-CM-2e6q_.js → chunk-4BX2VUAB-cdQt_dcU.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-GWLkQKRO.js → chunk-4TB4RGXK-BpTDA_Pb.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B6LOFqRZ.js → chunk-55IACEB6-CcitNo0P.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Ct3e09Ku.js → chunk-EDXVE4YY-BjKwZ9I7.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DK-vXpZP.js → chunk-FMBD7UC4-vZDVX3tI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CWxZc_DU.js → chunk-OYMX7WX6-DGkYahGo.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-jp8ISHue.js → chunk-QZHKN3VN-C9065hSE.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DtYet6WV.js → chunk-YZCP3GAM-D_pcTg6_.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BUmU_G-i.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BUmU_G-i.js +1 -0
- package/dist/assets/clone-CvoOne2K.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-lNC-slVX.js → cose-bilkent-S5V4N54A-BPPFMIGv.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-vm6U82qq.js → dagre-KV5264BT-fjzhKO5o.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-Dq3FXh-U.js → diagram-5BDNPKRD-5L6FzTry.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BxF85wcF.js → diagram-G4DWMVQ6-GOwcGpff.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CFCTeMz0.js → diagram-MMDJMWI5-BDsFE25W.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Bui5Azdg.js → diagram-TYMM5635-BqjpbSGH.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-DrpUO1Kb.js → erDiagram-SMLLAGMA-DWO3Dk1v.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-Bb3amxiK.js → flowDiagram-DWJPFMVM-Br4nNBVd.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BRwCFM3C.js → ganttDiagram-T4ZO3ILL-9sN-VOhw.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DVXbBwhs.js → gitGraphDiagram-UUTBAWPF-BsPcTAKr.js} +1 -1
- package/dist/assets/{graph-Dh22wYWs.js → graph-o31GzaB6.js} +1 -1
- package/dist/assets/{index-CfFFgxxf.js → index-B3b0lRUB.js} +1 -1
- package/dist/assets/{index-BRW8dfBU.js → index-CAiC6cw-.js} +2 -2
- package/dist/assets/{index-BwZB7UwN.js → index-CXMr3tiK.js} +1 -1
- package/dist/assets/{index-UlO7Q5dX.js → index-CuvDJTY7.js} +1 -1
- package/dist/assets/{index-Cfm4N4ao.js → index-DAjx9lT3.js} +1 -1
- package/dist/assets/{index-xZXOwglE.js → index-DyV81jSF.js} +1 -1
- package/dist/assets/{index-CQ1Nswa3.js → index-PDvAAbti.js} +1 -1
- package/dist/assets/{index-e8wejtj_.js → index-tZqiJJGq.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-cQPbF7Q6.js → infoDiagram-42DDH7IO-VUMzEo4o.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-cp-ir90Y.js → ishikawaDiagram-UXIWVN3A-BuzrU3sx.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-aoMcyNLC.js → journeyDiagram-VCZTEJTY-BZtR5BAF.js} +1 -1
- package/dist/assets/{jszip.min-C7BBsGL5.js → jszip.min-CtodH5MV.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Cugr0o0f.js → kanban-definition-6JOO6SKY-Cj_97xXb.js} +1 -1
- package/dist/assets/{layout-CL0eiocE.js → layout-Du0I-tlh.js} +1 -1
- package/dist/assets/{linear-D6EJvwNs.js → linear-BJtxCASI.js} +1 -1
- package/dist/assets/{mermaid.core-CNYcCP19.js → mermaid.core-QlorX0Yw.js} +2 -2
- package/dist/assets/{min-dFBWKmZ_.js → min-BVnQfunZ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-4fbr7E2n.js → mindmap-definition-QFDTVHPH-Bh_KL4qU.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-UHWzU_JW.js → pieDiagram-DEJITSTG-Bvv0rC_q.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-e87bZkAb.js → quadrantDiagram-34T5L4WZ-Bw8cGCRU.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-l_97YNSi.js → requirementDiagram-MS252O5E-Cg9jQ71h.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-D3JFnltO.js → sankeyDiagram-XADWPNL6-BXJo6-CD.js} +1 -1
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +41 -0
- package/dist/assets/seqResourceLoaders-Ds8-_QA3.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-5jDKcsRZ.js → sequenceDiagram-FGHM5R23-D7zpGxwq.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-D5_gBKn8.js → stateDiagram-FHFEXIEX-Der2CqR1.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js → stateDiagram-v2-QKLJ7IA2-DetvG_Lc.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-7IVQRwK1.js → timeline-definition-GMOUNBTQ-0UenzEZ6.js} +1 -1
- package/dist/assets/{vendor-antd-CfuxfYfy.js → vendor-antd-I6I2F7Rv.js} +1 -1
- package/dist/assets/{vendor-codemirror-C4Jn4TTc.js → vendor-codemirror-Zkm6UHQg.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BreKt5Hd.js → vendor-mdxeditor-CC09MDyZ.js} +2 -2
- package/dist/assets/{vendor-qrcode-CutTqXgh.js → vendor-qrcode-gZpmSeeh.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Cc9ovCDf.js → vendor-virtuoso-BGwznQ5y.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-D6qzGquN.js → vennDiagram-DHZGUBPP-COzYyZ5h.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DzAFqZmY.js → wardley-RL74JXVD-VZdOtN8t.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CImP9kv8.js → wardleyDiagram-NUSXRM2D-CVkL75Qy.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Co45rRaM.js → xychartDiagram-5P7HB3ND-6CHBAYyF.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/lib/chat-queue.js +14 -1
- package/server/lib/config-backup.js +4 -2
- package/server/lib/cron.js +181 -0
- package/server/lib/file-access-policy.js +45 -1
- package/server/lib/im-deny.js +12 -9
- package/server/lib/json-store.js +27 -1
- package/server/lib/project-prefs.js +4 -1
- package/server/lib/schedule-model.js +149 -0
- package/server/lib/scheduled-task-executor.js +97 -0
- package/server/lib/scheduled-task-runs-store.js +99 -0
- package/server/lib/scheduled-task-scheduler.js +254 -0
- package/server/lib/scheduled-task-store.js +511 -0
- package/server/lib/sdk-manager.js +283 -12
- package/server/pty-manager.js +13 -2
- package/server/routes/preferences.js +24 -1
- package/server/routes/scheduled-tasks.js +310 -0
- package/server/server.js +135 -0
- package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +0 -1
- package/dist/assets/clone-fiofFlrV.js +0 -1
- package/dist/assets/seqResourceLoaders-8kfGbCYo.js +0 -2
- package/dist/assets/seqResourceLoaders-C2YGihCH.css +0 -41
|
@@ -32,7 +32,7 @@ import { evaluateImDeny } from './im-deny.js';
|
|
|
32
32
|
import { APPROVAL_TOOLS, isOverTrustDir, isPublishCommand } from './approval-policy.js';
|
|
33
33
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
34
34
|
import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } from 'node:path';
|
|
35
|
-
import { createHash } from 'node:crypto';
|
|
35
|
+
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
|
|
|
@@ -49,6 +49,12 @@ try {
|
|
|
49
49
|
// MUST stay module-level (outside any Session closure): tests set it BEFORE initSdkSession.
|
|
50
50
|
export function __setQueryForTests(fn) { _query = fn; }
|
|
51
51
|
|
|
52
|
+
// Test seams for the ephemeral-spawn wiring: observe the module-level turn-end callback
|
|
53
|
+
// and the ephemeral-host set without a full initSdkSession.
|
|
54
|
+
export function __setOnTurnEndForTests(fn) { _onTurnEnd = fn; }
|
|
55
|
+
export function __ephemeralHostCountForTests() { return _ephemeralHosts.size; }
|
|
56
|
+
export function __ephemeralAbortCountForTests() { return _ephemeralAborts.size; }
|
|
57
|
+
|
|
52
58
|
// ── Session registry ─────────────────────────────────────────────────────────
|
|
53
59
|
// Keyed by path.resolve(cwd) — normalize only, NO realpath (house exact-string
|
|
54
60
|
// convention, routes/resume.js, so `/tmp` vs `/private/tmp` stay distinct). The
|
|
@@ -56,6 +62,14 @@ export function __setQueryForTests(fn) { _query = fn; }
|
|
|
56
62
|
// selects which session the SDK composer and WS handlers act on.
|
|
57
63
|
const _sessions = new Map();
|
|
58
64
|
let _activeKey = null;
|
|
65
|
+
// Ephemeral-turn hosts (scheduled-task spawn): sessions whose approval plumbing is
|
|
66
|
+
// currently borrowed by a standalone run. resolveApproval/cancelApproval/
|
|
67
|
+
// getPendingApprovals scan the UNION of _sessions and this set, so an ephemeral run's
|
|
68
|
+
// approvals stay resolvable even if the user closes the host workspace mid-run.
|
|
69
|
+
const _ephemeralHosts = new Set();
|
|
70
|
+
// AbortControllers of in-flight ephemeral turns, so stopSession() can abort them on
|
|
71
|
+
// shutdown (Electron stop/start, process exit) instead of leaking a hung child.
|
|
72
|
+
const _ephemeralAborts = new Set();
|
|
59
73
|
|
|
60
74
|
function _normalizeCwd(cwd) {
|
|
61
75
|
if (typeof cwd !== 'string' || !cwd) return '';
|
|
@@ -624,6 +638,167 @@ function _createSession(key, cwd) {
|
|
|
624
638
|
_drainResidentIterator(query, inputQueue);
|
|
625
639
|
}
|
|
626
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Run ONE standalone prompt in an ephemeral query that shares THIS session's approval
|
|
643
|
+
* plumbing (canUseTool host) but never touches resident state: not registered in
|
|
644
|
+
* `_sessions`, doesn't change `_activeKey`, doesn't go through `_processMessage`
|
|
645
|
+
* (so `_sessionId`/`_queryBusy`/lifecycle frames/watchdog are all untouched), and the
|
|
646
|
+
* result is judged directly from the query's own `result` message.
|
|
647
|
+
*
|
|
648
|
+
* Used by scheduled-task "spawn" mode: each run is a fresh, isolated headless session
|
|
649
|
+
* whose transcript routes to the task's project via cloned env headers (the resident
|
|
650
|
+
* session's env object is mutated in place by _ensureResidentQuery, so we must NOT
|
|
651
|
+
* reuse its reference — see _cloneEphemeralEnv).
|
|
652
|
+
*
|
|
653
|
+
* Returns { ok:true, sessionId, subtype, durationMs }
|
|
654
|
+
* or { ok:false, reason, error?, sessionId? }.
|
|
655
|
+
*/
|
|
656
|
+
async function _runEphemeralTurn(prompt, { timeoutMs = 30 * 60 * 1000, signal, permissionMode } = {}) {
|
|
657
|
+
if (!_query) return { ok: false, reason: 'sdk_unavailable' };
|
|
658
|
+
const ctx = { sessionId: null, approvalIds: [] };
|
|
659
|
+
const abort = new AbortController();
|
|
660
|
+
_ephemeralAborts.add(abort);
|
|
661
|
+
// Pre-aborted signal: bail BEFORE registering listeners / spawning (the abort listener
|
|
662
|
+
// would never fire for an already-aborted signal, wedging the run until the 30min
|
|
663
|
+
// timeout — review P3-7).
|
|
664
|
+
if (signal && signal.aborted) { _ephemeralAborts.delete(abort); return { ok: false, reason: 'aborted' }; }
|
|
665
|
+
const onOuterAbort = () => abort.abort();
|
|
666
|
+
if (signal) { try { signal.addEventListener('abort', onOuterAbort, { once: true }); } catch { /* best-effort */ } }
|
|
667
|
+
|
|
668
|
+
// Assemble options by reusing this host session's config, but with a CLONED env so
|
|
669
|
+
// the resident query's in-place header append can't leak the host's project/instance
|
|
670
|
+
// routing into this isolated child (and vice versa).
|
|
671
|
+
const env = _cloneEphemeralEnv(_childEnv);
|
|
672
|
+
if (!env) {
|
|
673
|
+
_ephemeralAborts.delete(abort);
|
|
674
|
+
if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
|
|
675
|
+
return { ok: false, reason: 'not_initialized' };
|
|
676
|
+
}
|
|
677
|
+
const ephInstanceKey = `ccv-${randomBytes(16).toString('hex')}`; // matches interceptor's /^ccv-[0-9a-f]+$/
|
|
678
|
+
// Permission mode (review: security P1-3): a spawn task runs unattended, so it must NOT
|
|
679
|
+
// silently inherit a bypassPermissions host. Default to 'default' unless the task
|
|
680
|
+
// explicitly opted into inheriting the host's mode.
|
|
681
|
+
const ephPermissionMode = (typeof permissionMode === 'string' && permissionMode) ? permissionMode : 'default';
|
|
682
|
+
const options = {
|
|
683
|
+
cwd: _cwd,
|
|
684
|
+
permissionMode: ephPermissionMode,
|
|
685
|
+
canUseTool: (tool, input, opts) => _handleCanUseTool(tool, input, opts, ctx),
|
|
686
|
+
...ephPermissionMode === 'bypassPermissions' && { allowDangerouslySkipPermissions: true },
|
|
687
|
+
};
|
|
688
|
+
options.env = env;
|
|
689
|
+
// Route this child's transcript to ITS project (the task's cwd) under a synthetic
|
|
690
|
+
// instance, exactly like the resident path tags x-ccv-project-dir/x-ccv-instance.
|
|
691
|
+
try {
|
|
692
|
+
const headerCwd = String(_cwd).replace(/[\r\n\x00-\x1f\x7f]/g, '');
|
|
693
|
+
if (headerCwd) {
|
|
694
|
+
const injected = `x-ccv-project-dir: ${headerCwd}\nx-ccv-instance: ${ephInstanceKey}`;
|
|
695
|
+
options.env.ANTHROPIC_CUSTOM_HEADERS = options.env.ANTHROPIC_CUSTOM_HEADERS
|
|
696
|
+
? `${options.env.ANTHROPIC_CUSTOM_HEADERS}\n${injected}` : injected;
|
|
697
|
+
}
|
|
698
|
+
} catch (err) { reportSwallowed('sdk-manager.ephemeral-headers', err); }
|
|
699
|
+
if (_settings) options.settings = _settings;
|
|
700
|
+
if (_launchModel) options.model = _launchModel;
|
|
701
|
+
if (_claudeExecutable) options.pathToClaudeCodeExecutable = _claudeExecutable;
|
|
702
|
+
// Fresh session: no resume; inject the startup system prompt like a new launch.
|
|
703
|
+
try {
|
|
704
|
+
const launchArgs = process.env.CCV_SKIP_THINKING_DISPLAY === '1' ? _userArgs : withDefaultThinkingDisplay(_userArgs);
|
|
705
|
+
const lc = resolveLaunchSystemPrompt({
|
|
706
|
+
spawnDir: _cwd, extraArgs: launchArgs, env: env || process.env,
|
|
707
|
+
launchSettings: _settings, resume: null, persistPending: true,
|
|
708
|
+
});
|
|
709
|
+
options.extraArgs = launchArgsToExtraArgs([...launchArgs, ...lc.sysPrompt.args]);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
reportSwallowed('sdk-manager.ephemeral-sysprompt', err);
|
|
712
|
+
try { options.extraArgs = launchArgsToExtraArgs(_userArgs); } catch { /* best-effort */ }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// _query() can throw synchronously (bad options); that must still release the abort
|
|
716
|
+
// handle registered above instead of leaking it into _ephemeralAborts (review P3).
|
|
717
|
+
let q;
|
|
718
|
+
try {
|
|
719
|
+
q = _query({ prompt: String(prompt == null ? '' : prompt), options });
|
|
720
|
+
} catch (err) {
|
|
721
|
+
_ephemeralAborts.delete(abort);
|
|
722
|
+
if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
|
|
723
|
+
return { ok: false, reason: 'internal', error: String(err?.message || err) };
|
|
724
|
+
}
|
|
725
|
+
const startedAt = Date.now();
|
|
726
|
+
|
|
727
|
+
const cleanupApprovals = (reason) => {
|
|
728
|
+
// Resolve + dismiss any approvals THIS run left pending so they don't ghost in the
|
|
729
|
+
// host's map for 24h (AskUserQuestion) and replay as phantom modals on reconnect.
|
|
730
|
+
for (const id of ctx.approvalIds) {
|
|
731
|
+
const pending = _pendingApprovals.get(id);
|
|
732
|
+
try { pending?.resolve(null); } catch { /* best-effort */ }
|
|
733
|
+
try { _pendingApprovals.delete(id); } catch { /* best-effort */ }
|
|
734
|
+
if (pending) { try { _broadcastApprovalDismiss(pending.kind, id, reason); } catch { /* best-effort */ } }
|
|
735
|
+
}
|
|
736
|
+
ctx.approvalIds.length = 0;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const run = (async () => {
|
|
740
|
+
let resultMsg = null;
|
|
741
|
+
try {
|
|
742
|
+
for await (const msg of q) {
|
|
743
|
+
if (msg?.type === 'system' && msg.subtype === 'init' && msg.session_id) ctx.sessionId = msg.session_id;
|
|
744
|
+
if (msg?.type === 'result') { resultMsg = msg; if (msg.session_id) ctx.sessionId = msg.session_id; }
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
if (abort.signal.aborted) return { ok: false, reason: 'aborted', sessionId: ctx.sessionId };
|
|
748
|
+
return { ok: false, reason: 'internal', error: String(err?.message || err), sessionId: ctx.sessionId };
|
|
749
|
+
}
|
|
750
|
+
if (!resultMsg) return { ok: false, reason: 'internal', error: 'no result message', sessionId: ctx.sessionId };
|
|
751
|
+
const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
|
|
752
|
+
if (!isError) {
|
|
753
|
+
return { ok: true, sessionId: ctx.sessionId || resultMsg.session_id || null, subtype: resultMsg.subtype, durationMs: Date.now() - startedAt };
|
|
754
|
+
}
|
|
755
|
+
const detail = Array.isArray(resultMsg.errors) && resultMsg.errors.length
|
|
756
|
+
? resultMsg.errors.join('; ')
|
|
757
|
+
: (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
|
|
758
|
+
return { ok: false, reason: 'turn-error', error: String(detail), sessionId: ctx.sessionId || resultMsg.session_id || null, durationMs: Date.now() - startedAt };
|
|
759
|
+
})();
|
|
760
|
+
|
|
761
|
+
let timer = null;
|
|
762
|
+
let timedOut = false;
|
|
763
|
+
const timeout = new Promise((resolve) => {
|
|
764
|
+
timer = setTimeout(() => {
|
|
765
|
+
timedOut = true;
|
|
766
|
+
try { abort.abort(); } catch {}
|
|
767
|
+
try { q.close?.(); } catch {}
|
|
768
|
+
resolve({ ok: false, reason: 'timeout', sessionId: ctx.sessionId });
|
|
769
|
+
}, timeoutMs);
|
|
770
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
771
|
+
});
|
|
772
|
+
const aborted = new Promise((resolve) => {
|
|
773
|
+
try {
|
|
774
|
+
abort.signal.addEventListener('abort', () => {
|
|
775
|
+
// A timeout-driven abort reports as 'timeout', not 'aborted' — the timeout
|
|
776
|
+
// branch wins the race semantics even though both fire on the same signal.
|
|
777
|
+
if (timedOut) return;
|
|
778
|
+
try { q.close?.(); } catch {}
|
|
779
|
+
resolve({ ok: false, reason: 'aborted', sessionId: ctx.sessionId });
|
|
780
|
+
}, { once: true });
|
|
781
|
+
} catch { /* best-effort */ }
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const r = await Promise.race([run, timeout, aborted]);
|
|
786
|
+
// Emit a turn-end so the panel's new session card leaves the "running" state
|
|
787
|
+
// (review P1-2): module-level _onTurnEnd doesn't touch host state.
|
|
788
|
+
if (ctx.sessionId && _onTurnEnd) {
|
|
789
|
+
try { _onTurnEnd({ sessionId: ctx.sessionId, ts: Date.now(), cwd: _cwd }); }
|
|
790
|
+
catch (err) { reportSwallowed('sdk-manager.ephemeral-turn-end', err); }
|
|
791
|
+
}
|
|
792
|
+
if (ctx.approvalIds.length) cleanupApprovals(r.ok ? 'completed' : (r.reason || 'ended'));
|
|
793
|
+
return r;
|
|
794
|
+
} finally {
|
|
795
|
+
if (timer) clearTimeout(timer);
|
|
796
|
+
_ephemeralAborts.delete(abort);
|
|
797
|
+
try { abort.abort(); } catch { /* release listeners */ }
|
|
798
|
+
if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
627
802
|
async function _drainResidentIterator(query, inputQueue) {
|
|
628
803
|
try {
|
|
629
804
|
for await (const msg of query) {
|
|
@@ -1090,8 +1265,19 @@ function _createSession(key, cwd) {
|
|
|
1090
1265
|
* short-circuits the ExitPlanMode/AskUserQuestion interactive branches;
|
|
1091
1266
|
* 4. the three regular branches (plan / ask / perm).
|
|
1092
1267
|
*/
|
|
1093
|
-
async function _handleCanUseTool(toolName, input, options) {
|
|
1268
|
+
async function _handleCanUseTool(toolName, input, options, ctx = null) {
|
|
1094
1269
|
const id = options?.toolUseID || `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1270
|
+
// Ephemeral-turn override (scheduled-task spawn): the approval payloads below carry
|
|
1271
|
+
// the ephemeral run's own sessionId (tracked in ctx as the init/result messages
|
|
1272
|
+
// stream in), NOT this host session's `_sessionId` — otherwise the pending modal is
|
|
1273
|
+
// attributed to the host's conversation. Frontend dismiss matches on `id` only, so
|
|
1274
|
+
// the host's sessionId stays untouched on the dismiss path.
|
|
1275
|
+
const effSessionId = () => (ctx && ctx.sessionId) || _sessionId || null;
|
|
1276
|
+
// Origin marker (security P2-1): an ephemeral/scheduled-task run's approval modal must
|
|
1277
|
+
// be identifiable as coming from an unattended task, not the user's live conversation.
|
|
1278
|
+
const origin = ctx ? { origin: 'scheduled-task' } : {};
|
|
1279
|
+
// Approval-id tracking happens inside _waitForApproval (only reached when a tool truly
|
|
1280
|
+
// parks for user input) so auto-allowed tools don't grow ctx.approvalIds unboundedly.
|
|
1095
1281
|
|
|
1096
1282
|
// 1. IM worker hard deny — must run before any auto-allow (perm-bridge.js parity).
|
|
1097
1283
|
if (process.env.CCV_IM_DENY === '1') {
|
|
@@ -1124,11 +1310,11 @@ function _createSession(key, cwd) {
|
|
|
1124
1310
|
}
|
|
1125
1311
|
} catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
|
|
1126
1312
|
}
|
|
1127
|
-
const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId:
|
|
1313
|
+
const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
|
|
1128
1314
|
if (_broadcastWs) {
|
|
1129
1315
|
_broadcastWs(planPayload);
|
|
1130
1316
|
}
|
|
1131
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal);
|
|
1317
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
|
|
1132
1318
|
if (result === null) {
|
|
1133
1319
|
return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
|
|
1134
1320
|
}
|
|
@@ -1158,11 +1344,11 @@ function _createSession(key, cwd) {
|
|
|
1158
1344
|
// server/lib/ask/ask-constants.js.
|
|
1159
1345
|
const askTimeoutMs = ASK_TIMEOUT_MS;
|
|
1160
1346
|
const askStartedAt = Date.now();
|
|
1161
|
-
const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId:
|
|
1347
|
+
const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
|
|
1162
1348
|
if (_broadcastWs) {
|
|
1163
1349
|
_broadcastWs(askPayload);
|
|
1164
1350
|
}
|
|
1165
|
-
const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal);
|
|
1351
|
+
const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal, ctx);
|
|
1166
1352
|
if (answers === null) {
|
|
1167
1353
|
return { behavior: 'deny', message: 'Timeout waiting for user answer' };
|
|
1168
1354
|
}
|
|
@@ -1234,12 +1420,12 @@ function _createSession(key, cwd) {
|
|
|
1234
1420
|
// unknown decision → fall through to normal approval flow
|
|
1235
1421
|
} catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
|
|
1236
1422
|
}
|
|
1237
|
-
const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId:
|
|
1423
|
+
const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
|
|
1238
1424
|
if (_broadcastWs) {
|
|
1239
1425
|
_broadcastWs(permPayload);
|
|
1240
1426
|
}
|
|
1241
1427
|
|
|
1242
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal);
|
|
1428
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
|
|
1243
1429
|
if (result === null) {
|
|
1244
1430
|
return { behavior: 'deny', message: 'Timeout waiting for user approval' };
|
|
1245
1431
|
}
|
|
@@ -1280,7 +1466,11 @@ function _createSession(key, cwd) {
|
|
|
1280
1466
|
} catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
|
|
1281
1467
|
}
|
|
1282
1468
|
|
|
1283
|
-
function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null) {
|
|
1469
|
+
function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
|
|
1470
|
+
// Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
|
|
1471
|
+
// callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
|
|
1472
|
+
// tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
|
|
1473
|
+
if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
|
|
1284
1474
|
return new Promise((resolve) => {
|
|
1285
1475
|
const timer = setTimeout(() => {
|
|
1286
1476
|
_pendingApprovals.delete(id);
|
|
@@ -1649,6 +1839,7 @@ function _createSession(key, cwd) {
|
|
|
1649
1839
|
},
|
|
1650
1840
|
switchToSession: _switchToSession,
|
|
1651
1841
|
getSessionId: () => _sessionId,
|
|
1842
|
+
runEphemeralTurn: _runEphemeralTurn,
|
|
1652
1843
|
close: () => {
|
|
1653
1844
|
for (const { id, kind } of _cancelAllApprovals()) {
|
|
1654
1845
|
_broadcastApprovalDismiss(kind, id, 'session-closed');
|
|
@@ -1667,6 +1858,78 @@ export function isSdkAvailable() {
|
|
|
1667
1858
|
return typeof _query === 'function';
|
|
1668
1859
|
}
|
|
1669
1860
|
|
|
1861
|
+
/**
|
|
1862
|
+
* Clone the host launch env for an ephemeral child, stripping any x-ccv-project-dir /
|
|
1863
|
+
* x-ccv-instance lines the resident query already appended IN PLACE (cli.js hands the
|
|
1864
|
+
* same childEnv object to initSdkSession AND setSdkLaunchConfig; _ensureResidentQuery
|
|
1865
|
+
* mutates it). Without this the isolated child would report the HOST's routing headers
|
|
1866
|
+
* and its transcript would be written under the wrong project/instance.
|
|
1867
|
+
* Returns null when there is no env to clone (SDK children default to process.env only
|
|
1868
|
+
* when `env` is omitted entirely — passing `{}` would spawn with an EMPTY environment).
|
|
1869
|
+
*/
|
|
1870
|
+
function _cloneEphemeralEnv(env) {
|
|
1871
|
+
if (!env || typeof env !== 'object') return null;
|
|
1872
|
+
const out = { ...env };
|
|
1873
|
+
if (typeof out.ANTHROPIC_CUSTOM_HEADERS === 'string') {
|
|
1874
|
+
out.ANTHROPIC_CUSTOM_HEADERS = out.ANTHROPIC_CUSTOM_HEADERS
|
|
1875
|
+
.split('\n')
|
|
1876
|
+
.filter(l => !/^x-ccv-(project-dir|instance):/i.test(l.trim()))
|
|
1877
|
+
.join('\n');
|
|
1878
|
+
}
|
|
1879
|
+
return out;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Spawn a standalone, single-prompt SDK session for a scheduled task ("spawn" mode).
|
|
1884
|
+
* The run is isolated from the user's resident conversation: it never enters
|
|
1885
|
+
* `_sessions`, never changes `_activeKey`, and its result is judged from the query's
|
|
1886
|
+
* own `result` message. Approval capability is borrowed from a host session for the
|
|
1887
|
+
* task's cwd (the existing resident session when one is registered, else a temporary
|
|
1888
|
+
* isolated host that is created just to carry approvals and torn down right after).
|
|
1889
|
+
*
|
|
1890
|
+
* SDK-mode only: without a launch-config template (setSdkLaunchConfig) the child would
|
|
1891
|
+
* bypass ccv's loopback proxy and produce no transcript, so we refuse.
|
|
1892
|
+
*
|
|
1893
|
+
* Returns { ok:true, sessionId, subtype, durationMs }
|
|
1894
|
+
* or { ok:false, reason:'sdk_unavailable'|'not_initialized'|'timeout'|'aborted'|'turn-error'|'internal', error?, sessionId? }.
|
|
1895
|
+
*/
|
|
1896
|
+
export async function spawnEphemeralSession(prompt, { cwd, project, timeoutMs = 30 * 60 * 1000, signal, permissionMode } = {}) {
|
|
1897
|
+
if (!_query) return { ok: false, reason: 'sdk_unavailable' };
|
|
1898
|
+
const cfg = _launchConfigTemplate;
|
|
1899
|
+
if (!cfg) return { ok: false, reason: 'not_initialized' };
|
|
1900
|
+
if (!cwd) return { ok: false, reason: 'not_initialized' };
|
|
1901
|
+
|
|
1902
|
+
const key = _normalizeCwd(cwd);
|
|
1903
|
+
let host = _sessions.get(key) || null;
|
|
1904
|
+
let created = false;
|
|
1905
|
+
if (!host) {
|
|
1906
|
+
// No resident session for this cwd in this boot: create an isolated host purely to
|
|
1907
|
+
// carry approvals (its canUseTool plumbing). It is NOT registered in _sessions.
|
|
1908
|
+
host = _createSession(`__ephemeral_host__:${key}`, cwd);
|
|
1909
|
+
try { host.init({ ...cfg }); }
|
|
1910
|
+
catch (err) { reportSwallowed('sdk-manager.ephemeral-init', err); return { ok: false, reason: 'not_initialized', error: String(err?.message || err) }; }
|
|
1911
|
+
created = true;
|
|
1912
|
+
}
|
|
1913
|
+
_ephemeralHosts.add(host);
|
|
1914
|
+
try {
|
|
1915
|
+
const r = await host.runEphemeralTurn(prompt, { timeoutMs, signal, permissionMode });
|
|
1916
|
+
return { ...r, sessionId: r.sessionId || null };
|
|
1917
|
+
} finally {
|
|
1918
|
+
_ephemeralHosts.delete(host);
|
|
1919
|
+
if (created) { try { host.teardown(); } catch (err) { reportSwallowed('sdk-manager.ephemeral-teardown', err); } }
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** Test seam: is a cwd's session currently hosting an ephemeral run? Checks the ephemeral
|
|
1924
|
+
* host set directly (an isolated host for an unregistered cwd never enters _sessions). */
|
|
1925
|
+
export function __isEphemeralHostForTests(cwd) {
|
|
1926
|
+
const key = _normalizeCwd(cwd);
|
|
1927
|
+
for (const host of _ephemeralHosts) {
|
|
1928
|
+
if (host.key === key || host.key === `__ephemeral_host__:${key}`) return true;
|
|
1929
|
+
}
|
|
1930
|
+
return false;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1670
1933
|
/**
|
|
1671
1934
|
* Initialize (or re-initialize) a workspace's SDK session.
|
|
1672
1935
|
* Does NOT start a query — waits for the first user message via sendUserMessage().
|
|
@@ -1760,7 +2023,9 @@ export async function sendUserMessage(text, anchor) {
|
|
|
1760
2023
|
* server.js when a WS message arrives. Does not switch active.
|
|
1761
2024
|
*/
|
|
1762
2025
|
export function resolveApproval(id, value) {
|
|
1763
|
-
|
|
2026
|
+
// Scan the union of resident sessions and ephemeral hosts: an ephemeral run's
|
|
2027
|
+
// approvals must stay resolvable even if the user closed the host workspace mid-run.
|
|
2028
|
+
for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
|
|
1764
2029
|
if (sess.resolveApproval(id, value)) return true;
|
|
1765
2030
|
}
|
|
1766
2031
|
return false;
|
|
@@ -1771,7 +2036,7 @@ export function resolveApproval(id, value) {
|
|
|
1771
2036
|
* WS handler). Does not switch active.
|
|
1772
2037
|
*/
|
|
1773
2038
|
export function cancelApproval(id, reason) {
|
|
1774
|
-
for (const sess of _sessions.values()) {
|
|
2039
|
+
for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
|
|
1775
2040
|
if (sess.cancelApproval(id, reason)) return true;
|
|
1776
2041
|
}
|
|
1777
2042
|
return false;
|
|
@@ -1786,7 +2051,7 @@ export function getPendingApprovals(anchor) {
|
|
|
1786
2051
|
const target = anchor ? _sessionForAnchor(anchor) : null;
|
|
1787
2052
|
if (target) return target.getPendingApprovals();
|
|
1788
2053
|
const out = [];
|
|
1789
|
-
for (const sess of _sessions.values()) out.push(...sess.getPendingApprovals());
|
|
2054
|
+
for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) out.push(...sess.getPendingApprovals());
|
|
1790
2055
|
return out;
|
|
1791
2056
|
}
|
|
1792
2057
|
|
|
@@ -1842,6 +2107,12 @@ export function getQueueSnapshot(anchor) {
|
|
|
1842
2107
|
* session and clears the registry + the active pointer (process-exit cleanup path).
|
|
1843
2108
|
*/
|
|
1844
2109
|
export function stopSession() {
|
|
2110
|
+
// Abort any in-flight ephemeral turns FIRST (their host may be about to be torn down),
|
|
2111
|
+
// then tear down resident sessions and ephemeral hosts.
|
|
2112
|
+
for (const abort of _ephemeralAborts) { try { abort.abort(); } catch { /* best-effort */ } }
|
|
2113
|
+
_ephemeralAborts.clear();
|
|
2114
|
+
for (const host of _ephemeralHosts) { try { host.teardown(); } catch (err) { reportSwallowed('sdk-manager.ephemeral-stop', err); } }
|
|
2115
|
+
_ephemeralHosts.clear();
|
|
1845
2116
|
for (const sess of _sessions.values()) {
|
|
1846
2117
|
try { sess.teardown(); }
|
|
1847
2118
|
catch (err) { reportSwallowed('sdk-manager.stop', err); }
|
package/server/pty-manager.js
CHANGED
|
@@ -501,6 +501,10 @@ async function _spawnClaudeImpl(key, proxyPort, cwd, extraArgs = [], claudePath
|
|
|
501
501
|
`Edit(${home}/.claude/settings.json)`, `Write(${home}/.claude/settings.json)`,
|
|
502
502
|
`Edit(${home}/.claude/settings.local.json)`, `Write(${home}/.claude/settings.local.json)`,
|
|
503
503
|
`Edit(${home}/.claude/cc-viewer/preferences.json)`, `Write(${home}/.claude/cc-viewer/preferences.json)`,
|
|
504
|
+
// preferences.json 现承载定时任务定义(含 prompt);运行历史/旧版任务文件同理拒读拒写。
|
|
505
|
+
`Read(${home}/.claude/cc-viewer/preferences.json)`,
|
|
506
|
+
`Read(${home}/.claude/cc-viewer/scheduled-task-runs.json)`, `Edit(${home}/.claude/cc-viewer/scheduled-task-runs.json)`, `Write(${home}/.claude/cc-viewer/scheduled-task-runs.json)`,
|
|
507
|
+
`Read(${home}/.claude/cc-viewer/scheduled-tasks.json)`, `Edit(${home}/.claude/cc-viewer/scheduled-tasks.json)`, `Write(${home}/.claude/cc-viewer/scheduled-tasks.json)`,
|
|
504
508
|
`Read(${home}/.claude/cc-viewer/credentials.json)`, `Edit(${home}/.claude/cc-viewer/credentials.json)`, `Write(${home}/.claude/cc-viewer/credentials.json)`,
|
|
505
509
|
`Read(${home}/.claude/cc-viewer/master.key)`, `Edit(${home}/.claude/cc-viewer/master.key)`, `Write(${home}/.claude/cc-viewer/master.key)`,
|
|
506
510
|
// Backup copies of the vault (cc-viewer-config-backups/<ts>/) hold the same decryption
|
|
@@ -1323,9 +1327,16 @@ export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
|
|
|
1323
1327
|
return false;
|
|
1324
1328
|
}
|
|
1325
1329
|
|
|
1326
|
-
/** writeToPtySequential variant routed by an explicit anchor.
|
|
1330
|
+
/** writeToPtySequential variant routed by an explicit anchor. When `opts.strict` is set
|
|
1331
|
+
* and the anchor names an explicit instanceKey that no longer resolves to a LIVE record,
|
|
1332
|
+
* the write fails (onComplete(false)) WITHOUT falling back to the project/active record —
|
|
1333
|
+
* scheduled-task injection must never land in the wrong conversation (review P0-2). */
|
|
1327
1334
|
export function writeToPtySequentialFor(chunks, onComplete, opts = {}, anchor = {}) {
|
|
1328
|
-
|
|
1335
|
+
let key = _resolveKeyByAnchor(anchor);
|
|
1336
|
+
if (opts && opts.strict && typeof anchor.instanceKey === 'string' && anchor.instanceKey) {
|
|
1337
|
+
const rec = ptys.get(anchor.instanceKey);
|
|
1338
|
+
if (!rec || !rec.ptyProcess) key = null; // strict: a dead explicit instance never falls back
|
|
1339
|
+
}
|
|
1329
1340
|
const s = key ? ptys.get(key) : null;
|
|
1330
1341
|
if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
|
|
1331
1342
|
if (onComplete) onComplete(false);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Preferences, Claude settings, and proxy-profile routes (moved verbatim from server.js).
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, discoverClaudeExecutables, resolveExplicitClaudePath, CLAUDE_EXECUTABLE_PREF_KEY } from '../../findcc.js';
|
|
@@ -14,6 +14,7 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
|
14
14
|
import { inspectShellHook } from '../lib/shell-hook-inspect.js';
|
|
15
15
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
16
16
|
import { listPlatforms } from '../lib/im/im-config.js';
|
|
17
|
+
import { runsFilePath } from '../lib/scheduled-task-runs-store.js';
|
|
17
18
|
import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
|
|
18
19
|
import { mutateJsonSync } from '../lib/json-store.js';
|
|
19
20
|
import { persistProfilesApiKeys } from '../lib/credential-access.js';
|
|
@@ -50,6 +51,9 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
50
51
|
// 全局 auth 与每个项目的 authByProject 覆盖都要剥离(后者同样含明文密码)。
|
|
51
52
|
delete prefs.auth;
|
|
52
53
|
delete prefs.authByProject;
|
|
54
|
+
// 定时任务(含 prompt/调度状态)属敏感数据:只能经 /api/scheduled-tasks 读写(admin+same-origin),
|
|
55
|
+
// 绝不随 prefs 下发/回写 —— 否则任何已授权客户端都能拿到 prompt 或借 applyPrefsPatch 注入任务。
|
|
56
|
+
delete prefs.scheduledTasks;
|
|
53
57
|
if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
54
58
|
delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
55
59
|
}
|
|
@@ -70,6 +74,7 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
70
74
|
// 客户端的读路径,与 admin 列表口保持对称——手改 preferences.json 注入的脏 fork 也不会泄露。
|
|
71
75
|
delete prefs.auth;
|
|
72
76
|
delete prefs.authByProject;
|
|
77
|
+
delete prefs.scheduledTasks; // fork 防御性二次剥离:脏 fork 也不得把任务带给远程客户端
|
|
73
78
|
stripImConfigs(prefs);
|
|
74
79
|
}
|
|
75
80
|
prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
|
|
@@ -143,6 +148,9 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
143
148
|
// prefsByProject(项目独立配置 fork)只能经 admin-only 的 /api/project-prefs/* 修改;元字段
|
|
144
149
|
// (_isLocal/_projectScoped/…)仅 GET 回包,绝不让它们经 /api/preferences 落盘污染。
|
|
145
150
|
delete incoming.prefsByProject;
|
|
151
|
+
// 定时任务只能经 /api/scheduled-tasks (admin+same-origin) 写 —— 任何客户端(含 admin)
|
|
152
|
+
// 都不得借 prefs POST 注入/清空/覆盖任务(claim CAS、调度状态校验只在 store 层做)。
|
|
153
|
+
delete incoming.scheduledTasks;
|
|
146
154
|
for (const k of Object.keys(incoming)) if (k[0] === '_') delete incoming[k];
|
|
147
155
|
// IM bridge configs 同理:只能经 admin-only 的 /api/dingtalk/config、/api/im/* 修改,禁止借
|
|
148
156
|
// /api/preferences 植入凭据。
|
|
@@ -154,11 +162,13 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
154
162
|
// 因此只允许 admin(本机 loopback 或已鉴权远程 admin)触发——未鉴权远程/项目客户端直接剥掉。
|
|
155
163
|
let targetFile = deps.getPrefsFile();
|
|
156
164
|
let carried = null;
|
|
165
|
+
let oldLogDir = null;
|
|
157
166
|
if (incoming.logDir && typeof incoming.logDir === 'string') {
|
|
158
167
|
if (!isAdminReq(req, isLocal)) {
|
|
159
168
|
delete incoming.logDir; // non-admin cannot move the data root (would open the LAN gate)
|
|
160
169
|
} else {
|
|
161
170
|
carried = readPrefsRaw(targetFile); // 旧文件全量(锁外读,迁移属罕见 admin 操作)
|
|
171
|
+
oldLogDir = LOG_DIR;
|
|
162
172
|
setLogDir(incoming.logDir);
|
|
163
173
|
targetFile = deps.getPrefsFile();
|
|
164
174
|
}
|
|
@@ -215,10 +225,23 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
215
225
|
delete prefs.auth;
|
|
216
226
|
delete prefs.authByProject;
|
|
217
227
|
delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
|
|
228
|
+
delete prefs.scheduledTasks; // 任务(含 prompt)同样不回显——读写都走 /api/scheduled-tasks
|
|
218
229
|
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
219
230
|
delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
|
|
220
231
|
}
|
|
221
232
|
stripImConfigs(prefs);
|
|
233
|
+
// logDir 切换时把旧根的定时任务运行历史一并搬到新根(任务定义已随 carried 进新 prefs;
|
|
234
|
+
// 历史不带过去会让既有任务的运行记录凭空消失)。best-effort:失败仅留痕,不影响主流程。
|
|
235
|
+
if (oldLogDir && oldLogDir !== LOG_DIR) {
|
|
236
|
+
try {
|
|
237
|
+
const oldRuns = runsFilePath(oldLogDir);
|
|
238
|
+
const newRuns = runsFilePath(LOG_DIR);
|
|
239
|
+
if (existsSync(oldRuns) && !existsSync(newRuns)) {
|
|
240
|
+
copyFileSync(oldRuns, newRuns);
|
|
241
|
+
try { unlinkSync(oldRuns); } catch { /* best-effort */ }
|
|
242
|
+
}
|
|
243
|
+
} catch (err) { reportSwallowed('preferences.logdir-runs-move', err); }
|
|
244
|
+
}
|
|
222
245
|
prefs.logDir = LOG_DIR;
|
|
223
246
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
224
247
|
res.end(JSON.stringify(prefs));
|