@twinsearth/w2m-dsh-plugin 1.9.1 → 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 +13 -2
- package/codex/mcp-servers.toml +1 -1
- 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/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 -8225
- 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,10 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
-
## [1.9.
|
|
11
|
-
|
|
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 发布。
|
|
12
21
|
**V1.9.1 更新**
|
|
13
22
|
|
|
23
|
+
## [1.9.1] — 2026-10-27
|
|
24
|
+
|
|
14
25
|
## [1.9.0] — 2026-10-27
|
|
15
26
|
|
|
16
27
|
**V1.9.0 大版本更新**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 联合开发:计划 → 控制面信封 → 分发(R1–R4 的生产接线点)。
|
|
3
|
+
*
|
|
4
|
+
* 只构造"任务指令 + 元数据":任务 ID、机器、步骤、命令 hash、base commit、写范围。
|
|
5
|
+
* 任何用户代码/文件/输出字节都不允许出现在这里;发送前逐条过 redline.inspectControlMessage,
|
|
6
|
+
* 失败即拒绝并写审计(只记元数据)。本模块不做网络 I/O,sendFn 由调用方注入。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { appendAudit } from './audit-file.mjs';
|
|
11
|
+
import { createAudit, inspectControlMessage } from './redline.mjs';
|
|
12
|
+
|
|
13
|
+
export const MAX_STEPS = 16;
|
|
14
|
+
const DATA_KEYS = new Set(['data', 'file', 'files', 'content', 'patch', 'diff', 'blob', 'bytes', 'stdout', 'stderr', 'base64', 'text']);
|
|
15
|
+
|
|
16
|
+
const sha256 = (value) => createHash('sha256').update(String(value), 'utf8').digest('hex');
|
|
17
|
+
|
|
18
|
+
function scanData(value, path, hits) {
|
|
19
|
+
if (value === null || typeof value !== 'object') return;
|
|
20
|
+
if (Array.isArray(value)) { value.forEach((entry, index) => scanData(entry, path + '[' + index + ']', hits)); return; }
|
|
21
|
+
for (const [key, child] of Object.entries(value)) {
|
|
22
|
+
const keyPath = path === '' ? key : path + '.' + key;
|
|
23
|
+
if (DATA_KEYS.has(key.toLowerCase())) hits.push(keyPath);
|
|
24
|
+
scanData(child, keyPath, hits);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 校验计划:步骤唯一、依赖存在、无环、无数据字段、机器 ID 合法。
|
|
30
|
+
* @returns {{ok: true, plan: object} | {ok: false, code: string, detail: object}}
|
|
31
|
+
*/
|
|
32
|
+
export function buildPlan(input) {
|
|
33
|
+
if (input === null || typeof input !== 'object') return { ok: false, code: 'PLAN_INVALID', detail: {} };
|
|
34
|
+
const steps = Array.isArray(input.steps) ? input.steps : null;
|
|
35
|
+
if (steps === null || steps.length === 0 || steps.length > MAX_STEPS) {
|
|
36
|
+
return { ok: false, code: 'PLAN_STEPS_INVALID', detail: { count: steps === null ? null : steps.length } };
|
|
37
|
+
}
|
|
38
|
+
const hits = [];
|
|
39
|
+
scanData(input, '', hits);
|
|
40
|
+
if (hits.length > 0) return { ok: false, code: 'DATA_PLANE_FORBIDDEN', detail: { paths: hits.slice(0, 8) } };
|
|
41
|
+
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
for (const step of steps) {
|
|
44
|
+
if (typeof step?.step_id !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(step.step_id)) {
|
|
45
|
+
return { ok: false, code: 'PLAN_STEP_ID_INVALID', detail: { step_id: step?.step_id ?? null } };
|
|
46
|
+
}
|
|
47
|
+
if (ids.has(step.step_id)) return { ok: false, code: 'PLAN_STEP_DUPLICATE', detail: { step_id: step.step_id } };
|
|
48
|
+
ids.add(step.step_id);
|
|
49
|
+
if (typeof step.machine_id !== 'string' || step.machine_id === '') {
|
|
50
|
+
return { ok: false, code: 'PLAN_MACHINE_INVALID', detail: { step_id: step.step_id } };
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(step.command_argv) || step.command_argv.length === 0) {
|
|
53
|
+
return { ok: false, code: 'PLAN_ARGV_INVALID', detail: { step_id: step.step_id } };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const graph = new Map(steps.map((step) => [step.step_id, Array.isArray(step.depends_on) ? step.depends_on : []]));
|
|
57
|
+
for (const [id, deps] of graph) {
|
|
58
|
+
for (const dep of deps) {
|
|
59
|
+
if (!ids.has(dep)) return { ok: false, code: 'PLAN_DEP_UNKNOWN', detail: { step_id: id, dep } };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const visiting = new Set();
|
|
63
|
+
const done = new Set();
|
|
64
|
+
const visit = (id) => {
|
|
65
|
+
if (done.has(id)) return true;
|
|
66
|
+
if (visiting.has(id)) return false;
|
|
67
|
+
visiting.add(id);
|
|
68
|
+
for (const dep of graph.get(id) ?? []) if (!visit(dep)) return false;
|
|
69
|
+
visiting.delete(id);
|
|
70
|
+
done.add(id);
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
for (const id of ids) if (!visit(id)) return { ok: false, code: 'PLAN_CYCLE', detail: { step_id: id } };
|
|
74
|
+
|
|
75
|
+
const canonical = {
|
|
76
|
+
v: 1,
|
|
77
|
+
title: typeof input.title === 'string' ? input.title.slice(0, 200) : '',
|
|
78
|
+
base_commit: typeof input.base_commit === 'string' ? input.base_commit : null,
|
|
79
|
+
steps: steps.map((step) => ({
|
|
80
|
+
step_id: step.step_id,
|
|
81
|
+
machine_id: step.machine_id,
|
|
82
|
+
command_argv: step.command_argv.slice(0, 32),
|
|
83
|
+
command_hash: sha256(JSON.stringify(step.command_argv)),
|
|
84
|
+
write_scope: Array.isArray(step.write_scope) ? step.write_scope.slice(0, 8) : [],
|
|
85
|
+
depends_on: Array.isArray(step.depends_on) ? step.depends_on.slice(0, 8) : [],
|
|
86
|
+
timeout_ms: Number.isInteger(step.timeout_ms) ? step.timeout_ms : null,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
return { ok: true, plan: { ...canonical, plan_sha256: sha256(JSON.stringify(canonical)) } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 计划 → 控制面信封(每个步骤一条 joint.dispatch,只含元数据)。 */
|
|
93
|
+
export function buildControlEnvelopes(plan) {
|
|
94
|
+
return plan.steps.map((step) => ({
|
|
95
|
+
v: 1,
|
|
96
|
+
type: 'joint.dispatch',
|
|
97
|
+
body: {
|
|
98
|
+
plan_sha256: plan.plan_sha256,
|
|
99
|
+
step_id: step.step_id,
|
|
100
|
+
machine_id: step.machine_id,
|
|
101
|
+
base_commit: plan.base_commit,
|
|
102
|
+
command_argv: step.command_argv,
|
|
103
|
+
command_hash: step.command_hash,
|
|
104
|
+
write_scope: step.write_scope,
|
|
105
|
+
depends_on: step.depends_on,
|
|
106
|
+
...(step.timeout_ms === null ? {} : { timeout_ms: step.timeout_ms }),
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 分发:逐条过红线 → 审计 → sendFn。失败即停,返回已完成与失败原因。
|
|
113
|
+
* @param {{plan: object, sendFn: Function, dryRun?: boolean, audit?: object}} input
|
|
114
|
+
*/
|
|
115
|
+
export async function dispatchPlan({ plan, sendFn, dryRun = false, audit = null, auditFile = null }) {
|
|
116
|
+
if (plan === null || typeof plan !== 'object' || typeof plan.plan_sha256 !== 'string') {
|
|
117
|
+
return { ok: false, code: 'PLAN_NOT_BUILT', sent: [], audits: [] };
|
|
118
|
+
}
|
|
119
|
+
const auditLog = audit ?? createAudit();
|
|
120
|
+
const envelopes = buildControlEnvelopes(plan);
|
|
121
|
+
const sent = [];
|
|
122
|
+
const audits = [];
|
|
123
|
+
for (const envelope of envelopes) {
|
|
124
|
+
const check = inspectControlMessage(envelope);
|
|
125
|
+
const bytes = Buffer.byteLength(JSON.stringify(envelope), 'utf8');
|
|
126
|
+
if (check.ok !== true) {
|
|
127
|
+
const record = { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: 'denied', reason_code: check.code, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
|
|
128
|
+
auditLog.append(record);
|
|
129
|
+
audits.push(record);
|
|
130
|
+
return { ok: false, code: check.code, detail: check.detail, sent, audits };
|
|
131
|
+
}
|
|
132
|
+
if (dryRun) { sent.push({ step_id: envelope.body.step_id, bytes, dispatched: false }); continue; }
|
|
133
|
+
if (typeof auditFile === 'string') {
|
|
134
|
+
let written;
|
|
135
|
+
try { written = appendAudit(auditFile, { event: 'joint.dispatch.sending', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) }); } catch (error) { written = { ok: false, detail: String(error) }; }
|
|
136
|
+
if (written.ok !== true) return { ok: false, code: 'AUDIT_UNAVAILABLE', detail: written, sent, audits };
|
|
137
|
+
}
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await sendFn(envelope, { stepId: envelope.body.step_id, machineId: envelope.body.machine_id, bytes });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const record = { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: 'error', reason_code: 'SEND_FAILED', control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
|
|
143
|
+
auditLog.append(record);
|
|
144
|
+
audits.push(record);
|
|
145
|
+
return { ok: false, code: 'SEND_FAILED', detail: { step_id: envelope.body.step_id, error: String(error).slice(0, 120) }, sent, audits };
|
|
146
|
+
}
|
|
147
|
+
const record = { event: 'joint.dispatch.sent', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: response?.ok === true ? 'ok' : 'rejected', reason_code: response?.code ?? null, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
|
|
148
|
+
auditLog.append(record);
|
|
149
|
+
audits.push(record);
|
|
150
|
+
sent.push({ step_id: envelope.body.step_id, bytes, dispatched: true });
|
|
151
|
+
if (response?.ok !== true) return { ok: false, code: response?.code ?? 'DISPATCH_REJECTED', detail: { step_id: envelope.body.step_id }, sent, audits };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, sent, audits, auditHead: auditLog.head(), dryRun };
|
|
154
|
+
}
|