@twinsearth/w2m-dsh-plugin 1.9.0 → 1.9.3
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.1/OPS-191.json +20 -0
- package/docs/acceptance/1.9.3/RELEASE-193.json +21 -0
- package/lib/client.js +122 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/sinks/dir-sink.mjs +33 -0
- package/src/ops/plugin-adapter.mjs +19 -2
- package/src/ops/upload-scheduler.mjs +9 -0
- package/src/plugin/folder-policy.mjs +10 -18
- package/src/plugin/gpw-policy-adapter.mjs +30 -0
- package/src/plugin/joint-dev/audit-file.mjs +83 -0
- package/src/plugin/joint-dev/dispatch.mjs +154 -0
- package/src/plugin/joint-dev/loop.mjs +73 -0
- package/src/plugin/joint-dev/plan-store.mjs +66 -0
- package/src/plugin/joint-dev/redline.mjs +16 -0
- package/src/plugin/npm-update.mjs +18 -12
- package/src/plugin/path-policy.mjs +214 -0
- package/src/plugin/target-policy.mjs +90 -0
- package/src/plugin/tools.mjs +8231 -8174
- package/src/relay/guarded-dispatch.mjs +43 -0
- package/src/relay/p0-projection.mjs +94 -0
- package/src/relay/redline-guard.mjs +72 -0
- package/src/relay/scrub.mjs +91 -0
- package/src/relay/server.mjs +21 -1
- package/src/relay/state.mjs +6 -1
- package/src/util/data-plane.mjs +180 -0
- package/src/util/redline-core.mjs +216 -0
- package/src/util/sealed-object.mjs +141 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.9.
|
|
8
|
+
"version": "1.9.3"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.9.
|
|
15
|
+
"version": "1.9.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.9.
|
|
29
|
+
"version": "1.9.3"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.9.3] - 2026-10-27
|
|
11
|
+
- 左侧边栏新增常驻 GPW 入口(默认显示、无关闭按钮),Composer 与右下角浮窗保留。
|
|
12
|
+
- 插件设置页新增“W2M 插件更新”分区:检测更新、发现新版本后立即安装、自动更新开关(开启后每次启动自动安装)。
|
|
13
|
+
- 更新检测修复:设置页打开即检查;本机为旧版时直接显示新版本号与安装按钮;npm 镜像不可用时回退 registry.npmjs.org。
|
|
14
|
+
- 自动更新不再只是偏好:开关打开后会实际调用校验与安装流程,并提示重启。
|
|
15
|
+
|
|
16
|
+
## [1.9.2-beta.1] - 2026-10-27
|
|
17
|
+
- P0 止血:relay 数据面端点退役(blob/blob-sessions/latest → 403),结果信封校验后投影后存储(不再落 argv/head/tail/路径原文)。
|
|
18
|
+
- B1 兼容窗口:默认接收 v1 信封并投影,设置 W2M_DATA_PLANE_GUARD=enforce 才硬拒。
|
|
19
|
+
- guard 递归扫描 + 16 KiB 语义上限 + 协议错误形状。
|
|
20
|
+
- beta:仅 GitHub prerelease,不含 npm/packages 发布。
|
|
21
|
+
**V1.9.1 更新**
|
|
22
|
+
|
|
23
|
+
## [1.9.1] — 2026-10-27
|
|
24
|
+
|
|
10
25
|
## [1.9.0] — 2026-10-27
|
|
11
26
|
|
|
12
27
|
**V1.9.0 大版本更新**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.1",
|
|
3
|
+
"case": "OPS-191",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-26T19:55:01.081Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-191-1793044501083",
|
|
11
|
+
"requirement": "V1.9.1 预发布验收(内部能力与细节不公开)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"gates": "GREEN x2",
|
|
16
|
+
"private_tests": "101/101",
|
|
17
|
+
"public_tests": "3059 collected / 0 failed"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.3",
|
|
3
|
+
"case": "RELEASE-193",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-27T12:59:16.096Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-193-1793105956097",
|
|
11
|
+
"requirement": "V1.9.3 发布验收:左侧边栏常驻 GPW 入口;插件设置页检测更新/自动更新;旧版安装可发现新版本",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"gates": "preflight GREEN, full suite 3167 passed",
|
|
16
|
+
"sidebar_gpw": "sidebar.footer.action registered, always visible, no close affordance",
|
|
17
|
+
"update_settings": "settings.section registered; check-on-mount; install button when outdated; auto-update toggle performs install",
|
|
18
|
+
"update_check_fix": "npm mirror failure falls back to registry.npmjs.org; settings shows latest/current without a click"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -493,7 +493,17 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
493
493
|
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
494
494
|
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
495
495
|
// v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
|
|
496
|
-
'.
|
|
496
|
+
'.w2mGpw_sideEntry{display:flex;align-items:center;gap:6px;width:100%;padding:6px 10px;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;border-radius:6px}',
|
|
497
|
+
'.w2mGpw_sideEntry:hover{background:rgba(127,127,127,.14)}',
|
|
498
|
+
'.w2mGpw_sideIcon{font-size:15px;line-height:1}',
|
|
499
|
+
'.w2mGpw_sideLabel{font-size:12px;font-weight:600;letter-spacing:.02em}',
|
|
500
|
+
'.w2mUpdateSection{display:flex;flex-direction:column;gap:8px;max-width:640px}',
|
|
501
|
+
'.w2mUpdateStatus{font-size:12px;opacity:.8}',
|
|
502
|
+
'.w2mUpdateRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
503
|
+
'.w2mUpdateBtn{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.4);background:transparent;color:inherit;font:inherit;cursor:pointer}',
|
|
504
|
+
'.w2mUpdateAuto{display:inline-flex;align-items:center;gap:6px;font-size:12px;opacity:.9}',
|
|
505
|
+
|
|
506
|
+
'.w2mChat_panel,.w2mChat_panel *{user-select:text!important;-webkit-user-select:text!important;cursor:text}',
|
|
497
507
|
'.w2mChat_copy{cursor:pointer!important}',
|
|
498
508
|
'.w2mChat_copy{margin-left:6px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;line-height:14px;padding:0 2px;vertical-align:middle}',
|
|
499
509
|
'.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
|
|
@@ -875,6 +885,93 @@ function PluginUpdate({ t }) {
|
|
|
875
885
|
state.status === 'error' ? h('div', { key: 'e', className: cls('relayHint') }, String(state.error ?? 'error')) : null,
|
|
876
886
|
]);
|
|
877
887
|
}
|
|
888
|
+
/**
|
|
889
|
+
* v1.9.3: the GPW entry in the LEFT sidebar footer. It is always rendered and carries no close
|
|
890
|
+
* affordance -- the operator asked for a permanent entry in addition to the composer and the
|
|
891
|
+
* floating launcher. The click reuses the launcher's event so there is one panel, not two.
|
|
892
|
+
*/
|
|
893
|
+
function GpwSidebarFooter() {
|
|
894
|
+
return h('button', {
|
|
895
|
+
type: 'button',
|
|
896
|
+
className: 'w2mGpw_sideEntry',
|
|
897
|
+
'data-w2m-gpw-sidebar': 'true',
|
|
898
|
+
title: 'GPW 文件夹策略',
|
|
899
|
+
onClick: () => {
|
|
900
|
+
try { window.dispatchEvent(new CustomEvent('w2m-gpw-open')); } catch { /* headless render */ }
|
|
901
|
+
},
|
|
902
|
+
}, [
|
|
903
|
+
h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '📁'),
|
|
904
|
+
h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
|
|
905
|
+
]);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const AUTO_UPDATE_PREF = 'w2m:plugin-auto-update';
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* v1.9.3: the update section on the plugin settings page.
|
|
912
|
+
*
|
|
913
|
+
* Two operator requests meet here: a check that still says something when the installed build is
|
|
914
|
+
* old (the old button lived inside the composer dropdown and was easy to miss), and an auto-update
|
|
915
|
+
* switch that actually performs the install instead of only remembering a preference.
|
|
916
|
+
*/
|
|
917
|
+
function UpdateSettingsSection() {
|
|
918
|
+
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
919
|
+
const [auto, setAuto] = React.useState(false);
|
|
920
|
+
React.useEffect(() => {
|
|
921
|
+
try { setAuto(globalThis.localStorage?.getItem(AUTO_UPDATE_PREF) === '1'); } catch { /* storage off */ }
|
|
922
|
+
}, []);
|
|
923
|
+
const run = React.useCallback(async ({ install }) => {
|
|
924
|
+
setState({ status: 'checking', info: null, error: null });
|
|
925
|
+
try {
|
|
926
|
+
const checked = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
927
|
+
const info = await checked.json().catch(() => ({}));
|
|
928
|
+
if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
|
|
929
|
+
if (install !== true || info.update_available !== true) {
|
|
930
|
+
setState({ status: info.update_available === true ? 'available' : 'current', info, error: null });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
setState({ status: 'applying', info, error: null });
|
|
934
|
+
const applied = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ action: 'apply' }) });
|
|
935
|
+
const result = await applied.json().catch(() => ({}));
|
|
936
|
+
if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
|
|
937
|
+
setState({ status: 'installed', info: { ...info, ...result }, error: null });
|
|
938
|
+
} catch (error) {
|
|
939
|
+
setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
|
|
940
|
+
}
|
|
941
|
+
}, []);
|
|
942
|
+
const toggleAuto = (event) => {
|
|
943
|
+
const next = event?.target?.checked === true;
|
|
944
|
+
setAuto(next);
|
|
945
|
+
try { globalThis.localStorage?.setItem(AUTO_UPDATE_PREF, next ? '1' : '0'); } catch { /* storage off */ }
|
|
946
|
+
if (next) run({ install: true });
|
|
947
|
+
};
|
|
948
|
+
// v1.9.3: state the update picture as soon as the section opens, so an outdated install is
|
|
949
|
+
// visible without a click; the auto path below only adds the install step.
|
|
950
|
+
React.useEffect(() => { run({ install: false }); }, [run]);
|
|
951
|
+
React.useEffect(() => { if (auto) run({ install: true }); }, [auto, run]);
|
|
952
|
+
const busy = state.status === 'checking' || state.status === 'applying';
|
|
953
|
+
const statusText = state.status === 'checking' ? '正在检测更新…'
|
|
954
|
+
: state.status === 'applying' ? '正在安装更新…'
|
|
955
|
+
: state.status === 'available' ? ('发现新版本 ' + (state.info?.latest ?? '?') + '(当前 ' + (state.info?.current ?? '?') + ')')
|
|
956
|
+
: state.status === 'installed' ? ('已安装 ' + (state.info?.to ?? state.info?.latest ?? '?') + ',重启后生效')
|
|
957
|
+
: state.status === 'current' ? ('已是最新版本 ' + (state.info?.current ?? state.info?.latest ?? '?'))
|
|
958
|
+
: state.status === 'error' ? ('更新失败:' + String(state.error ?? 'unknown'))
|
|
959
|
+
: '检测插件是否有新版本;开启自动更新后每次启动自动安装。';
|
|
960
|
+
return h('div', { className: 'w2mUpdateSection', 'data-w2m-update-section': state.status }, [
|
|
961
|
+
h('div', { key: 's', className: 'w2mUpdateStatus' }, statusText),
|
|
962
|
+
h('div', { key: 'row', className: 'w2mUpdateRow' }, [
|
|
963
|
+
h('button', { key: 'check', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: false }) }, '检测更新'),
|
|
964
|
+
state.status === 'available'
|
|
965
|
+
? h('button', { key: 'install', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: true }) }, '立即更新')
|
|
966
|
+
: null,
|
|
967
|
+
h('label', { key: 'auto', className: 'w2mUpdateAuto' }, [
|
|
968
|
+
h('input', { key: 'c', type: 'checkbox', checked: auto, disabled: state.status === 'applying', onChange: toggleAuto }),
|
|
969
|
+
h('span', { key: 'l' }, '自动更新'),
|
|
970
|
+
]),
|
|
971
|
+
]),
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
974
|
+
|
|
878
975
|
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
879
976
|
const [open, setOpen] = React.useState(false);
|
|
880
977
|
// v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
|
|
@@ -1709,6 +1806,30 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1709
1806
|
} catch (error) {
|
|
1710
1807
|
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
|
1711
1808
|
}
|
|
1809
|
+
// v1.9.3: the left-sidebar GPW entry (always visible, no close affordance).
|
|
1810
|
+
// A missing seat must not take the page down: this is an extra path to the same panel.
|
|
1811
|
+
try {
|
|
1812
|
+
slots.inject('sidebar.footer.action', () => slots.register({
|
|
1813
|
+
name: 'sidebar.footer.action',
|
|
1814
|
+
id: 'w2m-gpw-sidebar',
|
|
1815
|
+
label: 'GPW folders',
|
|
1816
|
+
inject: () => ({}),
|
|
1817
|
+
}, GpwSidebarFooter));
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
console.error('@twinsearth/w2m-dsh-plugin: the sidebar GPW entry was not mounted; the composer and launcher still work', error);
|
|
1820
|
+
}
|
|
1821
|
+
// v1.9.3: check-update + auto-update on the plugin settings page.
|
|
1822
|
+
try {
|
|
1823
|
+
slots.inject('settings.section', () => slots.register({
|
|
1824
|
+
name: 'settings.section',
|
|
1825
|
+
id: 'w2m-update',
|
|
1826
|
+
order: 90,
|
|
1827
|
+
label: () => 'W2M 插件更新',
|
|
1828
|
+
inject: () => ({}),
|
|
1829
|
+
}, UpdateSettingsSection));
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
console.error('@twinsearth/w2m-dsh-plugin: the update settings section was not mounted; the composer button still works', error);
|
|
1832
|
+
}
|
|
1712
1833
|
// v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
|
|
1713
1834
|
// asks allow/deny immediately instead of waiting silently in the panel.
|
|
1714
1835
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs",
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.3",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 目录 sink(本地/局域网共享盘):密封对象落盘、按内容哈希取回。
|
|
3
|
+
* 数据面适配器的最小实现;git/ssh 适配器同契约,后续补。
|
|
4
|
+
* 对象已经是 sealObject() 的密文,sink 不接触明文与密钥。
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function createDirSink({ root }) {
|
|
11
|
+
if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
|
|
12
|
+
mkdirSync(root, { recursive: true });
|
|
13
|
+
return {
|
|
14
|
+
kind: 'dir',
|
|
15
|
+
async pushObject({ sealed }) {
|
|
16
|
+
if (sealed === null || typeof sealed !== 'object') return { ok: false, code: 'OBJECT_INVALID' };
|
|
17
|
+
const body = JSON.stringify(sealed);
|
|
18
|
+
const id = createHash('sha256').update(body, 'utf8').digest('hex');
|
|
19
|
+
const path = join(root, id + '.json');
|
|
20
|
+
writeFileSync(path, body, { encoding: 'utf8', mode: 0o600 });
|
|
21
|
+
return { ok: true, objectId: id, path, bytes: Buffer.byteLength(body, 'utf8') };
|
|
22
|
+
},
|
|
23
|
+
async fetchObject({ objectId }) {
|
|
24
|
+
if (typeof objectId !== 'string' || !/^[0-9a-f]{64}$/.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
25
|
+
const path = join(root, objectId + '.json');
|
|
26
|
+
if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
|
|
27
|
+
return { ok: true, sealed: JSON.parse(readFileSync(path, 'utf8')), path };
|
|
28
|
+
},
|
|
29
|
+
async listObjects() {
|
|
30
|
+
return readdirSync(root).filter((name) => name.endsWith('.json')).map((name) => name.replace(/\.json$/, ''));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// internal telemetry module (undocumented)
|
|
2
2
|
import { buildSample, createActivityClock } from './agent-collector.mjs';
|
|
3
|
-
import { nextUploadAt, startupDelayMs, mayPersist } from './upload-scheduler.mjs';
|
|
3
|
+
import { nextUploadAt, startupDelayMs, mayPersist, fleetSizeOrDefault } from './upload-scheduler.mjs';
|
|
4
4
|
import { createOpsUploader } from './upload-client.mjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -36,7 +36,22 @@ export function createPluginCollector(options) {
|
|
|
36
36
|
});
|
|
37
37
|
if (!response.ok) throw new Error('relay ' + relayIndex + ' answered ' + response.status);
|
|
38
38
|
};
|
|
39
|
-
|
|
39
|
+
// Fleet size comes from the relay when it can be read; otherwise the operator's 2,026 fallback applies.
|
|
40
|
+
let fleetSize = fleetSizeOrDefault(options.fleetSize);
|
|
41
|
+
let fleetFetchedAt = 0;
|
|
42
|
+
const refreshFleetSize = async () => {
|
|
43
|
+
if (typeof options.fetchFleetSize !== 'function') return;
|
|
44
|
+
if (now() - fleetFetchedAt < (options.fleetSizeRefreshMs ?? 6 * 60 * 60 * 1000)) return;
|
|
45
|
+
try {
|
|
46
|
+
const observed = await options.fetchFleetSize();
|
|
47
|
+
if (Number.isFinite(observed) && observed > 0) {
|
|
48
|
+
fleetSize = fleetSizeOrDefault(observed);
|
|
49
|
+
fleetFetchedAt = now();
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// keep the last known or the fallback; never disturb the host
|
|
53
|
+
}
|
|
54
|
+
};
|
|
40
55
|
const uploader = createOpsUploader({ relayCount, now, postShard, retry: options.retry, fleetSize });
|
|
41
56
|
let timer = null;
|
|
42
57
|
let disposed = false;
|
|
@@ -51,6 +66,7 @@ export function createPluginCollector(options) {
|
|
|
51
66
|
|
|
52
67
|
function scheduleNext() {
|
|
53
68
|
if (disposed) return;
|
|
69
|
+
void refreshFleetSize();
|
|
54
70
|
const at = nextUploadAt({ machineId: options.machineId, nowMs: now(), fleetSize });
|
|
55
71
|
timer = setTimer(Math.max(1_000, at - now()), () => {
|
|
56
72
|
timer = null;
|
|
@@ -70,6 +86,7 @@ export function createPluginCollector(options) {
|
|
|
70
86
|
clock,
|
|
71
87
|
nowMs: now(),
|
|
72
88
|
});
|
|
89
|
+
await refreshFleetSize();
|
|
73
90
|
const outcome = await uploader.uploadSample({ sample, plaintext, nowMs: now() });
|
|
74
91
|
scheduleNext();
|
|
75
92
|
return outcome;
|
|
@@ -3,6 +3,15 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
|
|
4
4
|
/** Hot data exists for 72 h; persistence of history needs at least three writable relays. */
|
|
5
5
|
export const MIN_RELAYS_TO_STORE = 3;
|
|
6
|
+
|
|
7
|
+
/** Operator decision: when the real online count cannot be fetched from a relay, use 2,026 machines. */
|
|
8
|
+
export const DEFAULT_FLEET_SIZE = 2026;
|
|
9
|
+
|
|
10
|
+
/** Normalize a fleet-size observation; anything missing/invalid/non-positive becomes the 2,026 default. */
|
|
11
|
+
export function fleetSizeOrDefault(observed) {
|
|
12
|
+
const size = Number(observed);
|
|
13
|
+
return Number.isFinite(size) && size > 0 ? Math.floor(size) : DEFAULT_FLEET_SIZE;
|
|
14
|
+
}
|
|
6
15
|
export const HOUR_MS = 60 * 60 * 1000;
|
|
7
16
|
export const MAX_HOT_MS = 72 * HOUR_MS;
|
|
8
17
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decidePath } from './path-policy.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* GPW v2 — folder policy for the approval gate (v1.7.5-beta.1).
|
|
3
4
|
*
|
|
@@ -71,24 +72,15 @@ export function classifyPath(input) {
|
|
|
71
72
|
const base = input.base ?? process.cwd();
|
|
72
73
|
const target = normalizeFolder(input.target, { base, platform });
|
|
73
74
|
const normalise = (list) => (Array.isArray(list) ? list : []).map((f) => normalizeFolder(f, { base, platform }));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (typeof input.workspace === 'string' && input.workspace !== '') {
|
|
86
|
-
const workspace = normalizeFolder(input.workspace, { base, platform });
|
|
87
|
-
if (isInside(target, workspace, { platform })) {
|
|
88
|
-
return { decision: 'allow', target, folder: workspace, reason: 'the target is inside the workspace' };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { decision: 'pass', target, folder: null, reason: 'no list mentions this target, so GPW asks the operator' };
|
|
75
|
+
const rules = [
|
|
76
|
+
...normalise(input.blacklist).map((path) => ({ path, effect: 'deny', source: 'panel' })),
|
|
77
|
+
...normalise(input.whitelist).map((path) => ({ path, effect: 'allow', source: 'panel' })),
|
|
78
|
+
];
|
|
79
|
+
const verdict = decidePath({ target, rules, exceptions: [], workspace: input.workspace ?? null, platform });
|
|
80
|
+
// 兼容旧返回形状:approve 视为 pass(需要人工),判定仍由 decidePath 单一实现。
|
|
81
|
+
const decision = verdict.decision === 'approve' ? 'pass' : verdict.decision;
|
|
82
|
+
const folder = verdict.rule?.path ?? (verdict.source === 'workspace' ? normalizeFolder(input.workspace, { base, platform }) : null);
|
|
83
|
+
return { decision, target, folder, reason: verdict.reason };
|
|
92
84
|
}
|
|
93
85
|
|
|
94
86
|
/** Fields that carry a single filesystem path in the tool schemas this plugin sees. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0-1 / P0-2 适配层:gpw-colors 的唯一调用入口。
|
|
3
|
+
*
|
|
4
|
+
* 规则:白名单 = allow 规则,黑名单 = deny 规则;判定只走 path-policy.decideMany
|
|
5
|
+
* (分段比较、deny 优先、最长前缀、例外 approve)。颜色由 colorOf 渲染,
|
|
6
|
+
* 不参与判定;白名单/工作区全部命中时 skipBudget=true(不进 10 次/分预算)。
|
|
7
|
+
*/
|
|
8
|
+
import { DECISION, colorOf, decideMany } from './path-policy.mjs';
|
|
9
|
+
|
|
10
|
+
export function evaluateCall(input) {
|
|
11
|
+
const platform = input?.platform ?? process.platform;
|
|
12
|
+
const paths = Array.isArray(input?.paths) ? input.paths : [];
|
|
13
|
+
const rules = [];
|
|
14
|
+
for (const path of input?.whitelist ?? []) rules.push({ path, effect: 'allow', source: 'panel' });
|
|
15
|
+
for (const path of input?.blacklist ?? []) rules.push({ path, effect: 'deny', source: 'panel' });
|
|
16
|
+
const verdict = decideMany({
|
|
17
|
+
targets: paths,
|
|
18
|
+
rules,
|
|
19
|
+
exceptions: input?.exceptions ?? [],
|
|
20
|
+
workspace: input?.workspace ?? null,
|
|
21
|
+
platform,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
decision: verdict.decision,
|
|
25
|
+
color: colorOf(verdict.decision),
|
|
26
|
+
skipBudget: verdict.decision === DECISION.ALLOW,
|
|
27
|
+
reason: verdict.reason,
|
|
28
|
+
results: verdict.results ?? [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 红线审计落盘:JSONL + fsync + 哈希链 + 校验。
|
|
3
|
+
* 只接受元数据字段(内容字段直接拒绝),链断裂/篡改可被定位。
|
|
4
|
+
*/
|
|
5
|
+
import { closeSync, fsyncSync, openSync, readFileSync, writeSync } from 'node:fs';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const AUDIT_FORBIDDEN = new Set(['content', 'data', 'file', 'files', 'patch', 'stdout', 'stderr', 'diff', 'blob', 'bytes', 'text', 'base64']);
|
|
9
|
+
|
|
10
|
+
function stable(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
12
|
+
if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
|
|
13
|
+
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
17
|
+
|
|
18
|
+
/** 追加一条审计;写盘后 fsync。返回写入的记录。 */
|
|
19
|
+
export function appendAudit(file, event) {
|
|
20
|
+
if (event === null || typeof event !== 'object' || Array.isArray(event)) {
|
|
21
|
+
return { ok: false, code: 'AUDIT_EVENT_INVALID' };
|
|
22
|
+
}
|
|
23
|
+
const bad = Object.keys(event).filter((key) => AUDIT_FORBIDDEN.has(key.toLowerCase()));
|
|
24
|
+
if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
|
|
25
|
+
|
|
26
|
+
let previous = null;
|
|
27
|
+
let seq = 1;
|
|
28
|
+
try {
|
|
29
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
|
|
30
|
+
if (lines.length > 0) {
|
|
31
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
32
|
+
previous = last.record_sha256 ?? null;
|
|
33
|
+
seq = Number(last.seq ?? lines.length) + 1;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* 新文件 */ }
|
|
36
|
+
|
|
37
|
+
const body = {
|
|
38
|
+
v: 1,
|
|
39
|
+
seq,
|
|
40
|
+
at: event.at ?? new Date().toISOString(),
|
|
41
|
+
event: typeof event.event === 'string' ? event.event : 'joint.event',
|
|
42
|
+
plan_sha256: event.plan_sha256 ?? null,
|
|
43
|
+
step_id: event.step_id ?? null,
|
|
44
|
+
task_id: event.task_id ?? null,
|
|
45
|
+
machine_id: event.machine_id ?? null,
|
|
46
|
+
outcome: event.outcome ?? null,
|
|
47
|
+
reason_code: event.reason_code ?? null,
|
|
48
|
+
control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
|
|
49
|
+
control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
|
|
50
|
+
prev_sha256: previous,
|
|
51
|
+
};
|
|
52
|
+
const record = { ...body, record_sha256: sha256(stable(body)) };
|
|
53
|
+
const fd = openSync(file, 'a');
|
|
54
|
+
try {
|
|
55
|
+
writeSync(fd, JSON.stringify(record) + '\n');
|
|
56
|
+
fsyncSync(fd);
|
|
57
|
+
} finally {
|
|
58
|
+
closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, record };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 校验整条链;返回第一处问题的 seq。 */
|
|
64
|
+
export function verifyAuditFile(file) {
|
|
65
|
+
let lines;
|
|
66
|
+
try {
|
|
67
|
+
lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: true, count: 0, head: null };
|
|
70
|
+
}
|
|
71
|
+
let expectedPrev = null;
|
|
72
|
+
let head = null;
|
|
73
|
+
lines.forEach((line, index) => {
|
|
74
|
+
let record;
|
|
75
|
+
try { record = JSON.parse(line); } catch { throw new Error('AUDIT_JSON_BROKEN at line ' + (index + 1)); }
|
|
76
|
+
const { record_sha256: stored, ...body } = record;
|
|
77
|
+
if (body.prev_sha256 !== expectedPrev) throw new Error('AUDIT_CHAIN_BROKEN at seq ' + body.seq);
|
|
78
|
+
if (sha256(stable(body)) !== stored) throw new Error('AUDIT_ENTRY_TAMPERED at seq ' + body.seq);
|
|
79
|
+
expectedPrev = stored;
|
|
80
|
+
head = stored;
|
|
81
|
+
});
|
|
82
|
+
return { ok: true, count: lines.length, head };
|
|
83
|
+
}
|