@entrorove/skill 0.1.37 → 0.1.39
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/CAPABILITY-MAP.md +11 -0
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +97 -0
- package/REUSE-DISCIPLINE.md +18 -0
- package/SKILL.md +23 -92
- package/cli.mjs +15 -11
- package/cloud-task-client.mjs +3 -1
- package/ide-code-mode.mjs +38 -0
- package/ide-command.mjs +76 -0
- package/ide-hook.mjs +183 -0
- package/ide-install.mjs +60 -0
- package/ide-presentation.mjs +6 -0
- package/ide-queue.mjs +108 -0
- package/ide-results.mjs +59 -0
- package/ide-session.mjs +86 -0
- package/local-approved-plan.mjs +21 -6
- package/local-archguard-ast.mjs +6 -1
- package/local-archguard-language-ast.mjs +7 -0
- package/local-cli.mjs +6 -1
- package/local-contracts.mjs +4 -1
- package/local-discipline-actions.mjs +71 -0
- package/local-discipline-analysis.mjs +153 -0
- package/local-discipline-batch.mjs +32 -0
- package/local-discipline-contracts.d.mts +6 -0
- package/local-discipline-contracts.mjs +62 -0
- package/local-discipline-gate.mjs +85 -0
- package/local-discipline-native.mjs +33 -0
- package/local-discipline-operations.mjs +39 -0
- package/local-discipline-repository.mjs +87 -0
- package/local-discipline-snapshot.mjs +29 -0
- package/local-equivalence-runner.mjs +33 -0
- package/local-error.mjs +10 -0
- package/local-execution.mjs +1 -0
- package/local-integrity.d.mts +5 -0
- package/local-integrity.mjs +4 -0
- package/local-merge-controller.mjs +1 -0
- package/local-merge-rollback.mjs +2 -1
- package/local-mutation-authority.mjs +16 -7
- package/local-mutation-batch.mjs +7 -3
- package/local-mutation-files.mjs +22 -0
- package/local-mutation-snapshot.mjs +6 -3
- package/local-mutation-wire.mjs +6 -1
- package/local-runner-discipline.mjs +23 -0
- package/local-scope-repository.mjs +16 -0
- package/local-task-handoff-write-receipts.mjs +11 -0
- package/manifest.json +1 -1
- package/package.json +34 -4
- package/release-state.json +56 -28
- package/release.mjs +3 -1
- package/releases.json +12 -0
- package/remote-client.mjs +8 -0
- package/reuse-discipline-release.mjs +18 -0
- package/runtime/validator-runtime-v7.0.44.d.mts +3 -0
- package/runtime/validator-runtime-v7.0.44.mjs +1362 -0
- package/snapshot-cleanup-journal.mjs +32 -0
- package/snapshot-retention.mjs +95 -0
- package/task-entry-client.mjs +2 -2
- package/validator-runtime-build.mjs +16 -0
package/ide-hook.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest } from './ide-session.mjs';
|
|
5
|
+
import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand } from './ide-command.mjs';
|
|
6
|
+
import { inspectIdeCodeMode } from './ide-code-mode.mjs';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
|
|
10
|
+
import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
|
|
11
|
+
advanceIdeQueue, checkQueuedStart, unfinishedIdeQueue } from './ide-queue.mjs';
|
|
12
|
+
|
|
13
|
+
const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
|
|
14
|
+
const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
|
|
15
|
+
const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
|
|
16
|
+
const readTools = new Set(['wait', 'view_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource', 'web_search', 'update_plan', 'request_user_input', 'request_user_input_async']);
|
|
17
|
+
function collectResult(state, values, local) {
|
|
18
|
+
const messages = [];
|
|
19
|
+
for (const result of values) {
|
|
20
|
+
const value = result.output ?? result;
|
|
21
|
+
if (typeof value.requestId === 'string' && /^[a-f0-9]{64}$/u.test(value.requestId)) state.requestId = value.requestId;
|
|
22
|
+
if (!local && value.flowId && value.requestId) state.remoteRequestId = value.requestId;
|
|
23
|
+
if (publicId(value.flowId)) {
|
|
24
|
+
if (state.flowId && state.flowId !== value.flowId) throw new Error('Returned flow replaced the original task');
|
|
25
|
+
state.flowId = value.flowId;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value.taskNumber === 'string' && /^[0-9]{5}$/u.test(value.taskNumber)) {
|
|
28
|
+
if (state.taskId && state.taskId !== value.taskId) throw new Error('Returned cloud task replaced the original task');
|
|
29
|
+
state.taskId = value.taskId; state.taskNumber = value.taskNumber;
|
|
30
|
+
}
|
|
31
|
+
const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
|
|
32
|
+
if (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction) state.phase = 'needs-input';
|
|
33
|
+
else if (['blocked', 'failed', 'incomplete'].includes(value.status) || ['blocked', 'failed', 'incomplete'].includes(value.result?.status)
|
|
34
|
+
|| ['blocked', 'failed'].includes(value.taskStatus)) state.phase = 'blocked';
|
|
35
|
+
else if (nextStep || value.nextAction) state.phase = 'continuing';
|
|
36
|
+
else if (!local && (value.reports?.length || value.result?.reports?.length || ['completed','cancelled'].includes(value.taskStatus))
|
|
37
|
+
&& result.feedback?.acknowledgement?.stored === true) state.phase = 'ended';
|
|
38
|
+
else if (local && value.status === 'verified') state.phase = 'continuing';
|
|
39
|
+
else if (value.status === 'started' || value.status === 'succeeded') state.phase = 'continuing';
|
|
40
|
+
if (nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
|
|
41
|
+
const notices = [...(result.ideMessages ?? []), ...(value.ideMessages ?? []),
|
|
42
|
+
...(value.schemaVersion === 'entrorove.ide-event/1.0' ? [value.message] : []), ...(value.events ?? []).map(item => item.message)];
|
|
43
|
+
for (const message of notices) {
|
|
44
|
+
if (typeof message !== 'string' || !message || message.length > 12000) continue;
|
|
45
|
+
const key = ideDigest({ taskId: state.taskId, message, receiptId: result.feedback?.receiptId });
|
|
46
|
+
if (!state.seen.includes(key)) { state.seen.push(key); messages.push(message); }
|
|
47
|
+
}
|
|
48
|
+
if (result.feedback?.acknowledgement?.stored === true) state.feedbackAcknowledged = true;
|
|
49
|
+
}
|
|
50
|
+
state.seen = state.seen.slice(-200);
|
|
51
|
+
return messages;
|
|
52
|
+
}
|
|
53
|
+
async function beforeTool(event, state, home) {
|
|
54
|
+
const command = event.tool_input?.command ?? event.tool_input?.cmd, parsed = literalCommand(command);
|
|
55
|
+
const isShell = ['Bash', 'exec_command', 'shell_command'].includes(event.tool_name);
|
|
56
|
+
const installer = isShell && installerCommand(parsed);
|
|
57
|
+
if (installer) {
|
|
58
|
+
if (state.active) {
|
|
59
|
+
state.readCalls ??= {};
|
|
60
|
+
state.readCalls[event.tool_use_id] = { toolUseId: event.tool_use_id, command: 'install' };
|
|
61
|
+
}
|
|
62
|
+
return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input, command: installer,
|
|
63
|
+
...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: installer } : {}) } });
|
|
64
|
+
}
|
|
65
|
+
const namedSdk = parsed?.words.some(word => /^@entrorove\/skill(?:@[^ ]+)?$/u.test(word)
|
|
66
|
+
|| /(?:@entrorove\/skill|entrorove-skill)\/cli\.mjs$/u.test(word));
|
|
67
|
+
if (!state.active && !namedSdk) return {};
|
|
68
|
+
const runtime = isShell && parsed ? await ideRuntime(home) : null;
|
|
69
|
+
const call = runtime && sdkCommand(parsed, runtime);
|
|
70
|
+
if (call) {
|
|
71
|
+
state.active = true;
|
|
72
|
+
if (call.args[0] !== 'configure') await bindIdeAccount(state, home);
|
|
73
|
+
const newStart = call.args[0] === 'cloud-task' || call.args[0] === 'invoke' && ['start','cloud-task'].includes(call.input?.operation);
|
|
74
|
+
if (newStart && (state.flowId || state.taskId) && state.phase !== 'ended') return deny('Entrorove:必须继续原任务,禁止用第二次 start 或 cloud-task 绕过原门禁。');
|
|
75
|
+
const discovery = ['invoke','local'].includes(call.args[0]) && ['capabilities','help'].includes(call.input?.operation ?? call.args[1]);
|
|
76
|
+
const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue'].includes(call.args[0]);
|
|
77
|
+
if (unclassifiedIdeRequirements(state) && !observer) return deny('Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
|
|
78
|
+
if (newStart && !checkQueuedStart(state,call)) return deny('Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
|
|
79
|
+
if (call.args[0] === 'local' && !discovery && !state.flowId) return deny('Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
|
|
80
|
+
if (state.pendingCall && !observer) return deny('Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
|
|
81
|
+
const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
|
|
82
|
+
if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return deny('Entrorove:调用与本 IDE 任务的原编号不一致。');
|
|
83
|
+
if (call.args[0] === 'scope-connect' && call.args[1] !== state.repositoryRoot
|
|
84
|
+
|| call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return deny('Entrorove:调用不能替换本任务的实际仓库。');
|
|
85
|
+
let requestId;
|
|
86
|
+
if (newStart) state.taskGoal = call.args[0] === 'cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
|
|
87
|
+
if (call.args[0] === 'invoke' && !call.input.operation.startsWith('cloud-')) {
|
|
88
|
+
const { buildInvocation } = await import(pathToFileURL(path.join(path.dirname(runtime.cli), 'remote-client.mjs')).href);
|
|
89
|
+
requestId = buildInvocation(call.input.operation, call.input.input).requestId;
|
|
90
|
+
}
|
|
91
|
+
const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0], ...(requestId ? { requestId } : {}) };
|
|
92
|
+
if (observer) {
|
|
93
|
+
state.readCalls ??= {};
|
|
94
|
+
if (Object.keys(state.readCalls).length >= 32) return deny('Entrorove:只读恢复尚未返回,请等待原调用。');
|
|
95
|
+
state.readCalls[event.tool_use_id] = pending;
|
|
96
|
+
} else state.pendingCall = pending;
|
|
97
|
+
const installation = call.args[0] === 'install' && parsed.words.some(word => ['npx','npx.cmd'].includes(word));
|
|
98
|
+
const rewritten = renderSdkCommand(call, runtime, state.sessionId);
|
|
99
|
+
return output('PreToolUse', { permissionDecision: 'allow', ...(installation ? {} : { updatedInput: { ...event.tool_input, command: rewritten,
|
|
100
|
+
...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: rewritten } : {}) } }), additionalContext: ideContext(state) });
|
|
101
|
+
}
|
|
102
|
+
if (!state.active) return {};
|
|
103
|
+
if (['exec','functions.exec'].includes(event.tool_name)) {
|
|
104
|
+
const current = await ideRuntime(home), code = typeof event.tool_input === 'string' ? event.tool_input : event.tool_input?.code;
|
|
105
|
+
return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : deny('Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
|
|
106
|
+
}
|
|
107
|
+
if (isShell && readCommand(parsed) || readTools.has(event.tool_name)) return output('PreToolUse', { additionalContext: ideContext(state) });
|
|
108
|
+
return deny(`Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划;门禁未通过不得改换执行器。`);
|
|
109
|
+
}
|
|
110
|
+
export async function runIdeHook(event, { home } = {}) {
|
|
111
|
+
return ideSession(event, async state => {
|
|
112
|
+
const name = event.hook_event_name;
|
|
113
|
+
if (name === 'UserPromptSubmit' && /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu.test((event.prompt ?? '').trim())) {
|
|
114
|
+
state.active = false; state.phase = 'user-exited';
|
|
115
|
+
return output(name, { additionalContext: '用户明确退出 Entrorove 管理;原任务仍保留,未验收、未评分为成功。' });
|
|
116
|
+
}
|
|
117
|
+
if (name === 'UserPromptSubmit' && /\bentrorove\b/iu.test(event.prompt ?? '')) {
|
|
118
|
+
state.active = true;
|
|
119
|
+
if (state.phase === 'ended') { delete state.flowId; delete state.taskId; delete state.taskNumber; delete state.requestId; state.phase = 'entry'; }
|
|
120
|
+
}
|
|
121
|
+
if (name === 'UserPromptSubmit') registerIdeRequirement(state,event);
|
|
122
|
+
if (name === 'PreToolUse') return beforeTool(event, state, home);
|
|
123
|
+
const pendingRead = state.readCalls?.[event.tool_use_id];
|
|
124
|
+
if (name === 'PostToolUse' && state.active && (state.pendingCall?.toolUseId === event.tool_use_id || pendingRead)) {
|
|
125
|
+
const pending = pendingRead ?? state.pendingCall;
|
|
126
|
+
const recorded = await consumeIdeResults(event, state, pending, home);
|
|
127
|
+
const visible = parseIdeResults(event.tool_response);
|
|
128
|
+
const values = [...recorded, ...visible];
|
|
129
|
+
if (pending.command==='queue') {
|
|
130
|
+
delete state.readCalls[event.tool_use_id];
|
|
131
|
+
return output(name,{additionalContext:`${ideContext(state)}${ideQueueContext(state)}`});
|
|
132
|
+
}
|
|
133
|
+
if (['install','configure','agreement'].includes(pending.command)) {
|
|
134
|
+
delete state.readCalls[event.tool_use_id];
|
|
135
|
+
const configured = values.some(value => pending.command === 'install' ? /^\d+\.\d+\.\d+$/u.test(value.version ?? '') && value.ide?.configured === true
|
|
136
|
+
: pending.command === 'configure' ? value.configured === true : value.accepted === true);
|
|
137
|
+
if (!state.flowId && !state.taskId && !state.pendingCall && configured) state.phase = 'setup';
|
|
138
|
+
return output(name, { additionalContext: ideContext(state) });
|
|
139
|
+
}
|
|
140
|
+
if (!values.length) {
|
|
141
|
+
if (pendingRead) { delete state.readCalls[event.tool_use_id]; return output(name, { additionalContext: ideContext(state) }); }
|
|
142
|
+
state.phase = 'unknown';
|
|
143
|
+
if (pending.requestId) state.requestId = pending.requestId;
|
|
144
|
+
return { ...output(name, { additionalContext: `${ideContext(state)}原请求结果未知,只能 GET 恢复其 requestId,禁止再次 POST。` }), systemMessage: 'Entrorove 原调用尚无可验证结果,已保留待恢复记录。' };
|
|
145
|
+
}
|
|
146
|
+
const expectedRequest = state.pendingCall?.requestId ?? state.remoteRequestId ?? state.requestId;
|
|
147
|
+
const bound = pendingRead ? values.filter(result => {
|
|
148
|
+
const value = result.output ?? result;
|
|
149
|
+
return value.requestId === expectedRequest || state.taskId && value.taskId === state.taskId;
|
|
150
|
+
}) : values;
|
|
151
|
+
const recoveredOriginal = pendingRead && state.pendingCall?.requestId
|
|
152
|
+
&& values.some(result => (result.output ?? result).requestId === state.pendingCall.requestId);
|
|
153
|
+
const messages = collectResult(state, bound, pending.command === 'local');
|
|
154
|
+
if (pendingRead) delete state.readCalls[event.tool_use_id];
|
|
155
|
+
if (!pendingRead || recoveredOriginal) delete state.pendingCall;
|
|
156
|
+
const resumed = await advanceIdeQueue(state,home);
|
|
157
|
+
if (resumed) messages.push(resumed);
|
|
158
|
+
return { ...output(name, { additionalContext: ideContext(state) }), ...(messages.length ? { systemMessage: messages.join('\n') } : {}) };
|
|
159
|
+
}
|
|
160
|
+
if (name==='Stop' && state.active) {
|
|
161
|
+
const resumed = await advanceIdeQueue(state,home);
|
|
162
|
+
if (resumed || unclassifiedIdeRequirements(state) || unfinishedIdeQueue(state) && !['blocked','needs-input'].includes(state.phase)
|
|
163
|
+
|| !['ended','blocked','needs-input','setup'].includes(state.phase)) return {decision:'block',reason:`${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`};
|
|
164
|
+
}
|
|
165
|
+
return state.active && ['SessionStart','UserPromptSubmit','PostCompact','PreCompact'].includes(name) ? output(name, { additionalContext: `${ideContext(state)}${ideQueueContext(state)}` }) : {};
|
|
166
|
+
}, home);
|
|
167
|
+
}
|
|
168
|
+
let incomingEvent;
|
|
169
|
+
async function main() {
|
|
170
|
+
let raw = '';
|
|
171
|
+
for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 1_000_000) throw new Error('Host hook input is too large'); }
|
|
172
|
+
const event = JSON.parse(raw); incomingEvent = event.hook_event_name;
|
|
173
|
+
const result = await runIdeHook(event);
|
|
174
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
175
|
+
}
|
|
176
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {
|
|
177
|
+
// A non-zero generic exit may fail open in the host. Always return a supported
|
|
178
|
+
// explicit denial, without including arguments, prompts, credentials or paths.
|
|
179
|
+
const reason = 'Entrorove 安全检查失败;本次工具调用已阻止,请修复原检查。';
|
|
180
|
+
const result = incomingEvent === 'Stop' ? { decision: 'block', reason } : incomingEvent === 'PreToolUse' ? deny(reason)
|
|
181
|
+
: { continue: false, stopReason: reason, systemMessage: reason };
|
|
182
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
183
|
+
});
|
package/ide-install.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './ide-session.mjs';
|
|
5
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
6
|
+
|
|
7
|
+
const source = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const files = ['ide-hook.mjs', 'ide-session.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs'];
|
|
9
|
+
const events = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop'];
|
|
10
|
+
const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
|
|
11
|
+
function definition(node, file) {
|
|
12
|
+
const command = `${quote(node)} ${quote(file)}`;
|
|
13
|
+
return { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [{ type: 'command', command,
|
|
14
|
+
timeout: 10, statusMessage: 'Entrorove · 检查任务',
|
|
15
|
+
...(['PostCompact','Stop'].includes(event) ? {} : {additionalContextLimit:1500}) }] }]])) };
|
|
16
|
+
}
|
|
17
|
+
async function prepareConfigDirectory(home) {
|
|
18
|
+
const directory = path.join(home, '.codex');
|
|
19
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
const stat = await fs.lstat(directory);
|
|
21
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) throw ideError('ENTROROVE_IDE_CONFIG', 'Codex config must be an owned real directory');
|
|
22
|
+
return directory;
|
|
23
|
+
}
|
|
24
|
+
function mergeHooks(existing, previous, current) {
|
|
25
|
+
if (existing && (typeof existing !== 'object' || Array.isArray(existing) || existing.hooks && typeof existing.hooks !== 'object')) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hooks config is invalid; it was preserved');
|
|
26
|
+
const next = { ...existing, hooks: { ...existing?.hooks } };
|
|
27
|
+
for (const event of events) {
|
|
28
|
+
const old = previous?.hooks?.[event]?.[0], entries = next.hooks[event] ?? [];
|
|
29
|
+
if (!Array.isArray(entries)) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hook list is invalid; it was preserved');
|
|
30
|
+
const ours = entries.filter(entry => old && JSON.stringify(entry) === JSON.stringify(old));
|
|
31
|
+
if (previous && !ours.length && entries.some(entry => entry.hooks?.some(hook => hook.command === old?.hooks?.[0]?.command))) throw ideError('ENTROROVE_IDE_CONFIG_EDITED', 'A hand-edited Entrorove hook was preserved; review it before updating');
|
|
32
|
+
next.hooks[event] = [...entries.filter(entry => !ours.includes(entry)), current.hooks[event][0]];
|
|
33
|
+
}
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
export async function installIdeHooks({ home, version, packageDirectory = source, node = process.execPath }) {
|
|
37
|
+
const directory = await ideDirectory(home, true), configDirectory = await prepareConfigDirectory(home);
|
|
38
|
+
const hooksPath = path.join(configDirectory, 'hooks.json'), recordPath = path.join(directory, 'installation.json');
|
|
39
|
+
return withStateLock(hooksPath, async () => {
|
|
40
|
+
const existing = await readIdeJson(hooksPath), previous = await readIdeJson(recordPath);
|
|
41
|
+
const current = definition(node, path.join(directory, 'ide-hook.mjs'));
|
|
42
|
+
const config = mergeHooks(existing, previous?.definition, current);
|
|
43
|
+
const contents = await Promise.all(files.map(async name => [name, await fs.readFile(path.join(source, name))]));
|
|
44
|
+
for (const [name, bytes] of contents) {
|
|
45
|
+
const target = path.join(directory, name), stat = await fs.lstat(target).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
|
|
46
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)) throw ideError('ENTROROVE_IDE_STORE', 'Managed hook target must be a private regular file');
|
|
47
|
+
if (stat && previous?.files?.[name] && ideDigest((await fs.readFile(target)).toString('utf8')) !== previous.files[name]) throw ideError('ENTROROVE_IDE_HOOK_EDITED', 'Hand-edited hook code was preserved');
|
|
48
|
+
await fs.writeFile(target, bytes, { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
const cli = path.join(packageDirectory, 'cli.mjs');
|
|
51
|
+
await writeIdeJson(path.join(directory, 'runtime.json'), { schemaVersion: 'entrorove.ide-runtime/1.0', version, node, cli,
|
|
52
|
+
apiOrigin: 'https://entrorove.com',
|
|
53
|
+
cliDigest: ideDigest((await fs.readFile(cli)).toString('utf8')) });
|
|
54
|
+
if (JSON.stringify(existing) !== JSON.stringify(config)) await writeIdeJson(hooksPath, config);
|
|
55
|
+
await writeIdeJson(recordPath, { schemaVersion: 'entrorove.ide-installation/1.0', version, definition: current,
|
|
56
|
+
files: Object.fromEntries(contents.map(([name, bytes]) => [name, ideDigest(bytes.toString('utf8'))])) });
|
|
57
|
+
return { host: 'Codex', configured: true, enforcement: 'review-required', hooksPath,
|
|
58
|
+
message: '已安装持续执行检查。请在 Codex /hooks 审查并信任 Entrorove;实际收到 PreToolUse 回调后才算拦截已生效。' };
|
|
59
|
+
});
|
|
60
|
+
}
|
package/ide-presentation.mjs
CHANGED
|
@@ -7,6 +7,12 @@ export function ideClientHeaders(environment = process.env) {
|
|
|
7
7
|
if (typeof ide !== 'string' || !/^[\p{L}\p{N} .+_-]{2,48}$/u.test(ide)) throw new Error('Invalid ENTROROVE_IDE client name');
|
|
8
8
|
return { 'x-entrorove-ide': ide };
|
|
9
9
|
}
|
|
10
|
+
export function ideTaskIdentity(supplied, environment = process.env) {
|
|
11
|
+
if (environment.CODEX_THREAD_ID && supplied && supplied !== environment.CODEX_THREAD_ID) {
|
|
12
|
+
throw new Error('Cloud task must retain the actual Codex task identity');
|
|
13
|
+
}
|
|
14
|
+
return environment.CODEX_THREAD_ID ?? supplied;
|
|
15
|
+
}
|
|
10
16
|
export function displayIdeEvent(event, sink, notifications) {
|
|
11
17
|
if (sink) sink(event);
|
|
12
18
|
else process.stderr.write(`${event.message}\n${event.detail ? event.detail + '\n' : ''}`);
|
package/ide-queue.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ideDigest, ideError, ideRuntime, ideSession } from './ide-session.mjs';
|
|
4
|
+
|
|
5
|
+
const fields = ['flowId','taskId','taskNumber','requestId','remoteRequestId','phase','nextStep','feedbackAcknowledged','taskGoal'];
|
|
6
|
+
const control = /^(?:继续(?:执行)?[.!。!]?|已信任|已验证|验证了|确认|知道了|进度|啥进度了[??]?|状态|continue[.!]?|status[?]?|confirmed|done)$/iu;
|
|
7
|
+
const fail = message => { throw ideError('ENTROROVE_IDE_QUEUE', message); };
|
|
8
|
+
const context = state => Object.fromEntries(fields.filter(key => Object.hasOwn(state,key)).map(key => [key,structuredClone(state[key])]));
|
|
9
|
+
function restore(state, saved) {
|
|
10
|
+
for (const key of fields) delete state[key];
|
|
11
|
+
Object.assign(state,structuredClone(saved));
|
|
12
|
+
}
|
|
13
|
+
function queue(state) {
|
|
14
|
+
state.queue ??= { schemaVersion:'entrorove.ide-queue/1.0', nextSequence:1, current:null, parents:[], entries:[], completed:[] };
|
|
15
|
+
const value = state.queue;
|
|
16
|
+
if (value.schemaVersion !== 'entrorove.ide-queue/1.0' || !Number.isSafeInteger(value.nextSequence)
|
|
17
|
+
|| !Array.isArray(value.parents) || !Array.isArray(value.entries) || !Array.isArray(value.completed)) fail('Original task queue is invalid; it was preserved');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
async function priorities(home) {
|
|
21
|
+
const runtime = await ideRuntime(home);
|
|
22
|
+
return (await import(pathToFileURL(path.join(path.dirname(runtime.cli),'local-swarm-state.mjs')).href)).SWARM_PRIORITIES;
|
|
23
|
+
}
|
|
24
|
+
function ensureCurrent(state) {
|
|
25
|
+
const value = queue(state);
|
|
26
|
+
if (!value.current && (state.flowId || state.taskId)) value.current = { messageId:`original-${ideDigest({flowId:state.flowId,taskId:state.taskId})}`,
|
|
27
|
+
goal:state.taskGoal ?? null, priority:'normal', sequence:0, original:true };
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function registerIdeRequirement(state,event) {
|
|
31
|
+
if (!state.active || !(state.flowId || state.taskId || state.queue?.current)) return;
|
|
32
|
+
const text = (event.prompt ?? '').trim();
|
|
33
|
+
if (!text || control.test(text) || /^<send_user_message_question_reply>/u.test(text)) return;
|
|
34
|
+
const value = ensureCurrent(state);
|
|
35
|
+
const sourceId = event.prompt_id ?? event.user_message_id;
|
|
36
|
+
const messageId = `requirement-${ideDigest({sessionId:state.sessionId,sourceId:sourceId ?? value.nextSequence,text})}`;
|
|
37
|
+
if (value.entries.some(item=>item.messageId===messageId) || value.completed.some(item=>item.messageId===messageId)) return;
|
|
38
|
+
if (text.length>100_000 || value.entries.length>=64) fail('Task queue capacity reached; no new requirement was discarded. Resolve the original queue before admission.');
|
|
39
|
+
value.entries.push({messageId,goal:text,goalDigest:ideDigest(text),sequence:value.nextSequence++,status:'unclassified',priority:'normal'});
|
|
40
|
+
}
|
|
41
|
+
export function ideQueueContext(state) {
|
|
42
|
+
const value = state.queue;
|
|
43
|
+
if (!value) return '';
|
|
44
|
+
const pending = value.entries.filter(item=>item.status!=='completed');
|
|
45
|
+
return `${value.parents.length ? `有 ${value.parents.length} 个原任务等待恢复。` : ''}${pending.length ? `尚有 ${pending.length} 条插入需求;先用 queue status 读取,再用 queue route 分类,不能忽略或提前结束。` : ''}`;
|
|
46
|
+
}
|
|
47
|
+
export function unclassifiedIdeRequirements(state) { return state.queue?.entries.some(item=>item.status==='unclassified') ?? false; }
|
|
48
|
+
function activate(state,entry,pause) {
|
|
49
|
+
const value = ensureCurrent(state);
|
|
50
|
+
if (state.pendingCall) fail('An unresolved original call cannot be interrupted; recover it with GET first');
|
|
51
|
+
if (pause && value.current) value.parents.push({item:structuredClone(value.current),context:context(state)});
|
|
52
|
+
value.entries = value.entries.filter(item=>item.messageId!==entry.messageId);
|
|
53
|
+
value.current = {...entry,status:'active'};
|
|
54
|
+
restore(state,{phase:'entry',taskGoal:entry.goal,feedbackAcknowledged:false});
|
|
55
|
+
}
|
|
56
|
+
function sorted(value,ranks) {
|
|
57
|
+
return value.entries.filter(item=>item.status==='queued').sort((a,b)=>ranks.indexOf(b.priority)-ranks.indexOf(a.priority)||a.sequence-b.sequence);
|
|
58
|
+
}
|
|
59
|
+
export async function advanceIdeQueue(state,home) {
|
|
60
|
+
const value = ensureCurrent(state), ranks = await priorities(home), candidate = sorted(value,ranks)[0];
|
|
61
|
+
if (state.phase==='ended') {
|
|
62
|
+
if (value.current) value.completed.push({...value.current,context:context(state),completedAt:new Date().toISOString()});
|
|
63
|
+
value.current = null;
|
|
64
|
+
const parent = value.parents.at(-1);
|
|
65
|
+
if (candidate && (!parent || ranks.indexOf(candidate.priority)>ranks.indexOf(parent.item.priority))) {
|
|
66
|
+
activate(state,candidate,false);
|
|
67
|
+
return `已切换队列任务 ${candidate.messageId};必须按原需求进入受控入口。`;
|
|
68
|
+
}
|
|
69
|
+
if (parent) {
|
|
70
|
+
value.parents.pop(); value.current = parent.item; restore(state,parent.context);
|
|
71
|
+
return `插入任务已结束,自动恢复原任务${state.taskNumber ? ` ${state.taskNumber}` : ''}${state.flowId ? `,flowId=${state.flowId}` : ''}。继续原 nextStep,重新验证实际快照和权限,不能重建流程。`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!state.pendingCall && !unclassifiedIdeRequirements(state) && candidate
|
|
75
|
+
&& (state.phase==='ended' || state.phase==='setup' || candidate.mode==='interrupt'
|
|
76
|
+
&& ['continuing','blocked','needs-input'].includes(state.phase))) {
|
|
77
|
+
activate(state,candidate,state.phase!=='ended' && state.phase!=='setup');
|
|
78
|
+
return `已在受控调用结束的检查点插入需求 ${candidate.messageId};完成后必须恢复原任务。`;
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
export function checkQueuedStart(state,call) {
|
|
83
|
+
const item = state.queue?.current;
|
|
84
|
+
if (!item || item.original || item.status!=='active') return true;
|
|
85
|
+
const goal = call.args[0]==='cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
|
|
86
|
+
return typeof goal==='string' && ideDigest(goal)===item.goalDigest;
|
|
87
|
+
}
|
|
88
|
+
export function unfinishedIdeQueue(state) {
|
|
89
|
+
return Boolean(state.queue?.parents.length || state.queue?.entries.length || state.queue?.current && state.phase!=='ended');
|
|
90
|
+
}
|
|
91
|
+
export async function runIdeQueue(input,{environment=process.env,home,cwd=process.cwd()}={}) {
|
|
92
|
+
if (environment.ENTROROVE_IDE!=='Codex' || !environment.CODEX_THREAD_ID) fail('Task queue requires its actual guarded Codex session');
|
|
93
|
+
return ideSession({session_id:environment.CODEX_THREAD_ID,cwd},async state=>{
|
|
94
|
+
const value = ensureCurrent(state);
|
|
95
|
+
if (!state.active) fail('This session has not selected Entrorove');
|
|
96
|
+
if (input.action==='route') {
|
|
97
|
+
const entry = value.entries.find(item=>item.messageId===input.messageId);
|
|
98
|
+
if (!entry || entry.status!=='unclassified' || entry.goalDigest!==ideDigest(entry.goal)) fail('Only an actual unclassified user requirement may enter the queue');
|
|
99
|
+
if (!['queued','interrupt'].includes(input.mode) || !(await priorities(home)).includes(input.priority)) fail('Use a declared queue mode and the existing priority contract');
|
|
100
|
+
entry.mode=input.mode; entry.priority=input.priority; entry.status='queued';
|
|
101
|
+
entry.routedAt=new Date().toISOString();
|
|
102
|
+
} else if (input.action!=='status') fail('Unknown task queue action');
|
|
103
|
+
const notice = await advanceIdeQueue(state,home);
|
|
104
|
+
return {schemaVersion:'entrorove.ide-queue/1.0',sessionId:state.sessionId,current:value.current,parents:value.parents.map(parent=>({item:parent.item,...parent.context})),
|
|
105
|
+
entries:value.entries,completedCount:value.completed.length,...(notice ? {notice} : {}),
|
|
106
|
+
instruction:'Use the current requirement verbatim. Keep the original flow and task IDs, follow the controlled entry, and resume parents only after a verified end acknowledgement.'};
|
|
107
|
+
},home);
|
|
108
|
+
}
|
package/ide-results.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { ideDirectory, ideDigest, ideResultContext, readIdeJson, writeIdeJson } from './ide-session.mjs';
|
|
4
|
+
|
|
5
|
+
export function parseIdeResults(response) {
|
|
6
|
+
const raw = typeof response === 'string' ? response : response?.output ?? response?.stdout ?? response?.aggregated_output
|
|
7
|
+
?? response?.content?.filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
8
|
+
if (typeof raw !== 'string') return [];
|
|
9
|
+
return raw.split(/\r?\n/u).flatMap(line => {
|
|
10
|
+
try { const value = JSON.parse(line); return value && typeof value === 'object' ? [value] : []; }
|
|
11
|
+
catch { return []; }
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function resultMetadata(value, depth = 0) {
|
|
15
|
+
if (!value || typeof value !== 'object' || depth > 3) return undefined;
|
|
16
|
+
const metadata = {};
|
|
17
|
+
for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'taskId', 'taskNumber', 'taskStatus']) {
|
|
18
|
+
if (typeof value[key] === 'string' && value[key].length <= 200) metadata[key] = value[key];
|
|
19
|
+
}
|
|
20
|
+
for (const key of ['output', 'result']) if (value[key]) metadata[key] = resultMetadata(value[key], depth + 1);
|
|
21
|
+
for (const key of ['nextStep', 'nextAction']) if (typeof value[key]?.operation === 'string') metadata[key] = { operation: value[key].operation };
|
|
22
|
+
if (value.interaction) metadata.interaction = true;
|
|
23
|
+
if (Array.isArray(value.reports)) metadata.reports = value.reports.map(report => ({ verdict: report.verdict }));
|
|
24
|
+
if (value.feedback?.acknowledgement?.stored === true) metadata.feedback = { receiptId: value.feedback.receiptId, acknowledgement: { stored: true } };
|
|
25
|
+
return metadata;
|
|
26
|
+
}
|
|
27
|
+
const resultFile = (directory, sessionId, toolUseId) => path.join(directory, `result-${ideDigest({ sessionId, toolUseId })}.json`);
|
|
28
|
+
export async function persistIdeResults(context, values) {
|
|
29
|
+
if (!context || !values.length) return;
|
|
30
|
+
const directory = await ideDirectory(context.home);
|
|
31
|
+
await writeIdeJson(resultFile(directory, context.sessionId, context.toolUseId), {
|
|
32
|
+
schemaVersion: 'entrorove.ide-result/1.0', sessionId: context.sessionId, repositoryRoot: context.repositoryRoot,
|
|
33
|
+
toolUseId: context.toolUseId, inputDigest: context.inputDigest, accountDigest: context.accountDigest,
|
|
34
|
+
values: values.map(value => resultMetadata(value)), createdAt: new Date().toISOString()
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function consumeIdeResults(event, state, pending, home) {
|
|
38
|
+
const file = resultFile(await ideDirectory(home), state.sessionId, event.tool_use_id), proof = await readIdeJson(file);
|
|
39
|
+
if (!proof) return [];
|
|
40
|
+
if (proof.schemaVersion !== 'entrorove.ide-result/1.0' || proof.sessionId !== state.sessionId
|
|
41
|
+
|| proof.repositoryRoot !== state.repositoryRoot || proof.toolUseId !== pending.toolUseId
|
|
42
|
+
|| proof.inputDigest !== pending.inputDigest || proof.accountDigest !== state.accountDigest
|
|
43
|
+
|| !Array.isArray(proof.values)) throw new Error('CLI completion does not belong to this approved call');
|
|
44
|
+
await fs.unlink(file);
|
|
45
|
+
return proof.values;
|
|
46
|
+
}
|
|
47
|
+
export function captureIdeResults() {
|
|
48
|
+
const original = process.stdout.write, chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
process.stdout.write = function(chunk, ...rest) {
|
|
51
|
+
size += Buffer.byteLength(chunk);
|
|
52
|
+
if (size <= 8_000_000) chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk);
|
|
53
|
+
return original.call(this, chunk, ...rest);
|
|
54
|
+
};
|
|
55
|
+
return async () => {
|
|
56
|
+
process.stdout.write = original;
|
|
57
|
+
if (size <= 8_000_000) await persistIdeResults(ideResultContext(), parseIdeResults(chunks.join('')));
|
|
58
|
+
};
|
|
59
|
+
}
|
package/ide-session.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { credentialDirectory, readCredential } from './local-credential.mjs';
|
|
6
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
7
|
+
import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
|
|
8
|
+
|
|
9
|
+
export const ideDigest = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
|
|
10
|
+
export const ideError = (code, message) => Object.assign(new Error(message), { code });
|
|
11
|
+
let approvedResultContext;
|
|
12
|
+
export const ideResultContext = () => approvedResultContext;
|
|
13
|
+
export async function ideDirectory(home = os.homedir(), create = false) {
|
|
14
|
+
const parent = await credentialDirectory(home, create), directory = path.join(parent, 'ide');
|
|
15
|
+
if (create) await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
16
|
+
const stat = await fs.lstat(directory);
|
|
17
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) throw ideError('ENTROROVE_IDE_STORE', 'IDE state must be an owned private directory');
|
|
18
|
+
return directory;
|
|
19
|
+
}
|
|
20
|
+
export async function readIdeJson(file) {
|
|
21
|
+
let handle;
|
|
22
|
+
try {
|
|
23
|
+
handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
24
|
+
const stat = await handle.stat();
|
|
25
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1_000_000 || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) throw ideError('ENTROROVE_IDE_STORE', 'IDE state is not a private regular file');
|
|
26
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
27
|
+
} catch (cause) { if (cause.code === 'ENOENT') return null; throw cause; }
|
|
28
|
+
finally { await handle?.close(); }
|
|
29
|
+
}
|
|
30
|
+
export async function writeIdeJson(file, value) {
|
|
31
|
+
const stage = `${file}.${randomUUID()}.tmp`;
|
|
32
|
+
await fs.writeFile(stage, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
|
|
33
|
+
try { await fs.rename(stage, file); } finally { await fs.rm(stage, { force: true }); }
|
|
34
|
+
}
|
|
35
|
+
export async function ideSession(event, action, home = os.homedir()) {
|
|
36
|
+
if (typeof event.session_id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/u.test(event.session_id) || !path.isAbsolute(event.cwd ?? '')) throw ideError('ENTROROVE_IDE_IDENTITY', 'Host hook requires its actual session and repository');
|
|
37
|
+
const directory = await ideDirectory(home, true), file = path.join(directory, `session-${ideDigest(event.session_id)}.json`);
|
|
38
|
+
return withStateLock(file, async () => {
|
|
39
|
+
const root = await fs.realpath(event.cwd), prior = await readIdeJson(file);
|
|
40
|
+
if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id || prior.repositoryRoot !== root)) throw ideError('ENTROROVE_IDE_IDENTITY', 'A session cannot replace its original physical repository');
|
|
41
|
+
const state = prior ?? { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [] };
|
|
42
|
+
const result = await action(state);
|
|
43
|
+
await writeIdeJson(file, { ...state, observedAt: new Date().toISOString() });
|
|
44
|
+
return result;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export async function bindIdeAccount(state, home = os.homedir()) {
|
|
48
|
+
const token = await readCredential(home), digest = ideDigest(token);
|
|
49
|
+
if (state.accountDigest && state.accountDigest !== digest) {
|
|
50
|
+
const runtime = await ideRuntime(home);
|
|
51
|
+
const originalRequest = state.pendingCall?.requestId ?? state.remoteRequestId ?? state.requestId;
|
|
52
|
+
const route = state.taskId ? `/api/cloud-sandbox/tasks/${state.taskId}` : originalRequest ? `/api/skill/status/${originalRequest}` : null;
|
|
53
|
+
if (!route) throw ideError('ENTROROVE_IDE_ACCOUNT', 'Cannot prove the rotated key owns the original task');
|
|
54
|
+
const response = await requestTransport(runtime.apiOrigin ?? 'https://entrorove.com', route,
|
|
55
|
+
{ headers: { authorization: `Bearer ${token}` }, timeoutMs: 5000 });
|
|
56
|
+
const value = parseTransportResponse(response, 'original account binding');
|
|
57
|
+
if (!response.ok || (state.taskId ? value.task?.id !== state.taskId : value.output?.requestId !== originalRequest
|
|
58
|
+
|| state.flowId && value.output?.flowId !== state.flowId)) throw ideError('ENTROROVE_IDE_ACCOUNT', 'Account changed; the original task cannot inherit another account');
|
|
59
|
+
}
|
|
60
|
+
state.accountDigest = digest;
|
|
61
|
+
}
|
|
62
|
+
export async function ideRuntime(home = os.homedir()) {
|
|
63
|
+
const directory = await ideDirectory(home), runtime = await readIdeJson(path.join(directory, 'runtime.json'));
|
|
64
|
+
if (!runtime || runtime.schemaVersion !== 'entrorove.ide-runtime/1.0' || !path.isAbsolute(runtime.node ?? '') || !path.isAbsolute(runtime.cli ?? '') || !/^\d+\.\d+\.\d+$/u.test(runtime.version ?? '')) throw ideError('ENTROROVE_IDE_RUNTIME', 'Install the Entrorove IDE runtime before executing');
|
|
65
|
+
const cli = await fs.readFile(runtime.cli);
|
|
66
|
+
if (ideDigest(cli.toString('utf8')) !== runtime.cliDigest) throw ideError('ENTROROVE_IDE_RUNTIME', 'Installed CLI changed; reinstall the managed runtime');
|
|
67
|
+
return runtime;
|
|
68
|
+
}
|
|
69
|
+
export function ideContext(state) {
|
|
70
|
+
return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
|
|
71
|
+
}
|
|
72
|
+
export async function assertIdeExecution(args, input, { home = os.homedir(), environment = process.env } = {}) {
|
|
73
|
+
const host = environment.ENTROROVE_IDE ?? (environment.CODEX_THREAD_ID ? 'Codex' : environment.CURSOR_TRACE_ID ? 'Cursor' : environment.CLAUDE_CODE_ENTRYPOINT ? 'Claude Code' : null);
|
|
74
|
+
if (!host || host === 'Entrorove CLI') return;
|
|
75
|
+
if (host !== 'Codex' || !environment.CODEX_THREAD_ID) throw ideError('ENTROROVE_IDE_GUARD_UNSUPPORTED', 'This IDE has no verified tool guard adapter. Execution is blocked; never continue with another executor.');
|
|
76
|
+
const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(environment.CODEX_THREAD_ID)}.json`));
|
|
77
|
+
const expected = ideDigest({ args, input });
|
|
78
|
+
const approved = [state?.pendingCall, ...Object.values(state?.readCalls ?? {})].find(call => call?.inputDigest === expected);
|
|
79
|
+
if (!state?.active || !approved || state.sessionId !== environment.CODEX_THREAD_ID
|
|
80
|
+
|| Date.now() - Date.parse(state.observedAt) > 120_000) {
|
|
81
|
+
throw ideError('ENTROROVE_IDE_GUARD_REQUIRED', 'Codex tool guard has not approved this exact call. Run install, review Entrorove in /hooks, and resume the original task.');
|
|
82
|
+
}
|
|
83
|
+
await bindIdeAccount(state, home);
|
|
84
|
+
approvedResultContext = { home, sessionId: state.sessionId, repositoryRoot: state.repositoryRoot,
|
|
85
|
+
accountDigest: state.accountDigest, toolUseId: approved.toolUseId, inputDigest: expected };
|
|
86
|
+
}
|
package/local-approved-plan.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { performance } from 'node:perf_hooks';
|
|
8
8
|
import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
|
|
9
|
+
import { validateRunnerParallelPaths, captureRunnerEquivalence } from './local-equivalence-runner.mjs';
|
|
10
|
+
import { disciplineApprovedPolicy, disciplineDataSnapshotsBound } from './local-discipline-contracts.mjs';
|
|
11
|
+
import { runnerDisciplineBefore, runnerDisciplineAfter } from './local-runner-discipline.mjs';
|
|
9
12
|
|
|
10
13
|
export const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
|
|
11
14
|
export const PLAN_SCHEMA = 'validator.execution-plan/1.0';
|
|
@@ -33,7 +36,15 @@ function validatePlan(plan, artifactDigest = planArtifact) {
|
|
|
33
36
|
if (plan.schemaVersion !== PLAN_SCHEMA || plan.frozen !== true || !ID.test(plan.planId) || !ID.test(plan.validationRunId) || !ID.test(plan.memberId) || !ID.test(plan.chainId) || !SHA256.test(plan.artifactSha256) || !Array.isArray(plan.files) || plan.files.length === 0 || plan.files.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_INVALID', 'plan must be frozen and bounded');
|
|
34
37
|
for (let i = 0; i < plan.files.length; i += 1) { const file = exactObject(plan.files[i], ['path', 'sha256'], 'manifest entry'); safeRelative(file.path); if (!SHA256.test(file.sha256) || (i > 0 && plan.files[i - 1].path >= file.path)) fail('ENTROROVE_PLAN_MANIFEST', 'manifest paths must be sorted and unique'); }
|
|
35
38
|
if (artifactDigest(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
|
|
36
|
-
const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'
|
|
39
|
+
const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode',
|
|
40
|
+
...['parallelPaths', 'discipline'].filter(key => Object.hasOwn(plan.policy, key))], 'execution policy');
|
|
41
|
+
validateRunnerParallelPaths(plan);
|
|
42
|
+
if (policy.discipline) {
|
|
43
|
+
const reviewed = disciplineApprovedPolicy(policy.discipline);
|
|
44
|
+
if (!disciplineDataSnapshotsBound(reviewed, plan.files)) {
|
|
45
|
+
fail('ENTROROVE_DISCIPLINE_DATA_SNAPSHOT_REQUIRED', 'Existing data operations require their actual frozen snapshot artifact before execution');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
37
48
|
if (typeof policy.executable !== 'string' || !path.isAbsolute(policy.executable) || !SHA256.test(policy.executableSha256) || !Array.isArray(policy.args) || policy.args.length > 128 || policy.args.some(value => typeof value !== 'string' || value.includes('\0') || value.length > 4096)) fail('ENTROROVE_POLICY_INVALID', 'executable and args are invalid');
|
|
38
49
|
if (!policy.environment || typeof policy.environment !== 'object' || Array.isArray(policy.environment) || Object.keys(policy.environment).length > 64 || Object.entries(policy.environment).some(([key, value]) => !ENV.test(key) || typeof value !== 'string' || value.includes('\0') || value.length > 16_384)) fail('ENTROROVE_POLICY_ENVIRONMENT', 'environment must be explicit bounded strings');
|
|
39
50
|
boundedInt(policy.timeoutMs, RUNNER_LIMITS.maxTimeoutMs, 'timeoutMs'); boundedInt(policy.maxOutputBytes, RUNNER_LIMITS.maxOutputBytes, 'maxOutputBytes'); if (policy.requiredExitCode !== 0) fail('ENTROROVE_POLICY_EXIT', 'requiredExitCode must be zero');
|
|
@@ -95,13 +106,16 @@ function runCommand(authority) {
|
|
|
95
106
|
});
|
|
96
107
|
});
|
|
97
108
|
}
|
|
98
|
-
function subject(authority, result) {
|
|
109
|
+
async function subject(authority, result) {
|
|
110
|
+
const equivalence = await captureRunnerEquivalence(authority, result, projectFile);
|
|
111
|
+
return { schemaVersion: 'validator.validation-subject/1.0', artifactSha256: authority.plan.artifactSha256, memberId: authority.plan.memberId, chainId: authority.plan.chainId, executedAt: new Date().toISOString(), files: authority.plan.files, validationRunId: authority.plan.validationRunId, planId: authority.plan.planId, tests: authority.plan.tests, policy: { command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), requiredExitCode: 0, executionPlanSha256: authority.planFile.sha256, ...(authority.plan.policy.parallelPaths ? { parallelPaths: authority.plan.policy.parallelPaths } : {}), ...(authority.plan.policy.discipline ? { disciplineReviewRequired: true } : {}), ...(authority.plan.evidencePolicy ? { evidenceKind: authority.plan.evidencePolicy.kind, evidenceRunner: authority.plan.evidencePolicy.runner, workflowBinding: { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId }, ...(authority.plan.evidencePolicy.localVerification ? { localVerification: authority.plan.evidencePolicy.localVerification } : {}) } : {}) }, ...(equivalence === undefined ? {} : { equivalence }), ...(authority.disciplineComparison ? { disciplineComparison: authority.disciplineComparison } : {}), goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
|
|
99
112
|
function evidence(authority, result, sub) { const summary = result.timedOut ? 'Approved validation command exceeded its deadline' : result.outputLimitExceeded ? 'Approved validation command exceeded its output limit' : result.signal ? `Approved validation command ended on signal ${result.signal}` : `Approved validation command exited with code ${result.exitCode}`; const base = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `${authority.plan.validationRunId}:process`, kind: authority.plan.evidencePolicy?.kind ?? 'test', runner: authority.signing ? authority.plan.evidencePolicy?.runner ?? 'trusted-runner' : 'local', ...(authority.plan.evidencePolicy ? { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId } : {}), command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode === null ? 1 : result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
|
|
100
113
|
if (!authority.signing || !Number.isInteger(base.exitCode)) return base; const privateKey = authority.signing.privateKey; const receiptCore = { schemaVersion: RECEIPT_SCHEMA, keyId: authority.signing.config.keyId, subjectDigest: base.subjectDigest, nonce: randomUUID(), issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + authority.signing.config.receiptTtlMs).toISOString(), result: { runner: 'trusted-runner', passed: result.status === 'succeeded', exitCode: result.exitCode === null ? 1 : result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
export async function runApprovedPlan(input) { const owner = await authority(input); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
|
|
104
|
-
|
|
116
|
+
export async function runApprovedPlan(input) { const owner = await authority(input); const comparison = await runnerDisciplineBefore(input, owner); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
|
|
117
|
+
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
|
|
118
|
+
const sub = await subject(owner, result); const item = evidence(owner, result, sub); const status = !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status; return { schemaVersion: RUNNER_SCHEMA, status, planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy, subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null, ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}), signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
async function objectAuthority(input, afterExecution = false) {
|
|
@@ -123,13 +137,14 @@ async function objectAuthority(input, afterExecution = false) {
|
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export async function runApprovedPlanObject(input) {
|
|
126
|
-
const owner = await objectAuthority(input); const result = await runCommand(owner);
|
|
140
|
+
const owner = await objectAuthority(input); const comparison = await runnerDisciplineBefore(input, owner); const result = await runCommand(owner);
|
|
127
141
|
try { const after = await objectAuthority(input, true); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); }
|
|
128
142
|
catch (cause) {
|
|
129
143
|
const error = new Error(cause.message); error.code = cause.code;
|
|
130
144
|
error.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [], effects: 'unknown' }; throw error;
|
|
131
145
|
}
|
|
132
|
-
|
|
146
|
+
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
|
|
147
|
+
const sub = await subject(owner, result); const item = evidence(owner, result, sub);
|
|
133
148
|
return { schemaVersion: RUNNER_SCHEMA, status: !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status,
|
|
134
149
|
planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy,
|
|
135
150
|
subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null,
|
package/local-archguard-ast.mjs
CHANGED
|
@@ -285,8 +285,13 @@ function syntaxDiagnostics(source) {
|
|
|
285
285
|
target: ts.ScriptTarget.Latest, jsx: ts.JsxEmit.Preserve }, host);
|
|
286
286
|
return program.getSyntacticDiagnostics(source);
|
|
287
287
|
}
|
|
288
|
+
export function archguardScriptSource(file) {
|
|
289
|
+
const kind = SCRIPT_KINDS.get(file.path.split('.').at(-1));
|
|
290
|
+
if (kind === undefined) return null;
|
|
291
|
+
return ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, kind);
|
|
292
|
+
}
|
|
288
293
|
function analyzeScriptFile(file, rules) {
|
|
289
|
-
const source =
|
|
294
|
+
const source = archguardScriptSource(file);
|
|
290
295
|
const diagnostics = syntaxDiagnostics(source).map(diagnostic => {
|
|
291
296
|
const at = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
|
292
297
|
return { code: diagnostic.code, start: diagnostic.start ?? 0, end: (diagnostic.start ?? 0) + (diagnostic.length ?? 0),
|