@awak-app/simy-cli 0.7.15 → 0.7.17
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/README.md +225 -98
- package/package.json +5 -3
- package/src/agent.js +129 -7
- package/src/backend-executable.js +7 -6
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +302 -78
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +16 -5
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +12 -11
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +28 -38
- package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +1 -2
- package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +5 -63
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +21 -4
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +166 -0
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +98 -12
- package/src/codex-plugins/simy/hooks/user-experience.mjs +26 -0
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +76 -10
- package/src/delivery-hooks/consistency-monitor.js +20 -4
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +81 -1
- package/src/delivery-hooks/local-supervisor.js +13 -4
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-handover.js +84 -0
- package/src/delivery-hooks/supervisor-owner.js +3 -2
- package/src/delivery-hooks/supervisor-service.js +38 -6
- package/src/delivery-hooks/supervisor-watchdog.js +15 -4
- package/src/delivery-hooks/supervisor.js +151 -18
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +35 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/audit.js +24 -323
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/charter-validation.js +3 -41
- package/src/orchestrator/checklist-continuation.js +2 -14
- package/src/orchestrator/completion-contract.js +4 -49
- package/src/orchestrator/contract.js +5 -9
- package/src/orchestrator/delivery/WORKFLOW.md +30 -710
- package/src/orchestrator/delivery/references/acceptance-evidence.md +4 -216
- package/src/orchestrator/delivery/references/change-boundary-matrix.md +4 -103
- package/src/orchestrator/delivery/references/chrome-user-verification.md +5 -133
- package/src/orchestrator/delivery/references/cross-repository-control.md +4 -74
- package/src/orchestrator/delivery/references/delivery-levels.md +3 -117
- package/src/orchestrator/delivery/references/local-sqm-evidence.md +1 -1
- package/src/orchestrator/delivery/references/minimum-completion.md +4 -144
- package/src/orchestrator/delivery/references/pr-closure.md +4 -200
- package/src/orchestrator/delivery/references/shared-system-specifications.md +4 -161
- package/src/orchestrator/delivery/references/state-and-receipts.md +4 -245
- package/src/orchestrator/delivery/references/state-model.md +4 -185
- package/src/orchestrator/delivery/references/ux-specification.md +4 -95
- package/src/orchestrator/delivery-level.js +2 -10
- package/src/orchestrator/evidence.js +2 -3
- package/src/orchestrator/independent-audit.js +15 -164
- package/src/orchestrator/instruction.js +19 -254
- package/src/orchestrator/loop.js +35 -95
- package/src/orchestrator/outcome-monitor.js +21 -0
- package/src/orchestrator/pr-closure.js +2 -16
- package/src/orchestrator/recovery.js +32 -2
- package/src/orchestrator/ux-specification.js +3 -12
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +5 -2
- package/src/session-supervision/acceptance-reads.js +34 -0
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/browser-cdp.js +59 -0
- package/src/session-supervision/browser-recovery.js +97 -0
- package/src/session-supervision/codex-app-server.js +61 -22
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +47 -28
- package/src/session-supervision/final-response-recovery.js +5 -0
- package/src/session-supervision/index.js +22 -5
- package/src/session-supervision/interaction-proxy.js +250 -0
- package/src/session-supervision/interaction-task.js +189 -0
- package/src/session-supervision/native-interaction.js +102 -0
- package/src/session-supervision/provider-failure.js +40 -0
- package/src/session-supervision/purpose-instruction.js +14 -14
- package/src/session-supervision/purpose-recovery.js +32 -12
- package/src/session-supervision/purpose-verification.js +50 -216
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +6 -0
- package/src/sqm/local-tests.js +2 -1
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/startup-preflight.js +2 -20
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +457 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { suggestedProviderTokenBudget } from "./budget.js";
|
|
1
|
+
import { suggestedProviderTokenBudget, MAX_PROVIDER_TOKEN_BUDGET } from "./budget.js";
|
|
2
2
|
|
|
3
3
|
const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
|
|
|
13
13
|
schema_version: 1,
|
|
14
14
|
state,
|
|
15
15
|
reason_code: reasonCode,
|
|
16
|
-
user_message: recoveryMessage(reasonCode),
|
|
16
|
+
user_message: isSafetyCeiling(reasonCode) ? safetyCeilingRecoveryMessage(reasonCode, detail.budget) : recoveryMessage(reasonCode),
|
|
17
17
|
primary_action_id: actions[0].id,
|
|
18
18
|
actions,
|
|
19
19
|
technical_details: {
|
|
@@ -22,6 +22,9 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
|
|
|
22
22
|
max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
|
|
23
23
|
provider_token_budget: positiveInteger(snapshot?.charter?.token_budget),
|
|
24
24
|
provider_tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
|
|
25
|
+
provider_token_ceiling: positiveInteger(detail?.budget?.hard_token_ceiling),
|
|
26
|
+
attempt_ceiling: positiveInteger(detail?.budget?.hard_attempt_ceiling),
|
|
27
|
+
continuation_policy: clean(detail?.budget?.continuation_policy) || null,
|
|
25
28
|
token_budget: positiveInteger(snapshot?.charter?.token_budget),
|
|
26
29
|
retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
|
|
27
30
|
tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
|
|
@@ -115,6 +118,7 @@ function recoveryReasonCode(state, message, detail) {
|
|
|
115
118
|
return "retry_strategy_unchanged";
|
|
116
119
|
}
|
|
117
120
|
if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
|
|
121
|
+
if (["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code)) return code;
|
|
118
122
|
if (code === "token_budget_exhausted") return "token_budget_exhausted";
|
|
119
123
|
if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
|
|
120
124
|
const findings = strings(detail.finding_codes);
|
|
@@ -138,6 +142,25 @@ function recoveryReasonCode(state, message, detail) {
|
|
|
138
142
|
return "local_orchestration_failed";
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
function isSafetyCeiling(code) {
|
|
146
|
+
return ["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function safetyCeilingRecoveryMessage(code, budget = {}) {
|
|
150
|
+
const token = code === "provider_token_ceiling_reached";
|
|
151
|
+
const limit = token ? budget?.hard_token_ceiling : budget?.hard_attempt_ceiling;
|
|
152
|
+
const used = token ? budget?.tokens_used : (budget?.retries_used ?? 0) + 1;
|
|
153
|
+
const maximum = token ? MAX_PROVIDER_TOKEN_BUDGET : 100;
|
|
154
|
+
const base = `SIMY retained this same run's work and usage. The ${token ? "Provider token" : "executor attempt"} safety ceiling prevents another call. Increasing a notification threshold alone does not permit another call. `;
|
|
155
|
+
if (budget?.continuation_policy !== "progress") {
|
|
156
|
+
return base + "The bounded policy does not permit a same-run safety extension. Keep this run and review its remaining acceptance conditions with the operator; guidance alone cannot resume provider work.";
|
|
157
|
+
}
|
|
158
|
+
if (!Number.isFinite(limit) || !Number.isFinite(used) || limit >= maximum || used >= maximum) {
|
|
159
|
+
return base + `The validated maximum is ${maximum.toLocaleString("en-US")}; no larger same-run ceiling is available. Preserve evidence and review remaining work with the operator; guidance alone cannot resume provider work.`;
|
|
160
|
+
}
|
|
161
|
+
return base + `Review remaining acceptance conditions and explicitly increase the relevant ceiling above both recorded usage and its current limit, up to ${maximum.toLocaleString("en-US")}. Then continue this same run. Extension never resets usage or resumes work, and other approvals remain required.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
141
164
|
function recoveryMessage(reasonCode) {
|
|
142
165
|
const messages = {
|
|
143
166
|
repository_authorization_required:
|
|
@@ -198,6 +221,13 @@ function recoveryActions(reasonCode, budget = null) {
|
|
|
198
221
|
newRunAction(),
|
|
199
222
|
];
|
|
200
223
|
}
|
|
224
|
+
if (isSafetyCeiling(reasonCode)) {
|
|
225
|
+
return [guidanceAction(
|
|
226
|
+
"review_safety_ceiling",
|
|
227
|
+
"Review this run's safety ceiling",
|
|
228
|
+
safetyCeilingRecoveryMessage(reasonCode, budget),
|
|
229
|
+
)];
|
|
230
|
+
}
|
|
201
231
|
if (reasonCode === "token_budget_exhausted") {
|
|
202
232
|
const suggestedBudget = suggestedProviderTokenBudget(budget);
|
|
203
233
|
if (!suggestedBudget || budget?.retry_exhausted === true) return [newRunAction()];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceInstruction } from '../codex-plugins/simy/hooks/user-experience.mjs';
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { section } from "./shared.js";
|
|
3
4
|
|
|
@@ -5,16 +6,6 @@ export const UX_SPECIFICATION_PATH = fileURLToPath(
|
|
|
5
6
|
new URL("./delivery/references/ux-specification.md", import.meta.url),
|
|
6
7
|
);
|
|
7
8
|
|
|
8
|
-
export function buildUxSpecificationInstruction(
|
|
9
|
-
return section("
|
|
10
|
-
`Apply the same behavioral-completeness standard throughout requirements, design, implementation mapping, verification, review, release, closure and handoff. Read: ${UX_SPECIFICATION_PATH}`,
|
|
11
|
-
"Describe observable interaction units, not feature inventories. Reuse stable acceptance IDs. For each unit specify actor/goal, context/preconditions, trigger/reachable entry, perceived information, available action/choice/control, system response/state transition, usable outcome/postconditions, next action/handoff, alternative/failure/recovery paths, and verification oracle/provenance. Treat these as semantic obligations in the existing specification, not new CLI JSON fields.",
|
|
12
|
-
"Decompose when actor, precondition, decision, observable state, postcondition or recovery differs materially, or when any clause could fail independently. Link predecessors/successors and preserve end-to-end criteria. Derive branches and applicable constraints from authoritative requirements and observed system boundaries; do not invent scenarios, defaults, thresholds or product policy to fill gaps.",
|
|
13
|
-
"Description is sufficient only when another implementer can derive the intended behavior and an independent verifier can determine pass/fail without guessing a material requirement. Review source coverage, causal continuity, branch closure, controllability, observability, traceability, consistency and epistemic separation. Word count, row count and verbosity are not quality thresholds.",
|
|
14
|
-
"Keep required/intended/observed/assumed/unknown distinct. Unknown facts stay explicit with affected IDs and resolution evidence; a reversible missing requirement decision is resolved by grounded AI inference and a recorded assumption, while missing runtime evidence blocks the affected verification claim. Genuine non-applicability needs a source and reason, not an empty cell.",
|
|
15
|
-
"Persist the complete contract in the canonical specification and link the same IDs from summaries, matrices, PRs and receipts. Summaries may shorten repetition but must not replace or drop behavioral obligations, unresolved gaps or evidence. A phase change revalidates affected descriptions and results; plans never become observed proof by wording alone.",
|
|
16
|
-
auditor
|
|
17
|
-
? "Remain read-only. Independently derive coverage from original sources, identify any material behavior that still requires guessing, and report affected criterion IDs and required clarification/evidence as findings. Do not infer completeness from filled fields or copied executor claims."
|
|
18
|
-
: "Before dependent implementation, resolve material specification ambiguity with source research, a reversible default and verification; reserve needs_decision for irreversible or explicitly user-reserved decisions; continue independent work. Before verification or completion, repair missing descriptions/evidence rather than relabeling incomplete UX as complete. This procedural contract does not add automatic schema or GitHub enforcement.",
|
|
19
|
-
].join("\n"));
|
|
9
|
+
export function buildUxSpecificationInstruction() {
|
|
10
|
+
return section("requested_outcome", experienceInstruction + '\n' + 'Make the requested experience easy, accurate and concise. Resolve material ambiguity by inspecting the existing product and trying a reversible change. Exercise the actual user flow, including relevant errors and recovery. Specify only what helps implementation or use; no compulsory interaction-unit forms, traceability matrices or twelve-view reviews.');
|
|
20
11
|
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { matchesPairedNativeAccount } from './native-provider-identity.js';
|
|
4
|
+
import { readRecord } from './codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { readClaudeSession } from './session-supervision/claude-sessions.js';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { PassThrough } from 'node:stream';
|
|
10
|
+
import { realpath } from 'node:fs/promises';
|
|
11
|
+
import { PROVIDER_LINKS } from './provider-links.js';
|
|
12
|
+
import { normalizeProviderModelSelection } from './provider-model-selection.js';
|
|
13
|
+
import { resolveDesktopExecutorCommand } from './desktop-executor.js';
|
|
14
|
+
import { openCodexAppServer, readCodexSession } from './session-supervision/codex-app-server.js';
|
|
15
|
+
|
|
16
|
+
const NATIVE_SESSION_MODES = Object.freeze({ codex: 'existing', claude: 'fork' });
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const fail = code => Object.assign(new Error(`Provider delegation unavailable: ${code}.`), { code });
|
|
19
|
+
const identifier = value => typeof value === 'string' && value.trim() && value.length <= 256;
|
|
20
|
+
|
|
21
|
+
// Routing identifiers and provider-reported account email, never credentials. API models stay in Crew's
|
|
22
|
+
// registry/gateway and SIMY conversation; this native adapter does not invent
|
|
23
|
+
// local executables or sessions for them.
|
|
24
|
+
export function normalizeProviderDelegation(value, { provider, model } = {}) {
|
|
25
|
+
if (value == null) return null;
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
27
|
+
value.schema_version !== 'provider_delegation.v1' || value.provider !== provider ||
|
|
28
|
+
value.model !== model || !identifier(value.model) || !identifier(value.origin_task_id) ||
|
|
29
|
+
!identifier(value.origin_conversation_id) || !identifier(value.request_id) ||
|
|
30
|
+
!identifier(value.account_id) || !UUID.test(value.session_id || '') ||
|
|
31
|
+
!['existing', 'fork'].includes(value.session_mode)) throw fail('invalid_provider_delegation');
|
|
32
|
+
normalizeProviderModelSelection({ model: value.model });
|
|
33
|
+
if (!PROVIDER_LINKS.includes(provider)) throw fail('crew_gateway_required');
|
|
34
|
+
if (!Object.hasOwn(NATIVE_SESSION_MODES, provider)) throw fail('native_adapter_unavailable');
|
|
35
|
+
if (provider === 'claude' && value.session_mode !== 'fork') throw fail('claude_exact_session_writer_unavailable');
|
|
36
|
+
if (provider === 'claude' && value.allow_new_task !== true) throw fail('new_task_permission_required');
|
|
37
|
+
if (provider === 'codex' && value.session_mode !== 'existing') throw fail('unsupported_session_mode');
|
|
38
|
+
return Object.freeze(Object.fromEntries(['schema_version', 'provider', 'model', 'account_id', 'session_id', 'session_mode',
|
|
39
|
+
'origin_task_id', 'origin_conversation_id', 'request_id', 'allow_new_task', 'allow_new_task_request_id'].filter(key => value[key] !== undefined).map(key => [key, value[key]])));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Accept P01's exact target shape at the existing Local Task boundary. A
|
|
43
|
+
// target alone carries no task/session execution authority and cannot silently
|
|
44
|
+
// fall back to a fresh native invocation.
|
|
45
|
+
export function providerDelegationFromRequest(body, requestId) {
|
|
46
|
+
const target = body.delegation_target;
|
|
47
|
+
if (!body.delegation) {
|
|
48
|
+
if (target) throw fail('delegation_context_required');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const value = { ...body.delegation };
|
|
52
|
+
if (target) {
|
|
53
|
+
if (target.transport !== 'native') throw fail('crew_gateway_required');
|
|
54
|
+
for (const field of ['provider', 'model', 'account_id', 'session_id']) {
|
|
55
|
+
if (value[field] !== undefined && value[field] !== target[field]) throw fail('delegation_target_mismatch');
|
|
56
|
+
value[field] = target[field];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (value.request_id !== requestId) throw fail('delegation_request_mismatch');
|
|
60
|
+
const normalized = normalizeProviderDelegation(value, { provider: body.backend, model: body.model });
|
|
61
|
+
if (body.native_identity && normalized.session_mode === 'fork' && normalized.allow_new_task_request_id !== requestId)
|
|
62
|
+
throw fail('native_new_task_permission_required');
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function providerDelegationTarget(value) {
|
|
67
|
+
return value ? Object.fromEntries(['provider', 'model', 'account_id', 'session_id'].map(key => [key, value[key]]).concat([['transport', 'native']])) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function providerDelegationCoverage(capabilities = []) {
|
|
71
|
+
return capabilities.map(item => {
|
|
72
|
+
const native = item.transport !== 'api' && PROVIDER_LINKS.includes(item.provider);
|
|
73
|
+
const mode = Object.hasOwn(NATIVE_SESSION_MODES, item.provider) ? NATIVE_SESSION_MODES[item.provider] : null;
|
|
74
|
+
return { provider: item.provider, readiness: item.readiness,
|
|
75
|
+
execution_target: native ? 'local' : 'crew_gateway',
|
|
76
|
+
session_mode: native ? mode : 'simy_context',
|
|
77
|
+
...(native && mode ? { supported_session_modes: [mode],
|
|
78
|
+
continuation_mode: mode === 'existing' ? 'same_session' : 'fresh_explicit_fork',
|
|
79
|
+
cancel_scope: mode === 'existing' ? 'owned_turn' : 'owned_fork_process',
|
|
80
|
+
disconnect_recovery: 'hold_unknown_without_replay' } : {}),
|
|
81
|
+
limitation: !native ? 'requires_crew_gateway' : !mode ? 'native_adapter_unavailable' :
|
|
82
|
+
item.provider === 'claude' ? 'exact_session_writer_unavailable' : null };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function resolveDelegatedProviderCommand(input, { openClient = openCodexAppServer,
|
|
87
|
+
resolveCommand = resolveDesktopExecutorCommand, readClaude = readClaudeSession,
|
|
88
|
+
supervisionRoot, pairedDeviceId, providerLinkEpoch,
|
|
89
|
+
onWorkspaceAuthorized = null,
|
|
90
|
+
readSupervision = target => readRecord(supervisionRoot, target.provider, target.session_id),
|
|
91
|
+
inspectClaudeAccount = async bin => JSON.parse((await promisify(execFile)(bin, ['auth', 'status', '--json'], { timeout: 5000, maxBuffer: 65536 })).stdout) } = {}) {
|
|
92
|
+
const selection = normalizeProviderDelegation(input.delegation, { provider: input.backend, model: input.model });
|
|
93
|
+
const accountMatches = email => input.nativeIdentity ? matchesPairedNativeAccount(input.nativeIdentity,
|
|
94
|
+
{ deviceId: pairedDeviceId, provider: selection.provider, email, connectionEpoch: providerLinkEpoch ?? null }) && input.nativeIdentity.account_id === selection.account_id
|
|
95
|
+
: email === selection.account_id;
|
|
96
|
+
if (!selection) return resolveCommand(input);
|
|
97
|
+
let claudeCommand;
|
|
98
|
+
if (selection.provider === 'claude') {
|
|
99
|
+
if (input.nativeIdentity && selection.allow_new_task_request_id !== selection.request_id) throw fail('new_task_permission_required');
|
|
100
|
+
claudeCommand = await resolveCommand(input);
|
|
101
|
+
if (claudeCommand.verification === 'explicit_command_override') throw fail('provider_selection_unverifiable');
|
|
102
|
+
// Keep ordinary provider approval. Never use the desktop adapter's legacy
|
|
103
|
+
// skip-permissions flag for a delegation.
|
|
104
|
+
claudeCommand.args = ['-p', input.instruction, '--output-format', 'stream-json', '--verbose',
|
|
105
|
+
'--model', selection.model, '--resume', selection.session_id, '--fork-session',
|
|
106
|
+
'--permission-mode', input.permissionMode === 'read_only' ? 'plan' : 'default'];
|
|
107
|
+
}
|
|
108
|
+
return { bin: 'codex-app-server', args: [], env: {}, backend: input.backend,
|
|
109
|
+
spawn() {
|
|
110
|
+
const child = new EventEmitter();
|
|
111
|
+
child.stdout = new PassThrough(); child.stderr = new PassThrough();
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
let ownedChild;
|
|
114
|
+
child.kill = signal => {
|
|
115
|
+
const alreadyAborted = controller.signal.aborted;
|
|
116
|
+
controller.abort(Object.assign(fail('provider_cancelled'), { nativeSignal: signal || 'SIGINT' }));
|
|
117
|
+
// Abort invokes the owned Claude listener exactly once. Only a later
|
|
118
|
+
// escalation forwards another signal; Codex owns an RPC turn instead.
|
|
119
|
+
if (alreadyAborted) ownedChild?.kill(signal);
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
// Match the existing process interface; stopLocalTask only signals this
|
|
123
|
+
// owned turn through kill(), never an original session process.
|
|
124
|
+
setImmediate(async () => {
|
|
125
|
+
try {
|
|
126
|
+
const emit = event => child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
127
|
+
if (selection.provider === 'claude') await runClaudeDelegation({ ...input, selection,
|
|
128
|
+
command: claudeCommand, readClaude, inspectClaudeAccount, accountMatches, signal: controller.signal, emit, onChild: value => { ownedChild = value; } });
|
|
129
|
+
else await runCodexDelegation({ ...input, selection, signal: controller.signal, openClient, emit, readSupervision, accountMatches, onWorkspaceAuthorized });
|
|
130
|
+
child.stdout.end(); child.stderr.end(); child.emit('close', 0);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const message = fail(error.code || 'provider_result_unknown').message;
|
|
133
|
+
child.stdout.write(`${JSON.stringify({ type: 'simy.delegation.state', state: error.delegationState || (selection.provider === 'claude' && !ownedChild ? 'failed' : 'result_unknown') })}\n`);
|
|
134
|
+
child.stdout.end(`${JSON.stringify(selection.provider === 'claude' ?
|
|
135
|
+
{ type: 'result', is_error: true, result: message } : { type: 'turn.failed', error: { message } })}\n`);
|
|
136
|
+
child.stderr.end(); child.emit('close', 1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return child;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runCodexDelegation({ selection, instruction, getInstruction, repositoryPath, permissionMode, signal, openClient, emit, beforeDispatch, readSupervision, accountMatches, nativeIdentity, workspaceMode, onWorkspaceAuthorized, dispatchNative = send => send() }) {
|
|
145
|
+
if (signal.aborted) throw Object.assign(fail('provider_cancelled'), { delegationState: 'stopped' });
|
|
146
|
+
const preserveSupervision = async () => {
|
|
147
|
+
if ((await readSupervision(selection))?.run) throw fail('existing_supervised_task');
|
|
148
|
+
};
|
|
149
|
+
let client;
|
|
150
|
+
try { await preserveSupervision(); client = await openClient({}); }
|
|
151
|
+
catch (error) { error.delegationState = 'failed'; throw error; }
|
|
152
|
+
let turnId, starting, primaryError, completed = false, unsubscribe = () => {};
|
|
153
|
+
try {
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
const account = await client.request('account/read', { refreshToken: false });
|
|
156
|
+
if (!accountMatches(account.account?.email)) throw fail('provider_account_mismatch');
|
|
157
|
+
const metadata = await readCodexSession(client, selection.session_id, { signal });
|
|
158
|
+
if (metadata.isRunning === true) throw fail('provider_session_busy');
|
|
159
|
+
const configuration = await client.request('thread/resume', { threadId: selection.session_id, excludeTurns: true });
|
|
160
|
+
if (configuration.thread?.id !== selection.session_id || configuration.model !== selection.model ||
|
|
161
|
+
!configuration.cwd || await realpath(configuration.cwd) !== await realpath(nativeIdentity ? metadata.cwd : repositoryPath) ||
|
|
162
|
+
workspaceMode === 'repository' && await realpath(configuration.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
|
|
163
|
+
// Do not mutate provider permissions. An incompatible requested scope must
|
|
164
|
+
// be resolved in the original provider, never silently widened here.
|
|
165
|
+
if (permissionMode === 'read_only' ? configuration.sandbox?.type !== 'readOnly' :
|
|
166
|
+
configuration.sandbox?.type !== 'workspaceWrite' || configuration.sandbox.networkAccess !== false) throw fail('provider_permission_mismatch');
|
|
167
|
+
if (configuration.sandbox?.type === 'workspaceWrite') {
|
|
168
|
+
const roots = configuration.sandbox.writableRoots ?? [];
|
|
169
|
+
if (!Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !isAbsolute(root))) throw fail('provider_permission_mismatch');
|
|
170
|
+
await onWorkspaceAuthorized?.([configuration.cwd, ...roots]);
|
|
171
|
+
}
|
|
172
|
+
await beforeDispatch?.();
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
await preserveSupervision();
|
|
175
|
+
const currentAccount = await client.request('account/read', { refreshToken: false });
|
|
176
|
+
if (!accountMatches(currentAccount.account?.email)) throw fail('provider_account_mismatch');
|
|
177
|
+
await beforeDispatch?.();
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
const currentInstruction = getInstruction ? await getInstruction() : instruction;
|
|
180
|
+
if (typeof currentInstruction !== 'string' || !currentInstruction.trim()) throw fail('provider_instruction_unavailable');
|
|
181
|
+
await new Promise((resolve, reject) => {
|
|
182
|
+
let started = false, hasResult = false, settled = false;
|
|
183
|
+
let cleanup = () => {};
|
|
184
|
+
const finish = error => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true; cleanup();
|
|
187
|
+
if (error) reject(error); else resolve();
|
|
188
|
+
};
|
|
189
|
+
const buffered = [];
|
|
190
|
+
const consume = (method, params, requestId) => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
if (params.threadId !== selection.session_id || (params.turnId || params.turn?.id) !== turnId) return;
|
|
193
|
+
if (requestId !== undefined) {
|
|
194
|
+
emit({ type: 'simy.delegation.question', question: { ...params, method }, request_id: requestId });
|
|
195
|
+
finish(fail('provider_input_required')); return;
|
|
196
|
+
}
|
|
197
|
+
if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string') {
|
|
198
|
+
hasResult ||= Boolean(params.item.text.trim());
|
|
199
|
+
emit({ type: 'item.completed', item: { type: 'agent_message', text: params.item.text } });
|
|
200
|
+
}
|
|
201
|
+
if (method === 'turn/completed') {
|
|
202
|
+
completed = true;
|
|
203
|
+
if (params.turn.status === 'completed' && hasResult) finish();
|
|
204
|
+
else finish(fail('provider_turn_failed'));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
unsubscribe = client.onNotification((method, params, requestId) => {
|
|
208
|
+
if (!started) { if (buffered.length < 100) buffered.push([method, params, requestId]); return; }
|
|
209
|
+
consume(method, params, requestId);
|
|
210
|
+
});
|
|
211
|
+
const cancelled = () => finish(fail('provider_cancelled'));
|
|
212
|
+
signal.addEventListener('abort', cancelled, { once: true });
|
|
213
|
+
const closed = () => finish(fail('provider_result_unknown'));
|
|
214
|
+
client.child.once('close', closed);
|
|
215
|
+
cleanup = () => { signal.removeEventListener('abort', cancelled); client.child.off('close', closed); };
|
|
216
|
+
// The official provider writer lock acquired by thread/resume arbitrates
|
|
217
|
+
// races with the original app. No busy fallback, steer, or second task.
|
|
218
|
+
if (signal.aborted) { finish(fail('provider_cancelled')); return; }
|
|
219
|
+
starting = dispatchNative(() => client.request('turn/start', { threadId: selection.session_id, model: selection.model,
|
|
220
|
+
input: [{ type: 'text', text: currentInstruction }] })).then(response => {
|
|
221
|
+
turnId = response?.turn?.id; started = true;
|
|
222
|
+
if (!turnId) finish(fail('provider_result_unknown'));
|
|
223
|
+
else { emit({ type: 'thread.started', thread_id: selection.session_id }); for (const event of buffered) consume(...event); }
|
|
224
|
+
}, finish);
|
|
225
|
+
});
|
|
226
|
+
emit({ type: 'turn.completed' });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
primaryError = error;
|
|
229
|
+
if (!starting || completed) error.delegationState = 'failed';
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
unsubscribe();
|
|
233
|
+
// A cancelled request may have been accepted before its response arrived.
|
|
234
|
+
// Collect that exact ID before interrupting; never issue another start.
|
|
235
|
+
if (starting && !turnId) await starting.catch(() => {});
|
|
236
|
+
if (turnId && !completed && !client.closed) {
|
|
237
|
+
try {
|
|
238
|
+
await client.request('turn/interrupt', { threadId: selection.session_id, turnId }, 2000);
|
|
239
|
+
if (primaryError?.code === 'provider_input_required') primaryError.delegationState = 'needs_user';
|
|
240
|
+
if (primaryError?.code === 'provider_cancelled') primaryError.delegationState = 'stopped';
|
|
241
|
+
} catch {
|
|
242
|
+
if (primaryError) primaryError.delegationState = 'result_unknown';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Transport cleanup cannot erase a known terminal result or primary error.
|
|
246
|
+
try { await client.close(); } catch { /* The submitted turn is never replayed. */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runClaudeDelegation({ selection, repositoryPath, command, readClaude,
|
|
251
|
+
inspectClaudeAccount, signal, emit, beforeDispatch, onChild, accountMatches, nativeIdentity, workspaceMode, dispatchNative = send => send() }) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
const metadata = await readClaude(selection.session_id);
|
|
254
|
+
if (!metadata?.cwd || (!nativeIdentity || workspaceMode === 'repository') && await realpath(metadata.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
|
|
255
|
+
const nativeCwd = await realpath(metadata.cwd);
|
|
256
|
+
const account = await inspectClaudeAccount(command.bin);
|
|
257
|
+
if (account.loggedIn !== true || !accountMatches(account.email)) throw fail('provider_account_mismatch');
|
|
258
|
+
await beforeDispatch?.();
|
|
259
|
+
signal.throwIfAborted();
|
|
260
|
+
// Recheck the official identity after asynchronous lease/link guards, just as
|
|
261
|
+
// the Codex adapter does. A selected account cannot change while waiting.
|
|
262
|
+
const currentAccount = await inspectClaudeAccount(command.bin);
|
|
263
|
+
if (currentAccount.loggedIn !== true || !accountMatches(currentAccount.email)) throw fail('provider_account_mismatch');
|
|
264
|
+
await beforeDispatch?.();
|
|
265
|
+
signal.throwIfAborted();
|
|
266
|
+
await new Promise((resolve, reject) => {
|
|
267
|
+
let pending = '', terminal = false, nativeSession = null, question = false, terminalFailed = false, invalidStream = false, settled = false;
|
|
268
|
+
const decoder = new StringDecoder('utf8');
|
|
269
|
+
const child = dispatchNative(() => command.spawn(command.bin, command.args, { cwd: nativeCwd,
|
|
270
|
+
env: { ...process.env, ...command.env }, stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
271
|
+
// This is only the newly forked process; the original owner is never signalled.
|
|
272
|
+
onChild?.(child);
|
|
273
|
+
const cancel = () => child.kill(signal.reason?.nativeSignal || 'SIGINT');
|
|
274
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
275
|
+
const cleanup = () => signal.removeEventListener('abort', cancel);
|
|
276
|
+
child.stderr?.resume();
|
|
277
|
+
const consume = line => {
|
|
278
|
+
let event; try { event = JSON.parse(line); } catch { return; }
|
|
279
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) return;
|
|
280
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
281
|
+
if (!UUID.test(event.session_id || '') || event.session_id === selection.session_id || event.model !== selection.model ||
|
|
282
|
+
nativeSession && nativeSession !== event.session_id) {
|
|
283
|
+
invalidStream = true; child.kill('SIGINT'); return;
|
|
284
|
+
}
|
|
285
|
+
nativeSession = event.session_id;
|
|
286
|
+
emit({ type: 'simy.delegation.session', session_id: nativeSession });
|
|
287
|
+
}
|
|
288
|
+
if (!nativeSession || event.session_id && event.session_id !== nativeSession) return;
|
|
289
|
+
if (event.type === 'result') {
|
|
290
|
+
const denials = Array.isArray(event.permission_denials) ? event.permission_denials : [];
|
|
291
|
+
const questions = denials.flatMap(item => Array.isArray(item?.tool_input?.questions) ? item.tool_input.questions : []);
|
|
292
|
+
const message = questions.map(item => item?.question).filter(item => typeof item === 'string').join('\n') ||
|
|
293
|
+
(typeof event.result === 'string' && event.result.trim() ? event.result : null) ||
|
|
294
|
+
(Array.isArray(event.errors) ? event.errors.filter(item => typeof item === 'string').join('\n') : '') ||
|
|
295
|
+
denials.map(item => item?.tool_input?.description || item?.tool_input?.command).filter(item => typeof item === 'string').join('\n');
|
|
296
|
+
terminal = typeof event.result === 'string' && Boolean(event.result.trim()) || denials.length > 0 ||
|
|
297
|
+
Array.isArray(event.errors) && event.errors.some(item => typeof item === 'string' && item.trim());
|
|
298
|
+
terminalFailed = event.is_error === true || String(event.subtype || '').startsWith('error');
|
|
299
|
+
if (denials.length && !message) invalidStream = true;
|
|
300
|
+
if (denials.length && message) {
|
|
301
|
+
question = true;
|
|
302
|
+
emit({ type: 'simy.delegation.question', question: { message, permission_denials: denials, ...(questions.length ? { questions } : {}) } });
|
|
303
|
+
event.is_error = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (event.type === 'result' && typeof event.result === 'string')
|
|
307
|
+
emit({ type: 'assistant', message: { content: [{ type: 'text', text: event.result }] } });
|
|
308
|
+
emit(event);
|
|
309
|
+
};
|
|
310
|
+
child.stdout.on('data', chunk => {
|
|
311
|
+
if (settled) return;
|
|
312
|
+
pending += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
313
|
+
if (Buffer.byteLength(pending) > 256 * 1024) { invalidStream = true; child.kill('SIGINT'); return; }
|
|
314
|
+
const lines = pending.split('\n'); pending = lines.pop();
|
|
315
|
+
for (const line of lines) consume(line);
|
|
316
|
+
});
|
|
317
|
+
child.once('error', error => { if (settled) return; settled = true; cleanup(); const failure = fail('provider_result_unknown');
|
|
318
|
+
if (['ENOENT', 'EACCES'].includes(error?.code) && !child.pid) failure.delegationState = 'failed';
|
|
319
|
+
reject(failure); });
|
|
320
|
+
child.once('close', (code, closeSignal) => {
|
|
321
|
+
if (settled) return; settled = true;
|
|
322
|
+
cleanup();
|
|
323
|
+
pending += decoder.end();
|
|
324
|
+
if (!invalidStream && pending.trim()) consume(pending);
|
|
325
|
+
const rejectState = (reason, state) => reject(Object.assign(fail(reason), { delegationState: state }));
|
|
326
|
+
if (invalidStream || !nativeSession) rejectState('provider_result_unknown', 'result_unknown');
|
|
327
|
+
else if (signal.aborted) rejectState('provider_cancelled',
|
|
328
|
+
code === 130 || closeSignal === 'SIGINT' ? 'stopped' : 'result_unknown');
|
|
329
|
+
else if (question && terminal) rejectState('provider_input_required', 'needs_user');
|
|
330
|
+
else if (terminalFailed && terminal) rejectState('provider_turn_failed', 'failed');
|
|
331
|
+
else if (code !== 0 || !terminal) rejectState('provider_result_unknown', 'result_unknown');
|
|
332
|
+
else resolve();
|
|
333
|
+
});
|
|
334
|
+
if (signal.aborted) cancel();
|
|
335
|
+
});
|
|
336
|
+
}
|
package/src/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safetyCeilingRecoveryMessage } from "./orchestrator/recovery.js";
|
|
1
2
|
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
2
3
|
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
3
4
|
import { execFile, execFileSync } from "node:child_process";
|
|
@@ -258,7 +259,9 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
258
259
|
(persistedState === "failed" && restoredSnapshot.metadata?.controller_result_recovery)
|
|
259
260
|
? persistedState : null;
|
|
260
261
|
const objectiveCheck = verifyObjective(restoredSnapshot.charter);
|
|
261
|
-
|
|
262
|
+
// Snapshots predating this flag still carry the original review requirement.
|
|
263
|
+
const savedGoalUnproven = restoredSnapshot.charter.independent_review_required !== false &&
|
|
264
|
+
(persistedState === "merge_ready" || persistedState === "pr_ready_for_review") &&
|
|
262
265
|
!hasUserOutcomeProof(restoredSnapshot.attempts?.at(-1)?.independent_audit?.coverage_review);
|
|
263
266
|
const objectiveUnverified = !objectiveCheck.valid || objectiveCheck.legacy || savedGoalUnproven;
|
|
264
267
|
const restoredState = restoredFromState || objectiveUnverified ? "waiting_human" : persistedState;
|
|
@@ -459,7 +462,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
459
462
|
}
|
|
460
463
|
const currentBudget = refreshBudgetState(run.snapshot);
|
|
461
464
|
if (currentBudget.retry_exhausted) {
|
|
462
|
-
throw new Error("
|
|
465
|
+
throw new Error(safetyCeilingRecoveryMessage("attempt_ceiling_reached", currentBudget));
|
|
463
466
|
}
|
|
464
467
|
const previousBudget = currentBudget.token_budget;
|
|
465
468
|
const previousAudit = run.snapshot.final_audit;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { open, realpath } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { evidenceExcerpt } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
6
|
+
|
|
7
|
+
// Read only original file predicates in the authorized task directory. These
|
|
8
|
+
// are current independent controller reads, not worker claims or authored proof.
|
|
9
|
+
export async function captureAcceptanceReads(cwd, criteria, { signal } = {}) {
|
|
10
|
+
const root = await realpath(cwd), rows = [], seen = new Set(); let retained = 0;
|
|
11
|
+
for (const criterion of criteria) {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const check = criterion.check, target = check?.exactPath || check?.path;
|
|
14
|
+
if (!['file_equals', 'file_contains', 'file_exists'].includes(check?.type) || typeof target !== 'string' || !target || seen.has(target) || rows.length >= 8) continue;
|
|
15
|
+
seen.add(target); let handle;
|
|
16
|
+
try {
|
|
17
|
+
const path = await realpath(resolve(root, target)), within = relative(root, path);
|
|
18
|
+
if (!within || within === '..' || within.startsWith('../') || within.startsWith('..\\') || isAbsolute(within)) continue;
|
|
19
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
20
|
+
const info = await handle.stat(); if (!info.isFile() || info.size > 32768 || retained + info.size > 65536) continue;
|
|
21
|
+
const data = Buffer.alloc(32769), { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
22
|
+
if (bytesRead > 32768) continue;
|
|
23
|
+
const body = data.subarray(0, bytesRead); retained += bytesRead;
|
|
24
|
+
const digest = createHash('sha256').update(body).digest('hex');
|
|
25
|
+
rows.push({ id: `file:${target}`, source: 'controller_file_read', status: 'observed', at: Date.now(), digest,
|
|
26
|
+
recipe: { type: 'file_read', path: target }, observation: evidenceExcerpt(bytesRead ? body.toString('utf8') : JSON.stringify({ byteLength: 0, content: '' }), 4000) });
|
|
27
|
+
} catch { signal?.throwIfAborted(); /* Missing/large/unreadable files stay unverified; tool investigation remains available. */ }
|
|
28
|
+
finally { await handle?.close(); }
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
export const currentAcceptanceRead = (item, now) => item?.source === 'controller_file_read' && item.status === 'observed' &&
|
|
33
|
+
item.recipe?.type === 'file_read' && typeof item.recipe.path === 'string' && Number.isFinite(item.at) && item.at <= now && now - item.at < 60000 &&
|
|
34
|
+
/^[a-f0-9]{64}$/.test(item.digest) && typeof item.observation?.text === 'string';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, realpath, readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { PNG } from 'pngjs';
|
|
9
|
+
import { canonicalize } from '../sqm/canonical.js';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
const collectedManifests = new WeakSet();
|
|
13
|
+
const digest = bytes => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
14
|
+
const fail = code => Object.assign(new Error(code), { code });
|
|
15
|
+
const hashPattern = /^sha256:[a-f0-9]{64}$/;
|
|
16
|
+
|
|
17
|
+
// These are explicit read contracts, never commands or permission grants. A trusted
|
|
18
|
+
// controller supplies both the contract and authorize; worker manifests are not input.
|
|
19
|
+
export async function observeArtifacts(contract, { root, authorize, signal, adapters = {}, now = Date.now } = {}) {
|
|
20
|
+
if (!contract?.revision || !Array.isArray(contract.targets) || !contract.targets.length ||
|
|
21
|
+
new Set(contract.targets.map(t => t.id)).size !== contract.targets.length) throw fail('invalid_contract');
|
|
22
|
+
if (typeof authorize !== 'function') throw fail('authorization_required');
|
|
23
|
+
const snapshot = structuredClone(contract);
|
|
24
|
+
const contractDigest = digest(canonicalize(snapshot));
|
|
25
|
+
const targets = [];
|
|
26
|
+
for (const target of snapshot.targets) {
|
|
27
|
+
if (!target.id || !target.requirementId || !target.destination || !hashPattern.test(target.digest) ||
|
|
28
|
+
!Number.isSafeInteger(target.unitCount) || target.unitCount < 1 ||
|
|
29
|
+
!Array.isArray(target.expectedText) || target.expectedText.length !== target.unitCount ||
|
|
30
|
+
target.expectedText.some(text => typeof text !== 'string' || !text.trim())) throw fail('invalid_target');
|
|
31
|
+
await authorize(target); signal?.throwIfAborted();
|
|
32
|
+
let observation;
|
|
33
|
+
try {
|
|
34
|
+
const adapter = Object.hasOwn(adapters, target.kind) ? adapters[target.kind] :
|
|
35
|
+
['file', 'code', 'pdf', 'pptx'].includes(target.kind) ? localArtifactAdapter : null;
|
|
36
|
+
if (typeof adapter !== 'function') throw fail('unsupported_kind');
|
|
37
|
+
observation = await adapter(target, { root, signal, authorize });
|
|
38
|
+
const units = observation.units || [];
|
|
39
|
+
const complete = units.length === target.unitCount && new Set(units.map(u => u.index)).size === target.unitCount &&
|
|
40
|
+
units.every(u => Number.isSafeInteger(u.index) && u.index >= 1 && u.index <= target.unitCount &&
|
|
41
|
+
u.read === true && hashPattern.test(u.digest) &&
|
|
42
|
+
(!['pdf', 'pptx'].includes(target.kind) || u.rendered === true && hashPattern.test(u.renderDigest)));
|
|
43
|
+
const reasons = [];
|
|
44
|
+
if (observation.destination !== target.destination) reasons.push('destination_mismatch');
|
|
45
|
+
if (observation.digest !== target.digest) reasons.push('revision_mismatch');
|
|
46
|
+
if (!complete || observation.unitCount !== target.unitCount) reasons.push('incomplete_coverage');
|
|
47
|
+
if (complete && units.some(u => typeof u.text !== 'string' || !u.text.includes(target.expectedText[u.index - 1]))) reasons.push('content_mismatch');
|
|
48
|
+
observation = { ...observation, status: reasons.length ? 'unmet' : 'verified', reasons };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
observation = { status: 'unconfirmed', reasons: [error.code === 'ENOENT' ? 'reader_or_renderer_unavailable' :
|
|
52
|
+
['unsupported_kind', 'outside_root', 'not_regular_file', 'too_large', 'invalid_pdf', 'changed_during_read'].includes(error.code) ? error.code : 'observation_failed'] };
|
|
53
|
+
}
|
|
54
|
+
await authorize(target); signal?.throwIfAborted();
|
|
55
|
+
targets.push({ ...observation, id: target.id, requirementId: target.requirementId, kind: target.kind,
|
|
56
|
+
evidenceKind: ['pdf', 'pptx'].includes(target.kind) ? 'artifact_render_readback' : 'artifact_readback',
|
|
57
|
+
source: 'controller_read_only_observer', observedAt: now() });
|
|
58
|
+
}
|
|
59
|
+
const status = targets.some(t => t.status === 'unmet') ? 'unmet' : targets.every(t => t.status === 'verified') ? 'verified' : 'unconfirmed';
|
|
60
|
+
const manifest = { schemaVersion: 1, revision: snapshot.revision, contractDigest, status,
|
|
61
|
+
expectedCount: snapshot.targets.length, inspectedCount: targets.filter(t => t.units?.length).length, targets,
|
|
62
|
+
scope: 'declared_destination_bytes_unit_coverage_and_text_only' };
|
|
63
|
+
const result = { ...manifest, digest: digest(canonicalize(manifest)) };
|
|
64
|
+
collectedManifests.add(result);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function scopedRead(root, destination) {
|
|
69
|
+
const base = await realpath(root);
|
|
70
|
+
const requested = path.resolve(base, destination);
|
|
71
|
+
const resolved = await realpath(requested);
|
|
72
|
+
if (!resolved.startsWith(`${base}${path.sep}`)) throw fail('outside_root');
|
|
73
|
+
const stat = await lstat(requested);
|
|
74
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw fail('not_regular_file');
|
|
75
|
+
if (stat.size > 32 * 1024 * 1024) throw fail('too_large');
|
|
76
|
+
const bytes = await readFile(resolved);
|
|
77
|
+
if (bytes.length > 32 * 1024 * 1024) throw fail('too_large');
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function localArtifactAdapter(target, { root, signal, authorize, execute = exec } = {}) {
|
|
82
|
+
const bytes = await scopedRead(root, target.destination);
|
|
83
|
+
const sourceDigest = digest(bytes);
|
|
84
|
+
let units;
|
|
85
|
+
if (target.kind === 'file' || target.kind === 'code') {
|
|
86
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
87
|
+
units = [{ index: 1, read: true, digest: sourceDigest, text }];
|
|
88
|
+
} else if (target.kind === 'pdf' || target.kind === 'pptx') {
|
|
89
|
+
// Render an immutable snapshot; never allow document content to choose commands.
|
|
90
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'simy-artifact-observer-'));
|
|
91
|
+
try {
|
|
92
|
+
const input = path.join(temp, 'input.pdf');
|
|
93
|
+
const options = { signal, timeout: 30000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } };
|
|
94
|
+
let slideCount = null;
|
|
95
|
+
if (target.kind === 'pptx') {
|
|
96
|
+
const presentation = path.join(temp, 'input.pptx');
|
|
97
|
+
await writeFile(presentation, bytes, { mode: 0o600 });
|
|
98
|
+
const listing = await execute('unzip', ['-Z1', presentation], options);
|
|
99
|
+
slideCount = listing.stdout.split(/\r?\n/).filter(name => /^ppt\/slides\/slide[1-9]\d*\.xml$/.test(name)).length;
|
|
100
|
+
if (!slideCount || slideCount > 500) throw fail('invalid_pdf');
|
|
101
|
+
await authorize(target); signal?.throwIfAborted();
|
|
102
|
+
await execute('soffice', [`-env:UserInstallation=${pathToFileURL(path.join(temp, 'profile')).href}`,
|
|
103
|
+
'--headless', '--convert-to', 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
|
|
104
|
+
'--outdir', temp, presentation], { ...options, timeout: 60000 });
|
|
105
|
+
} else await writeFile(input, bytes, { mode: 0o600 });
|
|
106
|
+
const info = await execute('pdfinfo', [input], options);
|
|
107
|
+
const count = Number(info.stdout.match(/^Pages:\s+(\d+)\s*$/m)?.[1]);
|
|
108
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > 500 || slideCount !== null && count !== slideCount) throw fail('invalid_pdf');
|
|
109
|
+
units = [];
|
|
110
|
+
for (let index = 1; index <= count; index++) {
|
|
111
|
+
await authorize(target); signal?.throwIfAborted();
|
|
112
|
+
const prefix = path.join(temp, `page-${index}`);
|
|
113
|
+
await execute('pdftoppm', ['-f', String(index), '-l', String(index), '-singlefile', '-scale-to', '1200', '-png', input, prefix], options);
|
|
114
|
+
const png = await readFile(`${prefix}.png`);
|
|
115
|
+
const image = PNG.sync.read(png);
|
|
116
|
+
if (!image.width || !image.height) throw fail('invalid_pdf');
|
|
117
|
+
const { stdout: text } = await execute('pdftotext', ['-f', String(index), '-l', String(index), input, '-'], options);
|
|
118
|
+
units.push({ index, read: true, rendered: true, digest: digest(text), renderDigest: digest(png), text });
|
|
119
|
+
}
|
|
120
|
+
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
121
|
+
} else throw fail('unsupported_kind');
|
|
122
|
+
if (digest(await scopedRead(root, target.destination)) !== sourceDigest) throw fail('changed_during_read');
|
|
123
|
+
return { destination: target.destination, digest: sourceDigest, unitCount: units.length, units };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Only consume an in-memory controller-produced manifest after current-contract
|
|
127
|
+
// comparison. The digest detects corruption, not authenticity of a worker JSON file.
|
|
128
|
+
export function artifactRequirementEvidence(manifest, contract) {
|
|
129
|
+
if (!manifest || !collectedManifests.has(manifest)) return [];
|
|
130
|
+
const { digest: claimed, ...payload } = manifest;
|
|
131
|
+
if (claimed !== digest(canonicalize(payload)) || manifest.contractDigest !== digest(canonicalize(contract)) ||
|
|
132
|
+
manifest.revision !== contract.revision || manifest.status !== 'verified' ||
|
|
133
|
+
manifest.expectedCount !== contract.targets.length || manifest.targets.length !== contract.targets.length) return [];
|
|
134
|
+
return manifest.targets.map(target => ({ requirementId: target.requirementId, observations: [{
|
|
135
|
+
ref: `artifact:${target.id}`, target: target.destination, digest: target.digest,
|
|
136
|
+
scope: manifest.scope,
|
|
137
|
+
}] }));
|
|
138
|
+
}
|