@jjh32456/dsh-longlive 1.6.8 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -1539
- package/LICENSE +1 -1
- package/QUICKSTART.md +33 -12
- package/README.en.md +17 -7
- package/README.md +29 -10
- package/SPEC.md +5 -5
- package/cordis.patch.yml +17 -12
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +143 -37
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +66 -29
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +3 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +42 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +129 -18
- package/src/activity/model.js +11 -1
- package/src/activity/store.js +22 -3
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +145 -48
- package/src/deps.js +6 -4
- package/src/features.js +158 -8
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +1 -1
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/queue.mjs +1 -1
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/activity-realtime.test.mjs +0 -959
- package/test/activity-stream.test.mjs +0 -512
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -399
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -210
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -585
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/activity-append-child.mjs +0 -29
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1023
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -184
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1076
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -315
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -181
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/state-truth.test.mjs +0 -937
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
- package/test/version-consistency.test.mjs +0 -286
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// src/ui/panel-api.js — M2-F/M2-G 装配层(宿主接线 + HTTP 分派)
|
|
2
|
+
//
|
|
3
|
+
// 把权限门(panel-auth)、完整性(panel-integrity)、审计链(actions/audit)、
|
|
4
|
+
// evidence 写路径(actions/evidence)、动作面(ui/actions)与 16 条只读路由
|
|
5
|
+
// (ui/routes-readonly)装配成一个可测试的门面:
|
|
6
|
+
// - createPanelApi(options) 纯内存/文件装配,契约测试直接驱动;
|
|
7
|
+
// - registerPanelApiRoutes(ctx, options) 通过 ctx.inject(['webServer']) 注册
|
|
8
|
+
// 16 条只读路由 + 5 条动作路由(POST preview/apply/undo、GET actions/audit)
|
|
9
|
+
// + G3 distill 详情前缀路由。宿主没有 Web server 时注册失败只 warn,不阻塞加载。
|
|
10
|
+
//
|
|
11
|
+
// 开关:由 src/index.js 读 `memoryV2.enabled`(默认 false)决定是否调用本模块;
|
|
12
|
+
// 未开启时本模块不注册任何路由、不创建任何文件。
|
|
13
|
+
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { AuditError } from '../actions/audit.js';
|
|
18
|
+
import { createAuditLog } from '../actions/audit.js';
|
|
19
|
+
import { EvidenceError } from '../actions/evidence.js';
|
|
20
|
+
import { createEvidenceWriter } from '../actions/evidence.js';
|
|
21
|
+
import { createEventsIndexReader } from '../memory/event-index.js';
|
|
22
|
+
import { createActionService, PanelActionError } from './actions.js';
|
|
23
|
+
import { createPanelAuth } from './panel-auth.js';
|
|
24
|
+
import { createIntegrityGuard } from './panel-integrity.js';
|
|
25
|
+
import {
|
|
26
|
+
DISTILL_DETAIL_PREFIX,
|
|
27
|
+
READONLY_ROUTES,
|
|
28
|
+
createReadonlyRouter,
|
|
29
|
+
registerReadonlyRoutes,
|
|
30
|
+
} from './routes-readonly.js';
|
|
31
|
+
|
|
32
|
+
export const ACTIONS_BODY_LIMIT = 64 * 1024;
|
|
33
|
+
export const ACTION_ROUTE_PATHS = Object.freeze([
|
|
34
|
+
'/api/longlive/actions/preview',
|
|
35
|
+
'/api/longlive/actions/apply',
|
|
36
|
+
'/api/longlive/actions/undo',
|
|
37
|
+
'/api/longlive/actions',
|
|
38
|
+
'/api/longlive/audit',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Host DSH home (never a machine path literal in this published file). */
|
|
42
|
+
export function dshHomeDir() {
|
|
43
|
+
const raw = process.env.DSH_HOME;
|
|
44
|
+
if (typeof raw === 'string' && raw.trim() !== '') return path.resolve(raw.trim());
|
|
45
|
+
return path.join(os.homedir(), '.dsh');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the installed panel package root.
|
|
50
|
+
*
|
|
51
|
+
* P1-1(2): the default lives **outside** the memory root (`<DSH_HOME>/longlive-panel`)
|
|
52
|
+
* so a model/agent that can write memoryRoot cannot also own the signed manifest
|
|
53
|
+
* directory. Host installers may override via option/config; Gate-2 wires the
|
|
54
|
+
* real installed path.
|
|
55
|
+
*/
|
|
56
|
+
function defaultPanelRoot(memoryRoot, options) {
|
|
57
|
+
if (typeof options.panelRoot === 'string' && options.panelRoot.trim() !== '') return path.resolve(options.panelRoot);
|
|
58
|
+
if (typeof options.config?.memoryV2?.panelRoot === 'string' && options.config.memoryV2.panelRoot.trim() !== '') {
|
|
59
|
+
return path.resolve(options.config.memoryV2.panelRoot);
|
|
60
|
+
}
|
|
61
|
+
const candidate = path.join(dshHomeDir(), 'longlive-panel');
|
|
62
|
+
const resolvedMemory = memoryRoot !== '' ? path.resolve(memoryRoot) : null;
|
|
63
|
+
if (resolvedMemory !== null
|
|
64
|
+
&& (candidate === resolvedMemory || candidate.startsWith(`${resolvedMemory}${path.sep}`))) {
|
|
65
|
+
// Never fall back inside memoryRoot; use the OS-local app data dir instead.
|
|
66
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
67
|
+
if (typeof localAppData === 'string' && localAppData.trim() !== '') {
|
|
68
|
+
return path.join(localAppData.trim(), 'dsh-longlive', 'panel');
|
|
69
|
+
}
|
|
70
|
+
return path.join(os.tmpdir(), 'dsh-longlive-panel');
|
|
71
|
+
}
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the whole panel API (no host wiring).
|
|
77
|
+
* @param {object} options
|
|
78
|
+
* @param {string} options.memoryRoot
|
|
79
|
+
* @param {string} [options.panelRoot] installed panel package root (integrity manifest base)
|
|
80
|
+
* @param {() => Date} [options.now]
|
|
81
|
+
* @param {(size:number)=>Buffer} [options.randomBytes] CSPRNG seam
|
|
82
|
+
* @param {boolean} [options.panelPresent] false simulates uninstalled panel
|
|
83
|
+
* @param {() => object} [options.flagsSnapshot]
|
|
84
|
+
* @param {(query:object)=>object} [options.eventsReader]
|
|
85
|
+
* @param {(anchorId:string)=>object|null} [options.anchorResolver]
|
|
86
|
+
*/
|
|
87
|
+
export function createPanelApi(options = {}) {
|
|
88
|
+
const memoryRoot = String(options.memoryRoot ?? '');
|
|
89
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
90
|
+
const monoNow = typeof options.monoNow === 'function' ? options.monoNow : undefined;
|
|
91
|
+
const auth = createPanelAuth({
|
|
92
|
+
now,
|
|
93
|
+
// P0-1: host-injected capability secret (profile-private env/file); absent => fail closed.
|
|
94
|
+
hostSecret: options.hostSecret,
|
|
95
|
+
monoNow,
|
|
96
|
+
randomBytes: options.randomBytes,
|
|
97
|
+
panelPresent: options.panelPresent,
|
|
98
|
+
panelIntegrityKey: options.panelIntegrityKey,
|
|
99
|
+
});
|
|
100
|
+
const panelRoot = defaultPanelRoot(memoryRoot, options);
|
|
101
|
+
const integrity = createIntegrityGuard({
|
|
102
|
+
panelRoot,
|
|
103
|
+
now,
|
|
104
|
+
signKey: auth.panelIntegrityKeyBytes(),
|
|
105
|
+
});
|
|
106
|
+
const auditKey = auth.auditKeyBytes();
|
|
107
|
+
const audit = createAuditLog({ memoryRoot, auditKey, now });
|
|
108
|
+
const evidence = createEvidenceWriter({ memoryRoot, auditKey, now });
|
|
109
|
+
const actions = createActionService({
|
|
110
|
+
memoryRoot,
|
|
111
|
+
auth,
|
|
112
|
+
auditKey,
|
|
113
|
+
integrity,
|
|
114
|
+
audit,
|
|
115
|
+
evidence,
|
|
116
|
+
now,
|
|
117
|
+
monoNow,
|
|
118
|
+
clockToleranceMs: options.clockToleranceMs,
|
|
119
|
+
});
|
|
120
|
+
const readonly = createReadonlyRouter({
|
|
121
|
+
memoryRoot,
|
|
122
|
+
auth,
|
|
123
|
+
auditKey,
|
|
124
|
+
integrity,
|
|
125
|
+
now,
|
|
126
|
+
flagsSnapshot: options.flagsSnapshot,
|
|
127
|
+
// M2-A: #11 events route reads the FTS5 event index when it has been built
|
|
128
|
+
// (`.longlive/index/events.db`); without an index the honest empty state
|
|
129
|
+
// (`present:false` + E_EVENTS_INDEX_NOT_BUILT) is returned unchanged.
|
|
130
|
+
eventsReader: options.eventsReader ?? createEventsIndexReader({ memoryRoot, now }),
|
|
131
|
+
anchorResolver: options.anchorResolver,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function handleReadonly(request = {}) {
|
|
135
|
+
return readonly.handle(request);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function actionResponse(status, body) {
|
|
139
|
+
return { status, body, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function actionErrorResponse(error) {
|
|
143
|
+
if (error instanceof PanelActionError) {
|
|
144
|
+
return actionResponse(error.status, {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: { code: error.code, message: error.message, ...(error.details !== undefined ? { details: error.details } : {}) },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (error instanceof AuditError) {
|
|
150
|
+
return actionResponse(500, { ok: false, error: { code: error.code, message: error.message, ...(error.details !== undefined ? { details: error.details } : {}) } });
|
|
151
|
+
}
|
|
152
|
+
if (error instanceof EvidenceError) {
|
|
153
|
+
return actionResponse(error.status ?? 403, { ok: false, error: { code: error.code, message: error.message } });
|
|
154
|
+
}
|
|
155
|
+
return actionResponse(500, { ok: false, error: { code: 'E_ACTION_FAILED', message: error?.message ?? String(error) } });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle one in-process action request.
|
|
160
|
+
* @param {{method?:string, path:string, headers?:object, body?:object, source?:string, actorKind?:string, rawBodySize?:number}} request
|
|
161
|
+
*/
|
|
162
|
+
function handleAction(request = {}) {
|
|
163
|
+
const method = String(request.method ?? 'POST').toUpperCase();
|
|
164
|
+
let url;
|
|
165
|
+
try {
|
|
166
|
+
url = new URL(String(request.path ?? ''), 'http://longlive.local');
|
|
167
|
+
} catch {
|
|
168
|
+
return actionResponse(400, { ok: false, error: { code: 'E_PATH', message: '非法请求路径。' } });
|
|
169
|
+
}
|
|
170
|
+
const query = {};
|
|
171
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
172
|
+
query[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : value;
|
|
173
|
+
}
|
|
174
|
+
const gateRequest = { source: request.source ?? 'http', headers: request.headers ?? {}, actorKind: request.actorKind ?? 'host' };
|
|
175
|
+
const body = request.body !== null && typeof request.body === 'object' && !Array.isArray(request.body) ? request.body : {};
|
|
176
|
+
try {
|
|
177
|
+
if (url.pathname === '/api/longlive/actions/preview') {
|
|
178
|
+
if (method !== 'POST') return actionResponse(405, { ok: false, error: { code: 'E_METHOD', message: 'preview 只支持 POST。' } });
|
|
179
|
+
const size = Number.isInteger(request.rawBodySize) ? request.rawBodySize : Buffer.byteLength(JSON.stringify(body));
|
|
180
|
+
if (size > ACTIONS_BODY_LIMIT) return actionResponse(413, { ok: false, error: { code: 'E_BODY_TOO_LARGE', message: `请求体超过 ${ACTIONS_BODY_LIMIT} 字节。` } });
|
|
181
|
+
return actionResponse(200, actions.preview(gateRequest, body));
|
|
182
|
+
}
|
|
183
|
+
if (url.pathname === '/api/longlive/actions/apply') {
|
|
184
|
+
if (method !== 'POST') return actionResponse(405, { ok: false, error: { code: 'E_METHOD', message: 'apply 只支持 POST。' } });
|
|
185
|
+
const size = Number.isInteger(request.rawBodySize) ? request.rawBodySize : Buffer.byteLength(JSON.stringify(body));
|
|
186
|
+
if (size > ACTIONS_BODY_LIMIT) return actionResponse(413, { ok: false, error: { code: 'E_BODY_TOO_LARGE', message: `请求体超过 ${ACTIONS_BODY_LIMIT} 字节。` } });
|
|
187
|
+
return actionResponse(200, actions.apply(gateRequest, body));
|
|
188
|
+
}
|
|
189
|
+
if (url.pathname === '/api/longlive/actions/undo') {
|
|
190
|
+
if (method !== 'POST') return actionResponse(405, { ok: false, error: { code: 'E_METHOD', message: 'undo 只支持 POST。' } });
|
|
191
|
+
const size = Number.isInteger(request.rawBodySize) ? request.rawBodySize : Buffer.byteLength(JSON.stringify(body));
|
|
192
|
+
if (size > ACTIONS_BODY_LIMIT) return actionResponse(413, { ok: false, error: { code: 'E_BODY_TOO_LARGE', message: `请求体超过 ${ACTIONS_BODY_LIMIT} 字节。` } });
|
|
193
|
+
return actionResponse(200, actions.undo(gateRequest, body));
|
|
194
|
+
}
|
|
195
|
+
if (url.pathname === '/api/longlive/actions') {
|
|
196
|
+
if (method !== 'GET') return actionResponse(405, { ok: false, error: { code: 'E_METHOD', message: 'actions 列表只支持 GET。' } });
|
|
197
|
+
return actionResponse(200, actions.listActions(gateRequest, query));
|
|
198
|
+
}
|
|
199
|
+
if (url.pathname === '/api/longlive/audit') {
|
|
200
|
+
if (method !== 'GET') return actionResponse(405, { ok: false, error: { code: 'E_METHOD', message: 'audit 只支持 GET。' } });
|
|
201
|
+
return actionResponse(200, actions.readAudit(gateRequest, query));
|
|
202
|
+
}
|
|
203
|
+
return actionResponse(404, { ok: false, error: { code: 'E_ROUTE_UNKNOWN', message: `未知动作路由:${url.pathname}` } });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return actionErrorResponse(error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
memoryRoot,
|
|
211
|
+
panelRoot,
|
|
212
|
+
auth,
|
|
213
|
+
integrity,
|
|
214
|
+
audit,
|
|
215
|
+
evidence,
|
|
216
|
+
actions,
|
|
217
|
+
readonly,
|
|
218
|
+
handleReadonly,
|
|
219
|
+
handleAction,
|
|
220
|
+
/** Host-internal channel: mint a one-shot capability token for the panel. */
|
|
221
|
+
issueCapability: (opts = {}) => auth.issue(opts),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const warned = new Set();
|
|
226
|
+
function warnOnce(key, error) {
|
|
227
|
+
if (warned.has(key)) return;
|
|
228
|
+
warned.add(key);
|
|
229
|
+
try {
|
|
230
|
+
console.warn(`[longlive] ${key}: ${error?.stack ?? error?.message ?? String(error)}`);
|
|
231
|
+
} catch {
|
|
232
|
+
/* logging must never break activation */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function makeResponder(handler) {
|
|
237
|
+
return (req, res) => {
|
|
238
|
+
let body = {};
|
|
239
|
+
(async () => {
|
|
240
|
+
if (req?.method === 'POST') {
|
|
241
|
+
const chunks = [];
|
|
242
|
+
let size = 0;
|
|
243
|
+
let tooLarge = false;
|
|
244
|
+
await new Promise((resolve) => {
|
|
245
|
+
req.on('data', (chunk) => {
|
|
246
|
+
if (tooLarge) return;
|
|
247
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
248
|
+
size += buffer.length;
|
|
249
|
+
if (size > ACTIONS_BODY_LIMIT) {
|
|
250
|
+
tooLarge = true;
|
|
251
|
+
resolve();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
chunks.push(buffer);
|
|
255
|
+
});
|
|
256
|
+
req.on('end', resolve);
|
|
257
|
+
req.on('error', resolve);
|
|
258
|
+
});
|
|
259
|
+
if (tooLarge) {
|
|
260
|
+
const payload = JSON.stringify({ ok: false, error: { code: 'E_BODY_TOO_LARGE', message: `请求体超过 ${ACTIONS_BODY_LIMIT} 字节。` } });
|
|
261
|
+
try {
|
|
262
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) });
|
|
263
|
+
res.end(payload);
|
|
264
|
+
} catch {
|
|
265
|
+
/* peer gone */
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
body = chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
271
|
+
} catch {
|
|
272
|
+
body = {};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const host = String(req?.headers?.host ?? 'longlive.local');
|
|
276
|
+
const verdict = handler({
|
|
277
|
+
method: req?.method ?? 'GET',
|
|
278
|
+
path: `http://${host}${String(req?.url ?? '/')}`,
|
|
279
|
+
headers: req?.headers ?? {},
|
|
280
|
+
body,
|
|
281
|
+
source: 'http',
|
|
282
|
+
});
|
|
283
|
+
const payload = JSON.stringify(verdict.body ?? {});
|
|
284
|
+
try {
|
|
285
|
+
res.writeHead(verdict.status, {
|
|
286
|
+
'content-type': 'application/json; charset=utf-8',
|
|
287
|
+
'cache-control': 'no-store',
|
|
288
|
+
...(verdict.headers ?? {}),
|
|
289
|
+
'content-length': Buffer.byteLength(payload),
|
|
290
|
+
});
|
|
291
|
+
res.end(payload);
|
|
292
|
+
} catch {
|
|
293
|
+
try {
|
|
294
|
+
res.destroy?.();
|
|
295
|
+
} catch {
|
|
296
|
+
/* peer gone */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
})().catch((error) => warnOnce('panel api responder failed', error));
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Register the panel API routes on the host Web server (memoryV2.enabled only).
|
|
305
|
+
* @returns {Promise<{api:object|null, registered:number}>} resolved after inject callback
|
|
306
|
+
* is set up (the actual registration happens when the web server is available).
|
|
307
|
+
*/
|
|
308
|
+
export function registerPanelApiRoutes(ctx, options = {}) {
|
|
309
|
+
if (typeof ctx?.inject !== 'function') return false;
|
|
310
|
+
try {
|
|
311
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
312
|
+
const webServer = webCtx?.webServer;
|
|
313
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return;
|
|
314
|
+
let api;
|
|
315
|
+
try {
|
|
316
|
+
api = createPanelApi(options);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
warnOnce('panel api create failed', error);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const readonlyResponder = makeResponder(api.handleReadonly);
|
|
322
|
+
const actionResponder = makeResponder(api.handleAction);
|
|
323
|
+
let registered = 0;
|
|
324
|
+
const register = (registration, label) => {
|
|
325
|
+
try {
|
|
326
|
+
const dispose = webServer.register(registration);
|
|
327
|
+
registered += 1;
|
|
328
|
+
if (typeof dispose === 'function' && typeof webCtx.effect === 'function') {
|
|
329
|
+
try {
|
|
330
|
+
webCtx.effect(() => () => dispose(), `longlive: panel api ${label}`);
|
|
331
|
+
} catch {
|
|
332
|
+
/* fake context without an effect scope */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
warnOnce(`panel api route ${label} registration failed`, error);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
for (const route of READONLY_ROUTES) {
|
|
340
|
+
register(
|
|
341
|
+
route.dynamic
|
|
342
|
+
? { kind: 'prefix', path: route.path.replace(/\/:anchorId$/, '/'), handler: readonlyResponder }
|
|
343
|
+
: { kind: 'exact', path: route.path, handler: readonlyResponder },
|
|
344
|
+
`readonly ${route.id}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
register({ kind: 'prefix', path: DISTILL_DETAIL_PREFIX, handler: readonlyResponder }, 'distill detail');
|
|
348
|
+
for (const routePath of ACTION_ROUTE_PATHS) {
|
|
349
|
+
register({ kind: 'exact', path: routePath, handler: actionResponder }, `action ${routePath}`);
|
|
350
|
+
}
|
|
351
|
+
if (typeof webCtx.effect === 'function') {
|
|
352
|
+
try {
|
|
353
|
+
webCtx.effect(() => () => { api = null; }, 'longlive: panel api scope');
|
|
354
|
+
} catch {
|
|
355
|
+
/* fake context */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
warnOnce('panel api registration summary', { registered });
|
|
359
|
+
});
|
|
360
|
+
return true;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
warnOnce('panel api inject failed', error);
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export { registerReadonlyRoutes };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/ui/panel-auth.js — M2-G 权限门(N38 / §12.2)
|
|
2
|
+
//
|
|
3
|
+
// 两个凭据**只存在于宿主进程内存**:capability secret 必须由宿主进程注入
|
|
4
|
+
// (profile 私有文件 / 宿主环境变量 DSH_LONGLIVE_PANEL_SECRET),审计 key boot 时 32B CSPRNG 生成:
|
|
5
|
+
// - panelCap: 签发面板能力令牌的 HMAC key;
|
|
6
|
+
// - auditKey: HMAC 链式审计 / evidence 签名 key。
|
|
7
|
+
// 磁盘上没有任何凭据文件,也没有任何 HTTP 路由能把它们读出来:
|
|
8
|
+
// 面板经宿主内部通道拿到的是**短 TTL 一次性 nonce 能力令牌**
|
|
9
|
+
// capability = HMAC(panelCap, `${nonce}|${pid}|${windowNonce}|${sessionNonce}`)
|
|
10
|
+
// 请求头 `x-longlive-cap` + `x-longlive-nonce`;服务端内存 nonce 注册表单次消费,TTL 默认 60s。
|
|
11
|
+
//
|
|
12
|
+
// 权限三门(HTTP / 工具 / CLI)走同一个 {@link actionGate}:
|
|
13
|
+
// - subagent 一律 403 `E_SUBAGENT_FORBIDDEN`(先于 token 校验);
|
|
14
|
+
// - 面板未装 → 401 `E_PANEL_ABSENT`;
|
|
15
|
+
// - 无令牌 → 401 `E_NO_CAP`;错令牌 → 401 `E_CAP_INVALID`;nonce 已用/过期 → 401 `E_CAP_CONSUMED/E_CAP_EXPIRED`。
|
|
16
|
+
// actor 由令牌绑定为 `host`,请求体里的 actor 字段一律忽略(绝不信任请求体)。
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const CAP_TTL_MS = 60_000;
|
|
21
|
+
export const CAP_HEADER = 'x-longlive-cap';
|
|
22
|
+
export const NONCE_HEADER = 'x-longlive-nonce';
|
|
23
|
+
export const ACTOR = 'host';
|
|
24
|
+
|
|
25
|
+
/** Errors carry `{code,status}` so the HTTP layer and the contract tests agree. */
|
|
26
|
+
export class PanelAuthError extends Error {
|
|
27
|
+
constructor(code, message, status = 401, details = undefined) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'PanelAuthError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.status = status;
|
|
32
|
+
if (details !== undefined) this.details = details;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hmacHex(key, text) {
|
|
37
|
+
return crypto.createHmac('sha256', key).update(String(text), 'utf8').digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeHeaderValue(value) {
|
|
41
|
+
if (Array.isArray(value)) return value.length > 0 ? String(value[0]) : '';
|
|
42
|
+
return value === undefined || value === null ? '' : String(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headerOf(headers, name) {
|
|
46
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
47
|
+
const lower = name.toLowerCase();
|
|
48
|
+
if (headers[lower] !== undefined) return normalizeHeaderValue(headers[lower]);
|
|
49
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
50
|
+
if (key.toLowerCase() === lower) return normalizeHeaderValue(value);
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create the in-memory credential holder + permission gate.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} [options]
|
|
59
|
+
* @param {() => Date} [options.now] clock seam for tests.
|
|
60
|
+
* @param {(size:number)=>Buffer} [options.randomBytes] CSPRNG seam for tests.
|
|
61
|
+
* @param {boolean} [options.panelPresent] false simulates "panel uninstalled".
|
|
62
|
+
* @returns auth facade (credentials intentionally not enumerable).
|
|
63
|
+
*/
|
|
64
|
+
export function createPanelAuth(options = {}) {
|
|
65
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
66
|
+
const randomBytes = typeof options.randomBytes === 'function' ? options.randomBytes : crypto.randomBytes;
|
|
67
|
+
// Monotonic clock: capability TTL must not stretch when the wall clock rolls
|
|
68
|
+
// back (N38: TTL ≤60s is enforced against elapsed process time).
|
|
69
|
+
const monoNow = typeof options.monoNow === 'function' ? options.monoNow : () => process.uptime() * 1000;
|
|
70
|
+
const panelPresent = options.panelPresent !== false;
|
|
71
|
+
// P0-1: capability secret is HOST-INJECTED. A module that merely imports this
|
|
72
|
+
// file cannot mint a usable capability: without the host secret, issue()/gate
|
|
73
|
+
// fail closed with E_PANEL_HOST_ONLY. The secret is never written to disk.
|
|
74
|
+
function decodeSecret(value) {
|
|
75
|
+
if (Buffer.isBuffer(value) && value.length >= 32) return Buffer.from(value);
|
|
76
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
77
|
+
const raw = value.trim();
|
|
78
|
+
const hex = /^[0-9a-f]{64,}$/i.test(raw) ? Buffer.from(raw, 'hex') : null;
|
|
79
|
+
if (hex && hex.length >= 32) return hex;
|
|
80
|
+
try {
|
|
81
|
+
const b64 = Buffer.from(raw, 'base64');
|
|
82
|
+
if (b64.length >= 32) return b64;
|
|
83
|
+
} catch {
|
|
84
|
+
/* fallthrough */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const hostSecret = decodeSecret(options.hostSecret) ?? decodeSecret(process.env.DSH_LONGLIVE_PANEL_SECRET);
|
|
90
|
+
const hostCapable = hostSecret !== null;
|
|
91
|
+
const capabilitySecret = hostSecret ?? randomBytes(32); // host memory only
|
|
92
|
+
const auditKey = randomBytes(32); // host memory only
|
|
93
|
+
// R8/P1-1: independent in-memory key that anchors the panel integrity manifest.
|
|
94
|
+
// Tests may inject a fixed Buffer through options.panelIntegrityKey (host-side
|
|
95
|
+
// seam only); production always generates a fresh boot-scoped key.
|
|
96
|
+
const panelIntegrityKey = Buffer.isBuffer(options.panelIntegrityKey) && options.panelIntegrityKey.length >= 16
|
|
97
|
+
? Buffer.from(options.panelIntegrityKey)
|
|
98
|
+
: randomBytes(32);
|
|
99
|
+
/** nonce → { pid, windowNonce, sessionNonce, expiresAt, consumed } */
|
|
100
|
+
const nonces = new Map();
|
|
101
|
+
const stats = { issued: 0, verified: 0, rejected: 0, consumedReplays: 0 };
|
|
102
|
+
|
|
103
|
+
function currentMs() {
|
|
104
|
+
return now().getTime();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function prune(nowMono) {
|
|
108
|
+
for (const [nonce, entry] of nonces) {
|
|
109
|
+
if (entry.consumed || nowMono - entry.expiresMono > CAP_TTL_MS) nonces.delete(nonce);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Host-internal channel mints a one-shot capability token for the panel.
|
|
115
|
+
* Never exposed over HTTP; never written to disk.
|
|
116
|
+
*/
|
|
117
|
+
function issue(opts = {}) {
|
|
118
|
+
if (!hostCapable) {
|
|
119
|
+
stats.rejected += 1;
|
|
120
|
+
throw new PanelAuthError('E_PANEL_HOST_ONLY', '能力签发只允许宿主进程(host-injected secret 缺失):外部 import 不得发 cap。', 403);
|
|
121
|
+
}
|
|
122
|
+
const pid = Number.isInteger(opts.pid) ? opts.pid : process.pid;
|
|
123
|
+
const windowNonce = typeof opts.windowNonce === 'string' && opts.windowNonce !== '' ? opts.windowNonce : 'boot';
|
|
124
|
+
const sessionNonce = typeof opts.sessionNonce === 'string' ? opts.sessionNonce : '';
|
|
125
|
+
const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs > 0 ? Math.min(opts.ttlMs, CAP_TTL_MS) : CAP_TTL_MS;
|
|
126
|
+
const nonce = randomBytes(16).toString('hex');
|
|
127
|
+
const mac = hmacHex(capabilitySecret, `${nonce}|${pid}|${windowNonce}|${sessionNonce}`);
|
|
128
|
+
const token = `${nonce}.${mac}`;
|
|
129
|
+
const nowMs = currentMs();
|
|
130
|
+
const nowMono = monoNow();
|
|
131
|
+
prune(nowMono);
|
|
132
|
+
nonces.set(nonce, {
|
|
133
|
+
pid,
|
|
134
|
+
windowNonce,
|
|
135
|
+
sessionNonce,
|
|
136
|
+
expiresAt: nowMs + ttlMs,
|
|
137
|
+
expiresMono: nowMono + ttlMs,
|
|
138
|
+
consumed: false,
|
|
139
|
+
});
|
|
140
|
+
stats.issued += 1;
|
|
141
|
+
return { token, nonce, expiresAt: new Date(nowMs + ttlMs).toISOString(), actor: ACTOR };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function verifyToken(headers) {
|
|
145
|
+
if (!panelPresent) {
|
|
146
|
+
stats.rejected += 1;
|
|
147
|
+
throw new PanelAuthError('E_PANEL_ABSENT', '面板未安装:所有面板接口暂停(长活核心不受影响)。');
|
|
148
|
+
}
|
|
149
|
+
const token = headerOf(headers, CAP_HEADER);
|
|
150
|
+
const nonceHeader = headerOf(headers, NONCE_HEADER);
|
|
151
|
+
if (token === '') {
|
|
152
|
+
stats.rejected += 1;
|
|
153
|
+
throw new PanelAuthError('E_NO_CAP', '缺少能力令牌(x-longlive-cap):动作与只读接口一律拒绝。');
|
|
154
|
+
}
|
|
155
|
+
const dot = token.indexOf('.');
|
|
156
|
+
if (dot <= 0 || dot === token.length - 1) {
|
|
157
|
+
stats.rejected += 1;
|
|
158
|
+
throw new PanelAuthError('E_CAP_INVALID', '能力令牌格式非法。');
|
|
159
|
+
}
|
|
160
|
+
const tokenNonce = token.slice(0, dot);
|
|
161
|
+
const mac = token.slice(dot + 1);
|
|
162
|
+
if (nonceHeader !== '' && nonceHeader !== tokenNonce) {
|
|
163
|
+
stats.rejected += 1;
|
|
164
|
+
throw new PanelAuthError('E_CAP_INVALID', '能力令牌与 nonce 请求头不匹配。');
|
|
165
|
+
}
|
|
166
|
+
const entry = nonces.get(tokenNonce);
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
stats.rejected += 1;
|
|
169
|
+
throw new PanelAuthError('E_CAP_CONSUMED', 'nonce 不存在或已被消费(能力令牌一次性)。');
|
|
170
|
+
}
|
|
171
|
+
if (entry.consumed) {
|
|
172
|
+
stats.consumedReplays += 1;
|
|
173
|
+
stats.rejected += 1;
|
|
174
|
+
throw new PanelAuthError('E_CAP_CONSUMED', 'nonce 已被消费(重放被拒绝)。');
|
|
175
|
+
}
|
|
176
|
+
// TTL is enforced on the monotonic clock: a wall-clock rollback cannot
|
|
177
|
+
// extend a capability token's lifetime.
|
|
178
|
+
if (monoNow() > entry.expiresMono) {
|
|
179
|
+
stats.rejected += 1;
|
|
180
|
+
throw new PanelAuthError('E_CAP_EXPIRED', '能力令牌已过期(TTL ≤60s)。');
|
|
181
|
+
}
|
|
182
|
+
const expected = hmacHex(
|
|
183
|
+
capabilitySecret,
|
|
184
|
+
`${tokenNonce}|${entry.pid}|${entry.windowNonce}|${entry.sessionNonce}`,
|
|
185
|
+
);
|
|
186
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
187
|
+
const macBuf = Buffer.from(mac, 'utf8');
|
|
188
|
+
// EA-12: timingSafeEqual throws RangeError on unequal lengths -> compare lengths first.
|
|
189
|
+
if (expectedBuf.length !== macBuf.length || !crypto.timingSafeEqual(expectedBuf, macBuf)) {
|
|
190
|
+
stats.rejected += 1;
|
|
191
|
+
throw new PanelAuthError('E_CAP_INVALID', '能力令牌验签失败。');
|
|
192
|
+
}
|
|
193
|
+
entry.consumed = true;
|
|
194
|
+
stats.verified += 1;
|
|
195
|
+
return { ok: true, actor: ACTOR, actorId: ACTOR, pid: entry.pid, nonce: tokenNonce };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The single permission door for HTTP routes, tools and the CLI.
|
|
200
|
+
* `source` only changes the audit label — the checks are identical by design.
|
|
201
|
+
* The request body is never consulted for identity.
|
|
202
|
+
*/
|
|
203
|
+
function actionGate({ source = 'http', headers = {}, actorKind = 'host' } = {}) {
|
|
204
|
+
if (!hostCapable) {
|
|
205
|
+
stats.rejected += 1;
|
|
206
|
+
throw new PanelAuthError('E_PANEL_HOST_ONLY', '权限门只允许宿主进程(host-injected secret 缺失)。', 403);
|
|
207
|
+
}
|
|
208
|
+
if (source !== 'http' && source !== 'tool' && source !== 'cli') {
|
|
209
|
+
throw new PanelAuthError('E_SOURCE_INVALID', `未知入口:${source}`, 400);
|
|
210
|
+
}
|
|
211
|
+
if (actorKind === 'subagent') {
|
|
212
|
+
stats.rejected += 1;
|
|
213
|
+
throw new PanelAuthError('E_SUBAGENT_FORBIDDEN', 'subagent 不具有面板动作权限(一律 403)。', 403);
|
|
214
|
+
}
|
|
215
|
+
const verdict = verifyToken(headers);
|
|
216
|
+
// P1-2: actor is injected by the host (token-bound); request bodies are never
|
|
217
|
+
// consulted. A verdict with no actor is refused instead of defaulting silently.
|
|
218
|
+
if (typeof verdict?.actor !== 'string' || verdict.actor === '') {
|
|
219
|
+
stats.rejected += 1;
|
|
220
|
+
throw new PanelAuthError('E_ACTOR_REQUIRED', 'actor 必须由宿主注入(令牌绑定),缺省拒绝。', 401);
|
|
221
|
+
}
|
|
222
|
+
return { ...verdict, source };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Host-internal accessor for the audit/evidence signer. Not exposed to the panel. */
|
|
226
|
+
function auditKeyBytes() {
|
|
227
|
+
return auditKey;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Host-internal accessor for the R8 manifest HMAC anchor (boot-scoped). */
|
|
231
|
+
function panelIntegrityKeyBytes() {
|
|
232
|
+
return panelIntegrityKey;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hasCapabilitySecret() {
|
|
236
|
+
return hostCapable && Buffer.isBuffer(capabilitySecret) && capabilitySecret.length >= 32;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
panelPresent,
|
|
241
|
+
hostCapable,
|
|
242
|
+
issue,
|
|
243
|
+
verifyToken,
|
|
244
|
+
actionGate,
|
|
245
|
+
auditKeyBytes,
|
|
246
|
+
panelIntegrityKeyBytes,
|
|
247
|
+
hasCapabilitySecret,
|
|
248
|
+
stats,
|
|
249
|
+
CAP_TTL_MS,
|
|
250
|
+
};
|
|
251
|
+
}
|