@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 联合开发闭环(纯函数 + 注入传输):collect → merge → verify。
|
|
3
|
+
* 数据字节只经直连 fetchFn;relay 只出现在控制面(dispatch.mjs)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 收集:按 ref 直连拉取,校验 sha256;缺失/不匹配即记为问题。 */
|
|
7
|
+
export async function collectArtifacts({ refs, fetchFn, crypto }) {
|
|
8
|
+
const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const artifacts = [];
|
|
10
|
+
const problems = [];
|
|
11
|
+
for (const ref of refs) {
|
|
12
|
+
if (ref === null || typeof ref !== 'object' || typeof ref.machine !== 'string') {
|
|
13
|
+
problems.push({ machine: ref?.machine ?? null, file: ref?.file ?? null, reason: 'REF_INVALID' });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
let bytes;
|
|
17
|
+
try {
|
|
18
|
+
bytes = await fetchFn(ref);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'FETCH_FAILED', detail: String(error).slice(0, 80) });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (bytes === undefined || bytes === null) {
|
|
24
|
+
problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'REF_MISSING' });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const hash = digest(Buffer.from(bytes));
|
|
28
|
+
if (ref.sha256 !== hash) {
|
|
29
|
+
problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'HASH_MISMATCH', expected: ref.sha256, actual: hash });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
artifacts.push({ machine: ref.machine, file: ref.file, path: ref.path ?? ref.file, bytes: Buffer.from(bytes), sha256: hash });
|
|
33
|
+
}
|
|
34
|
+
return { ok: problems.length === 0, artifacts, problems };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 合并:按 (path) 检测同文件双改冲突;冲突时 head 不动,返回冲突明细。 */
|
|
38
|
+
export function mergeArtifacts({ artifacts }) {
|
|
39
|
+
const byPath = new Map();
|
|
40
|
+
const conflicts = [];
|
|
41
|
+
for (const artifact of artifacts) {
|
|
42
|
+
const path = artifact.path ?? artifact.file;
|
|
43
|
+
const key = String(path).toLowerCase();
|
|
44
|
+
const existing = byPath.get(key);
|
|
45
|
+
if (existing === undefined) {
|
|
46
|
+
byPath.set(key, { path, machines: [artifact.machine], bytes: artifact.bytes, sha256: artifact.sha256 });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (existing.sha256 === artifact.sha256) {
|
|
50
|
+
if (!existing.machines.includes(artifact.machine)) existing.machines.push(artifact.machine);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
conflicts.push({ path, machines: [...new Set([...existing.machines, artifact.machine])], reason: 'CONTENT_CONFLICT', left: existing.sha256, right: artifact.sha256 });
|
|
54
|
+
}
|
|
55
|
+
if (conflicts.length > 0) return { ok: false, code: 'MERGE_CONFLICT', conflicts, files: [] };
|
|
56
|
+
const files = [...byPath.values()].map((entry) => ({ path: entry.path, sha256: entry.sha256, bytes: entry.bytes, machines: entry.machines }));
|
|
57
|
+
return { ok: true, files, conflicts: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 复核:对合并结果逐机比对期望文件;只回结论与差异摘要。 */
|
|
61
|
+
export function verifyReview({ merged, expectations }) {
|
|
62
|
+
const present = new Map(merged.files.map((file) => [String(file.path).toLowerCase(), file.sha256]));
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const expectation of expectations) {
|
|
65
|
+
const actual = present.get(String(expectation.file).toLowerCase()) ?? null;
|
|
66
|
+
const pass = actual !== null && (expectation.sha256 === undefined || actual === expectation.sha256);
|
|
67
|
+
results.push(pass
|
|
68
|
+
? { machine: expectation.machine, file: expectation.file, status: 'PASS' }
|
|
69
|
+
: { machine: expectation.machine, file: expectation.file, status: 'FAIL', reason: actual === null ? 'FILE_MISSING' : 'HASH_MISMATCH', expected: expectation.sha256 ?? null, actual });
|
|
70
|
+
}
|
|
71
|
+
const failed = results.filter((entry) => entry.status === 'FAIL');
|
|
72
|
+
return { ok: failed.length === 0, verdict: failed.length === 0 ? 'PASS' : 'FAIL', results, failed };
|
|
73
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* joint-dev 计划持久化与人工审批门(本地文件,0600)。
|
|
3
|
+
* 未审批的计划禁止 dispatch;审批绑定 plan_sha256 与候选人。
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
10
|
+
const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
|
|
11
|
+
const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
|
|
12
|
+
|
|
13
|
+
export function savePlan({ stateDir, plan, now = Date.now() }) {
|
|
14
|
+
if (typeof plan?.plan_sha256 !== 'string' || plan.plan_sha256.length !== 64) return { ok: false, code: 'PLAN_INVALID' };
|
|
15
|
+
mkdirSync(planDir(stateDir), { recursive: true });
|
|
16
|
+
const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
|
|
17
|
+
if (existsSync(path)) return { ok: true, path, reused: true };
|
|
18
|
+
writeFileSync(path, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
19
|
+
return { ok: true, path, reused: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadPlan({ stateDir, planSha }) {
|
|
23
|
+
const path = join(planDir(stateDir), planSha + '.json');
|
|
24
|
+
if (!existsSync(path)) return { ok: false, code: 'PLAN_NOT_FOUND' };
|
|
25
|
+
return { ok: true, ...JSON.parse(readFileSync(path, 'utf8')), path };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 人工审批。authority 建议 ≥70;审批只由操作员 CLI/UI 写入,不暴露给 agent 工具。
|
|
30
|
+
*/
|
|
31
|
+
export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now() }) {
|
|
32
|
+
if (typeof planSha !== 'string' || planSha.length !== 64) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
33
|
+
if (typeof approver !== 'string' || approver === '') return { ok: false, code: 'APPROVER_REQUIRED' };
|
|
34
|
+
if (!Number.isInteger(authority) || authority < 70) return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority } };
|
|
35
|
+
if (!existsSync(join(planDir(stateDir), planSha + '.json'))) return { ok: false, code: 'PLAN_NOT_FOUND' };
|
|
36
|
+
mkdirSync(approvalDir(stateDir), { recursive: true });
|
|
37
|
+
const body = {
|
|
38
|
+
v: 1,
|
|
39
|
+
plan_sha256: planSha,
|
|
40
|
+
approver,
|
|
41
|
+
authority,
|
|
42
|
+
note: String(note).slice(0, 200),
|
|
43
|
+
approved_at: new Date(now).toISOString(),
|
|
44
|
+
};
|
|
45
|
+
const record = { ...body, approval_sha256: sha256(JSON.stringify(body)) };
|
|
46
|
+
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
47
|
+
writeFileSync(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
48
|
+
return { ok: true, approval: record, path };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadApproval({ stateDir, planSha }) {
|
|
52
|
+
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
53
|
+
if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
54
|
+
const record = JSON.parse(readFileSync(path, 'utf8'));
|
|
55
|
+
if (record.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
|
|
56
|
+
return { ok: true, approval: record };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** dispatch 前置门:计划必须存在且已审批。 */
|
|
60
|
+
export function assertDispatchAllowed({ stateDir, planSha }) {
|
|
61
|
+
const plan = loadPlan({ stateDir, planSha });
|
|
62
|
+
if (plan.ok !== true) return { ok: false, code: 'PLAN_NOT_FOUND' };
|
|
63
|
+
const approval = loadApproval({ stateDir, planSha });
|
|
64
|
+
if (approval.ok !== true) return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
65
|
+
return { ok: true, plan: plan.plan, approval: approval.approval };
|
|
66
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 兼容入口:红线判据的唯一实现在 src/util/redline-core.mjs(relay 与插件共用)。
|
|
3
|
+
* 保留此文件是为了既有引用(joint-dev/*、测试)不发生破坏性改动。
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
MAX_CONTROL_BYTES,
|
|
7
|
+
CONTROL_VERSION,
|
|
8
|
+
CONTROL_TYPES,
|
|
9
|
+
DATA_PLANES,
|
|
10
|
+
CONTROL_ALLOWED_KEYS,
|
|
11
|
+
BODY_ALLOWED_KEYS,
|
|
12
|
+
FORBIDDEN_DATA_KEYS,
|
|
13
|
+
inspectControlMessage,
|
|
14
|
+
assertPlane,
|
|
15
|
+
createAudit,
|
|
16
|
+
} from '../../util/redline-core.mjs';
|
|
@@ -14,6 +14,7 @@ import { compareVersions } from './update-source.mjs';
|
|
|
14
14
|
export const PLUGIN_PACKAGE_NAME = '@twinsearth/w2m-dsh-plugin';
|
|
15
15
|
/** npm mirrors are fine here: the answer is only "which version is latest", and the tarball is verified separately. */
|
|
16
16
|
export const DEFAULT_NPM_LATEST_URL = `https://registry.npmmirror.com/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}/latest`;
|
|
17
|
+
export const DEFAULT_NPM_LATEST_FALLBACK_URL = `https://registry.npmjs.org/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}/latest`;
|
|
17
18
|
export const NPM_CHECK_TIMEOUT_MS = 15_000;
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -61,19 +62,24 @@ export function decideNpmUpdate({ latest, currentVersion }) {
|
|
|
61
62
|
export async function checkNpmUpdate(input) {
|
|
62
63
|
const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
|
|
63
64
|
const url = input?.url ?? DEFAULT_NPM_LATEST_URL;
|
|
65
|
+
const urls = input?.url !== undefined ? [url] : [url, DEFAULT_NPM_LATEST_FALLBACK_URL];
|
|
64
66
|
const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : NPM_CHECK_TIMEOUT_MS;
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
let last = { ok: false, error: 'npm latest could not be read' };
|
|
68
|
+
for (const candidate of urls) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetchImpl(candidate, {
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
73
|
+
});
|
|
74
|
+
if (response?.ok !== true) { last = { ok: false, error: `npm registry answered HTTP ${response?.status ?? '?'}` }; continue; }
|
|
75
|
+
const doc = await response.json();
|
|
76
|
+
const parsed = parseNpmLatest(doc);
|
|
77
|
+
if (parsed.ok === true) return decideNpmUpdate({ latest: parsed, currentVersion: input?.currentVersion ?? null });
|
|
78
|
+
last = parsed;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
last = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
73
81
|
}
|
|
74
|
-
doc = await response.json();
|
|
75
|
-
} catch (error) {
|
|
76
|
-
return decideNpmUpdate({ latest: { ok: false, error: error instanceof Error ? error.message : String(error) }, currentVersion: input?.currentVersion ?? null });
|
|
77
82
|
}
|
|
78
|
-
return decideNpmUpdate({ latest:
|
|
83
|
+
return decideNpmUpdate({ latest: last, currentVersion: input?.currentVersion ?? null });
|
|
79
84
|
}
|
|
85
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPW 路径继承语义(单一判定点)。
|
|
3
|
+
*
|
|
4
|
+
* 白名单 = 子树许可(向下任意深度、不向上、分段比较、自反)。
|
|
5
|
+
* 黑名单 = 子树拒绝,默认优先;同深度 deny 胜。
|
|
6
|
+
* 父黑名单下的子目录默认 deny;唯一通道是显式例外:
|
|
7
|
+
* 严格更深、仅配置来源、带 TTL、效果 approve(每次人工确认)。
|
|
8
|
+
* 删除还需父目录写权限;敏感底线不可被白名单覆盖。
|
|
9
|
+
* 未在名单内 → pass(问人),绝不自动 allow。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SENSITIVE_SEGMENTS = new Set(['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'credentials']);
|
|
13
|
+
const SENSITIVE_EXT = /\.(pem|key|pfx|p12|jks|kdbx)$/i;
|
|
14
|
+
const SENSITIVE_NAMES = new Set(['.env', 'id_rsa', 'id_ed25519', 'wallet.dat', 'login data', 'gpw-folders.json', 'gpw-folders.json.key']);
|
|
15
|
+
|
|
16
|
+
export const DECISION = Object.freeze({ ALLOW: 'allow', DENY: 'deny', APPROVE: 'approve', PASS: 'pass' });
|
|
17
|
+
|
|
18
|
+
const isWindows = (platform) => platform === 'win32';
|
|
19
|
+
|
|
20
|
+
/** 词法规范化:别名、尾点/空格、. / ..、分隔符;Win32 只折 ASCII。 */
|
|
21
|
+
export function canonicalKey(raw, { platform = process.platform } = {}) {
|
|
22
|
+
if (typeof raw !== 'string') return null;
|
|
23
|
+
let text = raw.trim();
|
|
24
|
+
if (text === '' || text.includes('\u0000')) return null;
|
|
25
|
+
const win = isWindows(platform);
|
|
26
|
+
if (win) {
|
|
27
|
+
text = text.replace(/^\\\\[?.]\\UNC\\(.*)$/i, '\\\\$1').replace(/^\\\\[?.]\\(?=[A-Za-z]:)/, '');
|
|
28
|
+
text = text.replace(/\//g, '\\');
|
|
29
|
+
}
|
|
30
|
+
const segments = [];
|
|
31
|
+
const drive = win ? /^([A-Za-z]):(.*)$/.exec(text) : null;
|
|
32
|
+
let rest = text;
|
|
33
|
+
if (drive !== null) {
|
|
34
|
+
if (drive[2].startsWith('\\') === false) return null; // C:foo 盘符相对不可判定
|
|
35
|
+
rest = drive[2];
|
|
36
|
+
}
|
|
37
|
+
for (const part of rest.split(/[\\/]+/)) {
|
|
38
|
+
if (part === '' || part === '.') continue;
|
|
39
|
+
if (part === '..') { segments.pop(); continue; }
|
|
40
|
+
let segment = part;
|
|
41
|
+
if (win) segment = segment.replace(/[. ]+$/g, ''); // Win32 去尾点/空格
|
|
42
|
+
if (segment !== '') segments.push(win ? segment.replace(/[A-Z]/g, (c) => c.toLowerCase()) : segment);
|
|
43
|
+
}
|
|
44
|
+
if (win) return (drive === null ? '' : drive[1].toLowerCase() + ':\\') + segments.join('\\');
|
|
45
|
+
return '/' + segments.join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function segmentsOf(key, platform) {
|
|
49
|
+
if (typeof key !== 'string' || key === '') return null;
|
|
50
|
+
if (isWindows(platform)) {
|
|
51
|
+
const match = /^([A-Za-z]):\\(.*)$/.exec(key);
|
|
52
|
+
return match === null ? null : [match[1] + ':', ...match[2].split('\\').filter(Boolean)];
|
|
53
|
+
}
|
|
54
|
+
return key.split('/').filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 分段包含:自反;C:\work 不含 C:\workshop。 */
|
|
58
|
+
export function inside(targetKey, rootKey, platform = process.platform) {
|
|
59
|
+
const target = segmentsOf(targetKey, platform);
|
|
60
|
+
const root = segmentsOf(rootKey, platform);
|
|
61
|
+
if (target === null || root === null || target.length < root.length) return false;
|
|
62
|
+
for (let index = 0; index < root.length; index += 1) {
|
|
63
|
+
if (target[index] !== root[index]) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 敏感底线:段名/文件名/扩展名命中即 deny,任何白名单不能覆盖。 */
|
|
69
|
+
export function sensitiveMatch(raw) {
|
|
70
|
+
const lowered = String(raw).toLowerCase();
|
|
71
|
+
for (const segment of lowered.split(/[\\/]+/)) {
|
|
72
|
+
if (SENSITIVE_SEGMENTS.has(segment)) return { hit: true, reason: 'SENSITIVE_SEGMENT', segment };
|
|
73
|
+
if (SENSITIVE_NAMES.has(segment)) return { hit: true, reason: 'SENSITIVE_NAME', segment };
|
|
74
|
+
if (SENSITIVE_EXT.test(segment)) return { hit: true, reason: 'SENSITIVE_EXTENSION', segment };
|
|
75
|
+
}
|
|
76
|
+
return { hit: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ruleDepth(rule, target, platform) {
|
|
80
|
+
const root = canonicalKey(rule.path, { platform });
|
|
81
|
+
if (root === null || !inside(target, root, platform)) return -1;
|
|
82
|
+
return segmentsOf(root, platform).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 判定。规则:{path, effect:'allow'|'deny', source:'config'|'panel', expiresAt?, ops?}
|
|
87
|
+
* @returns {{decision: string, source: string, reason: string, rule?: object}}
|
|
88
|
+
*/
|
|
89
|
+
export function decidePath({ target, rules = [], exceptions = [], workspace = null, platform = process.platform, now = Date.now() }) {
|
|
90
|
+
const key = canonicalKey(target, { platform });
|
|
91
|
+
if (key === null) return { decision: DECISION.PASS, source: 'canonicalize', reason: 'UNRESOLVABLE' };
|
|
92
|
+
|
|
93
|
+
const sensitive = sensitiveMatch(target);
|
|
94
|
+
if (sensitive.hit) return { decision: DECISION.DENY, source: 'floor', reason: sensitive.reason };
|
|
95
|
+
|
|
96
|
+
const covering = [];
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
const depth = ruleDepth(rule, key, platform);
|
|
99
|
+
if (depth >= 0) covering.push({ rule, depth });
|
|
100
|
+
}
|
|
101
|
+
const denies = covering.filter((entry) => entry.rule.effect === 'deny');
|
|
102
|
+
if (denies.length > 0) {
|
|
103
|
+
const deepestDeny = Math.max(...denies.map((entry) => entry.depth));
|
|
104
|
+
const exception = exceptions.find((entry) => entry.source === 'config'
|
|
105
|
+
&& (entry.expiresAt === undefined || entry.expiresAt > now)
|
|
106
|
+
&& ruleDepth(entry, key, platform) > deepestDeny);
|
|
107
|
+
if (exception !== undefined) {
|
|
108
|
+
return { decision: DECISION.APPROVE, source: 'exception', reason: 'EXCEPTION_REQUIRES_APPROVAL', rule: exception };
|
|
109
|
+
}
|
|
110
|
+
return { decision: DECISION.DENY, source: 'rule', reason: 'DENY_RULE', rule: denies.sort((a, b) => b.depth - a.depth)[0].rule };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const allows = covering.filter((entry) => entry.rule.effect === 'allow').sort((a, b) => b.depth - a.depth);
|
|
114
|
+
if (allows.length > 0) return { decision: DECISION.ALLOW, source: 'rule', reason: 'ALLOW_RULE', rule: allows[0].rule };
|
|
115
|
+
if (workspace !== null) {
|
|
116
|
+
const root = canonicalKey(workspace, { platform });
|
|
117
|
+
if (root !== null && inside(key, root, platform)) return { decision: DECISION.ALLOW, source: 'workspace', reason: 'ALLOW_WORKSPACE' };
|
|
118
|
+
}
|
|
119
|
+
return { decision: DECISION.PASS, source: 'unlisted', reason: 'NO_RULE' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 操作维度:delete/rename 需要父目录写权限。
|
|
124
|
+
* @param {{target: string, operation: 'read'|'write'|'delete'|'exec', ...decidePathInput}} input
|
|
125
|
+
*/
|
|
126
|
+
export function decideOperation(input) {
|
|
127
|
+
const verdict = decidePath(input);
|
|
128
|
+
if (verdict.decision !== DECISION.ALLOW || input.operation !== 'delete') return verdict;
|
|
129
|
+
const parent = canonicalKey(input.target, { platform: input.platform });
|
|
130
|
+
if (parent === null) return verdict;
|
|
131
|
+
const parentPath = parent.replace(/[\\/][^\\/]+$/, '');
|
|
132
|
+
const parentVerdict = decidePath({ ...input, target: parentPath, operation: 'write' });
|
|
133
|
+
if (parentVerdict.decision !== DECISION.ALLOW) {
|
|
134
|
+
return { decision: DECISION.APPROVE, source: 'parent', reason: 'PARENT_WRITE_REQUIRED', parent: parentVerdict };
|
|
135
|
+
}
|
|
136
|
+
return verdict;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 写入期:不允许在黑名单子树内添加白名单(避免静默开洞)。 */
|
|
140
|
+
export function assertRuleWritable({ path, rules = [], platform = process.platform }) {
|
|
141
|
+
const key = canonicalKey(path, { platform });
|
|
142
|
+
if (key === null) return { ok: false, code: 'RULE_PATH_INVALID' };
|
|
143
|
+
for (const rule of rules) {
|
|
144
|
+
if (rule.effect !== 'deny') continue;
|
|
145
|
+
const depth = ruleDepth(rule, key, platform);
|
|
146
|
+
if (depth >= 0) return { ok: false, code: 'RULE_CONFLICT_DENY_ANCESTOR', detail: { ancestor: rule.path } };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 多路径聚合(C36 语义):任一 deny → deny;全部 allow → allow;否则 approve。
|
|
153
|
+
* 现状 gpw 的 classifyToolCall 是"任一 allow 就 allow"(fail-open),这里是修正版。
|
|
154
|
+
*/
|
|
155
|
+
export function decideMany({ targets, ...input }) {
|
|
156
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
157
|
+
return { decision: DECISION.PASS, source: 'unlisted', reason: 'NO_PATHS', results: [] };
|
|
158
|
+
}
|
|
159
|
+
const results = targets.map((target) => decidePath({ ...input, target }));
|
|
160
|
+
if (results.some((entry) => entry.decision === DECISION.DENY)) {
|
|
161
|
+
return { decision: DECISION.DENY, source: 'aggregate', reason: 'ANY_DENY', results };
|
|
162
|
+
}
|
|
163
|
+
if (results.every((entry) => entry.decision === DECISION.PASS)) {
|
|
164
|
+
return { decision: DECISION.PASS, source: 'aggregate', reason: 'NO_RULE', results };
|
|
165
|
+
}
|
|
166
|
+
if (results.every((entry) => entry.decision === DECISION.ALLOW)) {
|
|
167
|
+
return { decision: DECISION.ALLOW, source: 'aggregate', reason: 'ALL_ALLOW', results };
|
|
168
|
+
}
|
|
169
|
+
return { decision: DECISION.APPROVE, source: 'aggregate', reason: 'NEEDS_APPROVAL', results };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** P0-2:判定 → 颜色(颜色只做渲染,不参与判定)。 */
|
|
173
|
+
export function colorOf(decision) {
|
|
174
|
+
if (decision === DECISION.ALLOW) return 'green';
|
|
175
|
+
if (decision === DECISION.DENY) return 'red';
|
|
176
|
+
return 'yellow';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
import { realpathSync } from 'node:fs';
|
|
180
|
+
import { dirname } from 'node:path';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* FS 层:对最长已存在前缀取 realpath,剩余不存在尾部按词法拼回。
|
|
184
|
+
* 返回 { real, key, rest };无法解析时 real=null(调用方按更严处理)。
|
|
185
|
+
*/
|
|
186
|
+
export function realpathPrefix(raw, { platform = process.platform } = {}) {
|
|
187
|
+
const key = canonicalKey(raw, { platform });
|
|
188
|
+
if (key === null) return null;
|
|
189
|
+
let probe = key;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
const real = realpathSync.native(probe);
|
|
193
|
+
const normalized = canonicalKey(real, { platform });
|
|
194
|
+
if (normalized === null) return { real: null, key, rest: key };
|
|
195
|
+
const rest = key.slice(probe.length);
|
|
196
|
+
return { real, key: normalized + rest, rest };
|
|
197
|
+
} catch {
|
|
198
|
+
const parent = dirname(probe);
|
|
199
|
+
if (parent === probe) return { real: null, key, rest: key };
|
|
200
|
+
probe = parent;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const SEVERITY = { allow: 0, pass: 1, approve: 2, deny: 3 };
|
|
206
|
+
|
|
207
|
+
/** 词法与 FS 两层取更严(更严者胜);FS 不可用则用词法。 */
|
|
208
|
+
export function decidePathSecure(input) {
|
|
209
|
+
const lexical = decidePath(input);
|
|
210
|
+
const resolved = realpathPrefix(input.target, { platform: input.platform ?? process.platform });
|
|
211
|
+
if (resolved === null || resolved.real === null || resolved.key === canonicalKey(input.target, { platform: input.platform ?? process.platform })) return lexical;
|
|
212
|
+
const real = decidePath({ ...input, target: resolved.key });
|
|
213
|
+
return SEVERITY[real.decision] > SEVERITY[lexical.decision] ? real : lexical;
|
|
214
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最终机器选择策略(纯函数,独立可测;尚未接线到 dispatch/tools)。
|
|
3
|
+
*
|
|
4
|
+
* 优先级:指令显式点名 > 输入框选中 > 默认本机
|
|
5
|
+
* 规则:
|
|
6
|
+
* · 点名机器不存在/离线 → MACHINE_UNAVAILABLE,绝不换机;
|
|
7
|
+
* · 选中机器失效(离线/退编队)→ 自动回默认本机,并返回 SELECTION_FALLBACK_LOCAL;
|
|
8
|
+
* · 只有 online && inFleet 的机器可被选中(assertSelectable);
|
|
9
|
+
* · 选中“全部机器/联合开发” → fleet 模式(显式),不是本机。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TARGET_SOURCES = Object.freeze(['explicit', 'selected', 'local', 'fleet']);
|
|
13
|
+
|
|
14
|
+
function isUsable(machine) {
|
|
15
|
+
return machine !== null && typeof machine === 'object'
|
|
16
|
+
&& typeof machine.id === 'string' && machine.id !== ''
|
|
17
|
+
&& machine.online === true && machine.inFleet === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 可选择性校验:离线/不在编队的机器不能被写入选择。 */
|
|
21
|
+
export function assertSelectable(machine) {
|
|
22
|
+
if (machine === null || typeof machine !== 'object') {
|
|
23
|
+
return { ok: false, code: 'SELECTION_INVALID', detail: { reason: 'not-an-object' } };
|
|
24
|
+
}
|
|
25
|
+
if (typeof machine.id !== 'string' || machine.id === '') {
|
|
26
|
+
return { ok: false, code: 'SELECTION_INVALID', detail: { reason: 'missing-id' } };
|
|
27
|
+
}
|
|
28
|
+
if (machine.online !== true) {
|
|
29
|
+
return { ok: false, code: 'SELECTION_MACHINE_OFFLINE', detail: { machine: machine.id } };
|
|
30
|
+
}
|
|
31
|
+
if (machine.inFleet !== true) {
|
|
32
|
+
return { ok: false, code: 'SELECTION_MACHINE_NOT_IN_FLEET', detail: { machine: machine.id } };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析本次调用的目标。
|
|
39
|
+
* @param {{explicitMachine?: {id,name?,online?,inFleet?}|string|null,
|
|
40
|
+
* selected?: {mode?: 'machine'|'fleet', machine?: object|null}|null,
|
|
41
|
+
* localMachine: {id,name?},
|
|
42
|
+
* fleetMachines?: Array<object>}} input
|
|
43
|
+
* @returns {{ok: true, source: string, machine: object|null, warning?: string}
|
|
44
|
+
* | {ok: false, code: string, detail: object}}
|
|
45
|
+
*/
|
|
46
|
+
export function resolveTarget(input) {
|
|
47
|
+
const local = input?.localMachine;
|
|
48
|
+
if (local === null || typeof local !== 'object' || typeof local.id !== 'string' || local.id === '') {
|
|
49
|
+
return { ok: false, code: 'LOCAL_MACHINE_UNKNOWN', detail: {} };
|
|
50
|
+
}
|
|
51
|
+
const fleet = Array.isArray(input?.fleetMachines) ? input.fleetMachines : [];
|
|
52
|
+
const find = (id) => fleet.find((machine) => machine.id === id) ?? null;
|
|
53
|
+
|
|
54
|
+
// 1) 显式点名:最高优先;找不到/离线即拒绝,不换机。
|
|
55
|
+
const explicit = input?.explicitMachine;
|
|
56
|
+
if (explicit !== undefined && explicit !== null && explicit !== '') {
|
|
57
|
+
const id = typeof explicit === 'string' ? explicit : explicit.id;
|
|
58
|
+
if (typeof id !== 'string' || id === '') {
|
|
59
|
+
return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { reason: 'missing-id' } };
|
|
60
|
+
}
|
|
61
|
+
if (id === local.id) return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
62
|
+
const named = find(id);
|
|
63
|
+
if (named === null) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'not-in-fleet' } };
|
|
64
|
+
if (named.online !== true) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'offline' } };
|
|
65
|
+
if (named.inFleet !== true) return { ok: false, code: 'MACHINE_UNAVAILABLE', detail: { machine: id, reason: 'not-in-fleet' } };
|
|
66
|
+
return { ok: true, source: 'explicit', machine: named };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2) 选中项:fleet 模式 → 整编队;machine 模式 → 该机;失效 → TARGET_UNAVAILABLE(不换机)。
|
|
70
|
+
const selected = input?.selected ?? null;
|
|
71
|
+
if (selected !== null && selected.mode === 'fleet') {
|
|
72
|
+
return { ok: true, source: 'fleet', machine: null };
|
|
73
|
+
}
|
|
74
|
+
if (selected !== null && selected.machine !== undefined && selected.machine !== null) {
|
|
75
|
+
const picked = selected.machine;
|
|
76
|
+
if (picked.id === local.id) return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
77
|
+
const current = typeof picked.id === 'string' ? find(picked.id) : null;
|
|
78
|
+
if (isUsable(current)) {
|
|
79
|
+
return { ok: true, source: 'selected', machine: current };
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, code: 'TARGET_UNAVAILABLE', detail: { machine: picked.id ?? null, reason: 'selection-invalid' } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3) 未选中:指令显式要求“全队/整编队/所有机器” → fleet;否则默认本机。
|
|
85
|
+
const phrase = typeof input?.promptText === 'string' ? input.promptText : '';
|
|
86
|
+
if (/(全队|整编队|所有机器|全部机器)/.test(phrase)) {
|
|
87
|
+
return { ok: true, source: 'fleet', machine: null };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, source: 'local', machine: { id: local.id, name: local.name ?? null } };
|
|
90
|
+
}
|