@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.133
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/CHANGELOG.md +666 -0
- package/README.md +101 -1
- package/dist/build-info.json +10 -10
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +306 -0
- package/dist/cli/help.js +88 -12
- package/dist/cli/hook-ingress.js +78 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +80 -0
- package/dist/cli/run-cli.js +1465 -450
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +52 -0
- package/dist/cli/runtime-process.js +153 -0
- package/dist/cli.js +17 -2
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +190 -0
- package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +1108 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
- package/dist/harness-runtime/lib/model-observations.mjs +101 -0
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.d.mts +5 -0
- package/dist/harness-runtime/lib/process-json.mjs +201 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
- package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-verification.schema.json +7 -5
- package/dist/schemas/code-work-batch.schema.json +2 -2
- package/dist/schemas/flow-run.schema.json +2 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +118 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/branch-context.js +3 -3
- package/dist/services/canon.js +11 -8
- package/dist/services/check-runner-evidence.js +121 -0
- package/dist/services/cleanup.js +133 -40
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +851 -109
- package/dist/services/codex-capacity.js +367 -0
- package/dist/services/codex-hook-delivery.js +32 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/compatibility-preflight.js +13 -3
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +119 -0
- package/dist/services/dashboard.js +135 -56
- package/dist/services/engines.js +245 -119
- package/dist/services/eval-snapshots.js +1146 -105
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +230 -0
- package/dist/services/harness-adapter.js +329 -36
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +747 -325
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +1 -288
- package/dist/services/lifecycle-contract.js +1 -0
- package/dist/services/lifecycle-invocations.js +1774 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-lease-monitor.js +133 -0
- package/dist/services/managed-processes.js +409 -51
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +163 -35
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +28 -17
- package/dist/services/protocols.js +163 -87
- package/dist/services/recovery-observation-budget.js +121 -0
- package/dist/services/recovery-snapshot-database.js +145 -0
- package/dist/services/repair-intents.js +42 -0
- package/dist/services/review-copy.js +157 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +411 -0
- package/dist/services/run-control.js +985 -0
- package/dist/services/run-controller-adapter.js +399 -0
- package/dist/services/run-controller-capture.js +179 -0
- package/dist/services/run-controller-process.js +187 -0
- package/dist/services/run-controller-recovery.js +289 -0
- package/dist/services/run-controller-state.js +97 -0
- package/dist/services/run-controller.js +1035 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +147 -0
- package/dist/services/run-observations.js +152 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +112 -0
- package/dist/services/run-recovery.js +349 -0
- package/dist/services/runs.js +558 -217
- package/dist/services/runtime-budget.js +430 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +468 -0
- package/dist/services/runtime-scope-resume.js +599 -0
- package/dist/services/runtime-scope-stop.js +180 -0
- package/dist/services/runtime-scope-worker.js +287 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +27 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +185 -19
- package/dist/services/stage-lifecycle.js +98 -107
- package/dist/services/stage-pause.js +89 -75
- package/dist/services/stage-report-renderer.js +158 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/tooling.js +32 -23
- package/dist/services/usage.js +49 -101
- package/dist/services/verification-matrix.js +374 -0
- package/dist/services/vnext-code-review.js +222 -87
- package/dist/services/vnext-code.js +324 -251
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +79 -4
- package/dist/services/vnext-fanout.js +177 -17
- package/dist/services/vnext-merge.js +335 -113
- package/dist/services/vnext-plan-review.js +276 -126
- package/dist/services/vnext-plan.js +227 -102
- package/dist/services/vnext-protocolize.js +335 -82
- package/dist/services/vnext-specify.js +119 -81
- package/dist/services/work-registry.js +1038 -192
- package/dist/services/workspace-bootstrap.js +87 -0
- package/dist/services/workspace-files.js +85 -0
- package/dist/services/worktrees.js +98 -44
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +17 -0
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +781 -37
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +30 -13
- package/tools/repair-paused-run-status.mjs +84 -0
package/dist/services/hooks.js
CHANGED
|
@@ -2,18 +2,23 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { parse, stringify } from "smol-toml";
|
|
5
|
+
import { parse, stringify, TomlError } from "smol-toml";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
7
8
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
|
-
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
10
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
9
11
|
import { appendAudit } from "./audit.js";
|
|
12
|
+
import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
|
|
10
13
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
-
import {
|
|
14
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
15
|
+
import { observeLifecycleInvocation, observedLifecycleInvocation, managedNativeInvocationScope } from "./lifecycle-invocations.js";
|
|
16
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
|
|
12
17
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
|
-
import { activeFlowSessionsForProject,
|
|
18
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
14
19
|
const defaultProfileName = "default";
|
|
15
20
|
const maxSanitizedSummaryLength = 1600;
|
|
16
|
-
const
|
|
21
|
+
export const codexHomeSharedEntries = [
|
|
17
22
|
"auth.json",
|
|
18
23
|
"auth.bk",
|
|
19
24
|
"sessions",
|
|
@@ -50,22 +55,28 @@ export function planCodexHome(context, input) {
|
|
|
50
55
|
config_path: paths.configPath,
|
|
51
56
|
hooks_path: paths.hooksPath,
|
|
52
57
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
53
|
-
shared_entries:
|
|
58
|
+
shared_entries: codexHomeSharedEntries,
|
|
54
59
|
exists: fs.existsSync(paths.targetHome),
|
|
55
60
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
|
-
export function
|
|
63
|
+
export function prepareCodexHome(context, input) {
|
|
59
64
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
60
65
|
const profile = resolveProfileName(input.profile);
|
|
61
66
|
const paths = resolveHomePaths(context, project, profile, input);
|
|
67
|
+
if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
|
|
68
|
+
throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
|
|
69
|
+
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
70
|
+
return { project, profile, paths, config };
|
|
71
|
+
}
|
|
72
|
+
export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
|
|
73
|
+
const { project, profile, paths, config } = prepared;
|
|
62
74
|
const now = context.now();
|
|
63
75
|
ensureDir(paths.targetHome);
|
|
64
76
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
65
77
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
66
78
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
67
79
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
68
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
69
80
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
70
81
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
71
82
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -136,7 +147,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
136
147
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
137
148
|
};
|
|
138
149
|
}
|
|
139
|
-
export function
|
|
150
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
140
151
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
141
152
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
142
153
|
}
|
|
@@ -144,9 +155,30 @@ export function removeCodexHome(context, input) {
|
|
|
144
155
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
145
156
|
const profile = resolveProfileName(input.profile);
|
|
146
157
|
const record = homeProfile(context, project.id, profile);
|
|
158
|
+
if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
|
|
159
|
+
let marker;
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
163
|
+
throw new Error("Invalid marker");
|
|
164
|
+
marker = value;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
|
|
168
|
+
}
|
|
169
|
+
if (marker.managed_by !== "dd-flow-cli" || marker.project_id !== project.id || marker.profile !== profile || marker.target_home !== record.target_home || marker.source_home !== record.source_home || canonicalPath(record.target_home, false) === canonicalPath(record.source_home, false)) {
|
|
170
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { project, profile, record, mode };
|
|
174
|
+
}
|
|
175
|
+
export function removeCodexHome(context, input) {
|
|
176
|
+
const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
|
|
147
177
|
if (!record) {
|
|
148
178
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
149
179
|
}
|
|
180
|
+
if (record.status === "removed")
|
|
181
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
150
182
|
const removedEntries = [];
|
|
151
183
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
152
184
|
const target = path.join(record.target_home, relative);
|
|
@@ -207,7 +239,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
207
239
|
recorded
|
|
208
240
|
};
|
|
209
241
|
}
|
|
210
|
-
export function
|
|
242
|
+
export function prepareCodexHooks(context, input) {
|
|
211
243
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
212
244
|
const target = resolveHookTarget(input.target);
|
|
213
245
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -217,9 +249,16 @@ export function installCodexHooks(context, input) {
|
|
|
217
249
|
hooks_path: location.hooksPath
|
|
218
250
|
});
|
|
219
251
|
}
|
|
220
|
-
const
|
|
221
|
-
const
|
|
252
|
+
const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
|
|
253
|
+
const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
|
|
254
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing) || (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== "object" || Array.isArray(existing.hooks) || Object.values(existing.hooks).some(entries => !Array.isArray(entries)))))
|
|
255
|
+
return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
|
|
256
|
+
return { project, target, location, existing, text };
|
|
257
|
+
}
|
|
258
|
+
export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
259
|
+
const { project, target, location, existing, text } = prepared;
|
|
222
260
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
261
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
223
262
|
ensureDir(path.dirname(location.hooksPath));
|
|
224
263
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
225
264
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -238,20 +277,11 @@ export function installCodexHooks(context, input) {
|
|
|
238
277
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
239
278
|
};
|
|
240
279
|
}
|
|
241
|
-
export function removeCodexHooks(context, input) {
|
|
242
|
-
const project
|
|
243
|
-
const target = resolveHookTarget(input.target);
|
|
244
|
-
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
245
|
-
if (target === "default" && !input.yes) {
|
|
246
|
-
throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
|
|
247
|
-
target,
|
|
248
|
-
hooks_path: location.hooksPath
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
const existing = readJsonIfExists(location.hooksPath) ?? {};
|
|
280
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
281
|
+
const { project, target, location, existing, text } = prepared;
|
|
252
282
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
253
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
254
283
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
284
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
255
285
|
ensureDir(path.dirname(location.hooksPath));
|
|
256
286
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
257
287
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -274,98 +304,148 @@ export function handleCodexHook(context, input) {
|
|
|
274
304
|
// therefore the only valid identity for a fresh Work.
|
|
275
305
|
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
276
306
|
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
277
|
-
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
278
307
|
const turnId = stringValue(payload.turn_id);
|
|
279
308
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
280
309
|
const command = lifecycleCommandFromPayload(payload);
|
|
281
310
|
if (!command)
|
|
282
311
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
283
312
|
const lifecycle = lifecycleFacts(command);
|
|
284
|
-
if (!lifecycle)
|
|
313
|
+
if (!lifecycle && !continuationCommand(command))
|
|
285
314
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
315
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
286
316
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
287
317
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
permissionDecision: "deny",
|
|
298
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
299
|
-
}
|
|
300
|
-
};
|
|
318
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
319
|
+
const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
|
|
320
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
321
|
+
const proof = context.codexHookProof;
|
|
322
|
+
if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
|
|
323
|
+
|| proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
|
|
324
|
+
|| proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
|
|
325
|
+
throw new AppError("native_hook_unproven", "Managed Codex admission requires its active native hook delivery, not a manual handler call", 1, { ...(invocationId ? { invocation_id: invocationId } : {}), effect: "no_effect" });
|
|
326
|
+
}
|
|
301
327
|
}
|
|
302
|
-
const
|
|
303
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
328
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
304
329
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
305
330
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
306
|
-
|
|
307
|
-
|
|
331
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
332
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
333
|
+
registerProject(context, { root: bootstrapRoot });
|
|
308
334
|
}
|
|
309
|
-
const project = projectForHook(context,
|
|
310
|
-
if (!project)
|
|
335
|
+
const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
|
|
336
|
+
if (!project) {
|
|
337
|
+
if (invocationId)
|
|
338
|
+
throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
|
|
311
339
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
312
|
-
|
|
340
|
+
}
|
|
341
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
|
|
342
|
+
// Codex retains the root session_id for a thread-spawned child, while the
|
|
343
|
+
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
344
|
+
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
345
|
+
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
313
346
|
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
314
347
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
protocolId,
|
|
329
|
-
harness: "codex-desktop",
|
|
330
|
-
providerSessionId: providerSessionId ?? null,
|
|
331
|
-
parentSessionId,
|
|
332
|
-
sessionId: effectiveSessionId,
|
|
333
|
-
agentId: rawChildProviderSessionId ?? null,
|
|
334
|
-
turnId: turnId ?? null,
|
|
335
|
-
eventName,
|
|
336
|
-
toolName: toolName ?? null,
|
|
337
|
-
status: "observed",
|
|
338
|
-
payload,
|
|
339
|
-
eventKey,
|
|
340
|
-
matchKey,
|
|
341
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
342
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
343
|
-
});
|
|
344
|
-
if (effectiveSessionId) {
|
|
345
|
-
recordFlowSessionObservation(context, {
|
|
348
|
+
const eventCwd = stringValue(payload.cwd) ?? project.root;
|
|
349
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: eventCwd });
|
|
350
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
351
|
+
// Resolve legacy transcript discovery before acquiring the receipt write lock.
|
|
352
|
+
const transcriptPath = stringValue(payload.transcript_path)
|
|
353
|
+
?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
|
|
354
|
+
const persist = (scope) => {
|
|
355
|
+
if (scope)
|
|
356
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
|
|
357
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
|
|
358
|
+
const effectiveSessionId = storageId;
|
|
359
|
+
const protocolId = binding?.protocol_id ?? null;
|
|
360
|
+
const inserted = recordHookEvent(context, {
|
|
346
361
|
projectId: project.id,
|
|
347
|
-
sessionId: effectiveSessionId,
|
|
348
|
-
runId: observedSession?.run_id ?? null,
|
|
349
362
|
protocolId,
|
|
350
|
-
|
|
363
|
+
harness: "codex-desktop",
|
|
364
|
+
providerSessionId: providerSessionId ?? null,
|
|
365
|
+
parentSessionId,
|
|
366
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
367
|
+
// state or an agent-supplied shell command/payload.
|
|
368
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
369
|
+
sessionId: effectiveSessionId,
|
|
370
|
+
agentId: rawChildProviderSessionId ?? null,
|
|
371
|
+
turnId: turnId ?? null,
|
|
372
|
+
eventName,
|
|
351
373
|
toolName: toolName ?? null,
|
|
352
|
-
|
|
374
|
+
status: "observed",
|
|
375
|
+
payload,
|
|
376
|
+
eventKey,
|
|
377
|
+
matchKey,
|
|
378
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
379
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
353
380
|
});
|
|
354
|
-
|
|
355
|
-
|
|
381
|
+
if (effectiveSessionId) {
|
|
382
|
+
recordFlowSessionObservation(context, {
|
|
383
|
+
projectId: project.id,
|
|
384
|
+
sessionId: effectiveSessionId,
|
|
385
|
+
protocolId,
|
|
386
|
+
cwd: eventCwd,
|
|
387
|
+
toolName: toolName ?? null,
|
|
388
|
+
eventKey
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return { inserted, protocolId };
|
|
392
|
+
};
|
|
393
|
+
const common = {
|
|
356
394
|
ok: true,
|
|
357
|
-
observed: inserted,
|
|
358
|
-
duplicate: !inserted,
|
|
359
395
|
event_key: eventKey,
|
|
360
396
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
361
397
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
362
|
-
protocol_id:
|
|
363
|
-
|
|
398
|
+
protocol_id: null
|
|
399
|
+
};
|
|
400
|
+
if (!lifecycle) {
|
|
401
|
+
if (providerSessionId)
|
|
402
|
+
assignedLifecycleCwd(context, project.root, null, providerSessionId, command, stringValue(payload.cwd));
|
|
403
|
+
const persisted = context.db.writeTransaction(() => persist());
|
|
404
|
+
return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
|
|
405
|
+
}
|
|
406
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
407
|
+
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
408
|
+
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
409
|
+
if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
|
|
410
|
+
throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
|
|
411
|
+
const executionCwd = assignedLifecycleCwd(context, project.root, lifecycle, providerSessionId, command, stringValue(payload.cwd), { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId }, "codex-desktop");
|
|
412
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
413
|
+
...(invocationId ? { id: invocationId } : {}),
|
|
414
|
+
command,
|
|
415
|
+
harness: "codex-desktop",
|
|
416
|
+
projectId: project.id,
|
|
417
|
+
...(executionCwd ? { executionCwd } : {}),
|
|
418
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
|
|
419
|
+
recordReceipt: scope => { persist(scope); return eventKey; }
|
|
420
|
+
});
|
|
421
|
+
const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
422
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
423
|
+
if (admittedInvocationId)
|
|
424
|
+
return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
425
|
+
return {
|
|
426
|
+
...common,
|
|
427
|
+
protocol_id: stored?.protocol_id ?? null,
|
|
428
|
+
observed: !receipt.duplicate,
|
|
429
|
+
duplicate: receipt.duplicate,
|
|
430
|
+
hookSpecificOutput: {
|
|
431
|
+
hookEventName: "PreToolUse",
|
|
432
|
+
permissionDecision: "allow",
|
|
433
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const persisted = persist();
|
|
438
|
+
return {
|
|
439
|
+
...common,
|
|
440
|
+
protocol_id: persisted.protocolId,
|
|
441
|
+
observed: persisted.inserted,
|
|
442
|
+
duplicate: !persisted.inserted,
|
|
443
|
+
...(providerSessionId
|
|
364
444
|
? {
|
|
365
445
|
hookSpecificOutput: {
|
|
366
446
|
hookEventName: "PreToolUse",
|
|
367
447
|
permissionDecision: "allow",
|
|
368
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
448
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
369
449
|
}
|
|
370
450
|
}
|
|
371
451
|
: {})
|
|
@@ -377,7 +457,24 @@ function codexNativeParentId(payload, childId) {
|
|
|
377
457
|
if (!transcript || !path.isAbsolute(transcript))
|
|
378
458
|
return null;
|
|
379
459
|
try {
|
|
380
|
-
const
|
|
460
|
+
const descriptor = fs.openSync(transcript, "r");
|
|
461
|
+
const chunks = [];
|
|
462
|
+
try {
|
|
463
|
+
for (;;) {
|
|
464
|
+
const buffer = Buffer.alloc(4096);
|
|
465
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
466
|
+
if (!length)
|
|
467
|
+
break;
|
|
468
|
+
const newline = buffer.subarray(0, length).indexOf(10);
|
|
469
|
+
chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
|
|
470
|
+
if (newline >= 0)
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
fs.closeSync(descriptor);
|
|
476
|
+
}
|
|
477
|
+
const firstLine = Buffer.concat(chunks).toString("utf8");
|
|
381
478
|
if (!firstLine)
|
|
382
479
|
return null;
|
|
383
480
|
const record = JSON.parse(firstLine);
|
|
@@ -389,117 +486,140 @@ function codexNativeParentId(payload, childId) {
|
|
|
389
486
|
return null;
|
|
390
487
|
}
|
|
391
488
|
}
|
|
392
|
-
/**
|
|
489
|
+
/** Qualified ACP admission binds public commands to an exact issued assignment.
|
|
490
|
+
* Its provenance is an observation, never a synchronous PreToolUse assertion. */
|
|
393
491
|
export function handleZcodeEvent(context, input) {
|
|
394
492
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
395
|
-
|
|
493
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
494
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
396
495
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
400
|
-
return { ok: true, observed: false, reason: "
|
|
401
|
-
const updateMeta = objectRecord(update._meta);
|
|
402
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
403
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
404
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
405
|
-
const rawInput = objectRecord(update.rawInput);
|
|
496
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
497
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
498
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
499
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
406
500
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
407
|
-
|
|
501
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none", participation: "none" };
|
|
502
|
+
if (parsed.participation === "none" || parsed.participation === "unproven")
|
|
503
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
504
|
+
if (parsed.kind === "none" && !continuationCommand(command))
|
|
505
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle shell execution is unsupported", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
|
|
506
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
507
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
508
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
509
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
510
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
511
|
+
}) }, parsed.kind !== "none" ? commandOption(parsed.invocation, "invocation-id") : undefined);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
515
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
516
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
517
|
+
*/
|
|
518
|
+
export function handleZcodeHook(context, input) {
|
|
519
|
+
return recordZcodeLifecycle(context, input, undefined, true);
|
|
520
|
+
}
|
|
521
|
+
function recordZcodeLifecycle(context, input, invocationId, nativePreTool = false) {
|
|
522
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
523
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
524
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
525
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
526
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
527
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
528
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
529
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
408
530
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
409
|
-
}
|
|
410
531
|
const lifecycle = lifecycleFacts(command);
|
|
411
|
-
if (!lifecycle)
|
|
532
|
+
if (!lifecycle && !continuationCommand(command))
|
|
412
533
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
534
|
+
// ZCode can execute background Bash without emitting the ACP tool_call that admits an issued CLI invocation.
|
|
535
|
+
if (nativePreTool && lifecycle && rawInput.run_in_background === true) {
|
|
536
|
+
const reason = "Run dd-flow lifecycle commands in the foreground; ZCode background Bash does not provide the required admission receipt.";
|
|
537
|
+
return { continue: false, reason, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } };
|
|
417
538
|
}
|
|
418
|
-
|
|
539
|
+
if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
|
|
540
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
419
541
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
542
|
+
const meta = objectRecord(hook._meta);
|
|
543
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
544
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
545
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
546
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
547
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
548
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
549
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
425
550
|
}
|
|
426
|
-
|
|
427
|
-
|
|
551
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
552
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
553
|
+
}
|
|
554
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
555
|
+
if (!toolUseId)
|
|
556
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
557
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
558
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
559
|
+
if (lifecycle?.bootstrapStageStart)
|
|
428
560
|
registerProject(context, { root: expectedRoot });
|
|
429
561
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
430
|
-
const envelopeMeta = objectRecord(notification._meta);
|
|
431
|
-
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
432
|
-
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
433
|
-
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
434
|
-
const daemonId = stringValue(ddZcode.daemonId);
|
|
435
|
-
if (!rootProviderSessionId)
|
|
436
|
-
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
437
|
-
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
438
|
-
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
439
562
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
440
|
-
const parentSessionId =
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
model: stringValue(observedProfile.model),
|
|
461
|
-
reasoning: stringValue(observedProfile.reasoning),
|
|
462
|
-
mode: stringValue(observedProfile.mode),
|
|
463
|
-
agent_type: stringValue(runtime.source)
|
|
563
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
564
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
565
|
+
const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
|
|
566
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
567
|
+
const eventNameForReceipt = nativePreTool ? "PreToolUse" : "ToolCallObserved";
|
|
568
|
+
const persist = (scope) => {
|
|
569
|
+
if (scope)
|
|
570
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
571
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: observedCwd });
|
|
572
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
573
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
574
|
+
const inserted = recordHookEvent(context, {
|
|
575
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
576
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
577
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
578
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd
|
|
579
|
+
});
|
|
580
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
|
|
581
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
|
|
582
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
464
583
|
};
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
584
|
+
if (!lifecycle) {
|
|
585
|
+
assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
|
|
586
|
+
return context.db.writeTransaction(() => persist());
|
|
587
|
+
}
|
|
588
|
+
let persistedResult;
|
|
589
|
+
const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId }, "zcode-acp");
|
|
590
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
591
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
468
592
|
harness: "zcode-acp",
|
|
469
|
-
providerSessionId,
|
|
470
|
-
parentSessionId,
|
|
471
|
-
daemonId: daemonId ?? null,
|
|
472
|
-
sessionId,
|
|
473
|
-
agentId: agentId ?? null,
|
|
474
|
-
turnId: null,
|
|
475
|
-
eventName: "PreToolUse",
|
|
476
|
-
toolName: toolName ?? "Bash",
|
|
477
|
-
status: "observed",
|
|
478
|
-
payload,
|
|
479
|
-
eventKey,
|
|
480
|
-
matchKey,
|
|
481
|
-
transcriptPath: null,
|
|
482
|
-
cwd: expectedRoot
|
|
483
|
-
});
|
|
484
|
-
recordFlowSessionObservation(context, {
|
|
485
593
|
projectId: project.id,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
cwd: expectedRoot,
|
|
490
|
-
toolName: toolName ?? "Bash",
|
|
491
|
-
eventKey
|
|
594
|
+
...(executionCwd ? { executionCwd } : {}),
|
|
595
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
596
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
492
597
|
});
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
598
|
+
if (receipt.duplicate) {
|
|
599
|
+
// Keep first admission immutable. A second transport is independently
|
|
600
|
+
// retained corroboration, never another claim or an invented ACP cwd.
|
|
601
|
+
const retained = context.db.get("SELECT event_name, match_key, harness, provider_session_id, parent_session_id, daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
602
|
+
if (retained && retained.event_name !== eventNameForReceipt) {
|
|
603
|
+
if (retained.harness !== "zcode-acp" || retained.provider_session_id !== providerSessionId || retained.parent_session_id !== parentSessionId || retained.daemon_id !== daemonId) {
|
|
604
|
+
throw new AppError("hook_event_replay_mismatch", "Corroborating transport has different native ownership", 1, { event_key: receipt.eventKey });
|
|
605
|
+
}
|
|
606
|
+
context.db.writeTransaction(() => recordHookEvent(context, { projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
607
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? null, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "corroborating",
|
|
608
|
+
payload: { ...hook, command, corroborates_event_key: receipt.eventKey, admission_transport: nativePreTool ? "native_pre_tool" : "acp_observation" },
|
|
609
|
+
eventKey: `${receipt.eventKey}:${eventNameForReceipt}`, matchKey: null, transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd }));
|
|
610
|
+
}
|
|
611
|
+
else if (retained) {
|
|
612
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: receipt.eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId,
|
|
613
|
+
sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey: retained.match_key, cwd: observedCwd });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
617
|
+
if (admittedInvocationId)
|
|
618
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
619
|
+
// Without an issued managed attempt this is still ordinary hook evidence.
|
|
620
|
+
// Preserve persist()'s replay result instead of overwriting it with the
|
|
621
|
+
// admission layer's non-authoritative duplicate flag.
|
|
622
|
+
return persistedResult ?? persist();
|
|
503
623
|
}
|
|
504
624
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
505
625
|
export function handleGrokEvent(context, input) {
|
|
@@ -515,27 +635,18 @@ export function handleGrokEvent(context, input) {
|
|
|
515
635
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
516
636
|
}
|
|
517
637
|
const lifecycle = lifecycleFacts(command);
|
|
518
|
-
if (!lifecycle)
|
|
638
|
+
if (!lifecycle && !continuationCommand(command))
|
|
519
639
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
520
|
-
|
|
521
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
522
|
-
standalone_command: lifecycle.invocation.command
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
640
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
526
641
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
527
|
-
|
|
528
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
529
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
642
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
533
643
|
if (bootstrapStageStart)
|
|
534
644
|
registerProject(context, { root: expectedRoot });
|
|
535
645
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
536
646
|
const meta = objectRecord(hook._meta);
|
|
537
647
|
const ddGrok = objectRecord(meta.ddGrok);
|
|
538
648
|
const observedProfile = objectRecord(ddGrok.observedProfile);
|
|
649
|
+
const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
|
|
539
650
|
const rootProviderSessionId = stringValue(ddGrok.rootProviderSessionId);
|
|
540
651
|
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
541
652
|
if (!rootProviderSessionId || !providerSessionId)
|
|
@@ -545,41 +656,157 @@ export function handleGrokEvent(context, input) {
|
|
|
545
656
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
546
657
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
547
658
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
548
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
549
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
550
|
-
}, sessionId) : undefined;
|
|
551
659
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
552
|
-
harness: "grok-acp", providerSessionId,
|
|
660
|
+
harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
|
|
661
|
+
promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
553
662
|
})).digest("hex");
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
663
|
+
const persist = (scope) => {
|
|
664
|
+
if (scope)
|
|
665
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
666
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: observedCwd });
|
|
667
|
+
const payload = {
|
|
668
|
+
...hook, session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
669
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
|
|
670
|
+
};
|
|
671
|
+
const inserted = recordHookEvent(context, {
|
|
672
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
673
|
+
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
674
|
+
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: observedCwd
|
|
675
|
+
});
|
|
676
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
|
|
677
|
+
return inserted;
|
|
557
678
|
};
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
679
|
+
const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
680
|
+
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null });
|
|
681
|
+
if (!lifecycle) {
|
|
682
|
+
assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
|
|
683
|
+
return { ...result(context.db.writeTransaction(() => persist())), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
684
|
+
}
|
|
685
|
+
let inserted = false;
|
|
686
|
+
const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey }, "grok-acp");
|
|
687
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
688
|
+
command, harness: "grok-acp", projectId: project.id,
|
|
689
|
+
...(executionCwd ? { executionCwd } : {}),
|
|
690
|
+
identity: { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey },
|
|
691
|
+
recordReceipt: scope => { inserted = persist(scope); return eventKey; }
|
|
566
692
|
});
|
|
567
|
-
|
|
568
|
-
...(
|
|
569
|
-
return (
|
|
570
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
571
|
-
: result;
|
|
693
|
+
if (receipt.invocationId)
|
|
694
|
+
return { ...result(inserted), invocation_id: receipt.invocationId, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
695
|
+
return { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
572
696
|
}
|
|
573
697
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
574
698
|
export function handleOpenCodeEvent(context, input) {
|
|
575
699
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
576
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
577
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
578
700
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
579
701
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
702
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
703
|
+
}
|
|
704
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
705
|
+
export function handleDroidEvent(context, input) {
|
|
706
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
707
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
708
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
709
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
710
|
+
}
|
|
711
|
+
/** Stable project identity is not the native Session's execution workspace. */
|
|
712
|
+
function assignedLifecycleCwd(context, projectRoot, facts, providerSessionId, command, observedCwd, identity, harness) {
|
|
713
|
+
const auxiliary = continuationCommand(command);
|
|
714
|
+
if ((!facts && !auxiliary) || facts?.analysis.supported === false)
|
|
715
|
+
return undefined;
|
|
716
|
+
const proven = identity && harness ? managedNativeInvocationScope(context, projectRoot, identity, harness) : null;
|
|
717
|
+
const constraint = proven ?? (identity ? { projectRoot, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId } : undefined);
|
|
718
|
+
const issuedRunId = facts && !facts.runId && !facts.workId && !facts.bootstrapStageStart && constraint ? observedLifecycleInvocation(context, command, constraint, identity ? { identity } : undefined)?.scope.runId : null;
|
|
719
|
+
if (facts?.invocation.operation.startsWith("stage_") && !facts.bootstrapStageStart && !facts.runId && !issuedRunId)
|
|
720
|
+
throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
721
|
+
const expected = controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId);
|
|
722
|
+
// Unknown CLI targets have no assigned workspace. Recording their native
|
|
723
|
+
// observation must not turn the stable identity root into cwd authority.
|
|
724
|
+
if (!expected)
|
|
725
|
+
return undefined;
|
|
726
|
+
const route = facts?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
|
|
727
|
+
if (route && resolveProjectRoot(route) !== expected)
|
|
728
|
+
throw new AppError("invocation_directory_mismatch", "Literal lifecycle route differs from the assigned workspace", 1, { expected, actual: route, actual_provenance: "shell_literal_route", phase: "prepare", effect: "no_effect", recoverable: false });
|
|
729
|
+
if (!route && observedCwd && resolveProjectRoot(observedCwd) !== expected)
|
|
730
|
+
throw new AppError("invocation_directory_mismatch", "Native lifecycle directory differs from the assigned workspace", 1, { expected, actual: observedCwd, actual_provenance: "native_tool_cwd", phase: "prepare", effect: "no_effect", recoverable: false });
|
|
731
|
+
// ACP without native cwd still binds assigned authority; CLI confirms the
|
|
732
|
+
// physical process directory, rather than inventing native observation.
|
|
733
|
+
return expected;
|
|
734
|
+
}
|
|
735
|
+
function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
|
|
736
|
+
if (facts?.bootstrapStageStart)
|
|
737
|
+
return projectRoot;
|
|
738
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
739
|
+
if (!project)
|
|
740
|
+
return undefined;
|
|
741
|
+
if (facts?.invocation.operation === "merge_apply" || facts?.invocation.operation === "merge_repair") {
|
|
742
|
+
const requestId = commandPosition(facts.invocation, 0);
|
|
743
|
+
const request = requestId && context.db.get("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND merge_request_id = ?", [project.id, requestId]);
|
|
744
|
+
if (request)
|
|
745
|
+
return resolveProjectRoot(request.target_workspace);
|
|
746
|
+
}
|
|
747
|
+
let work;
|
|
748
|
+
if (facts?.workId) {
|
|
749
|
+
try {
|
|
750
|
+
work = resolveWorkReference(context, facts.workId, { projectId: project.id });
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
if (!work)
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
|
|
760
|
+
if (!reference)
|
|
761
|
+
return undefined;
|
|
762
|
+
const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
|
|
763
|
+
if (!run)
|
|
764
|
+
return undefined; // Hook ingress does not validate a mistyped CLI target.
|
|
765
|
+
if (work) {
|
|
766
|
+
// PROTOCOLIZE provisions the feature checkout after its coordinator
|
|
767
|
+
// Session has launched in the stable project root. Its transition Work
|
|
768
|
+
// must keep that physical route until the Stage is complete. Child Works
|
|
769
|
+
// share stage membership, not the root coordinator's execution directory.
|
|
770
|
+
if (work.parent_work_id === null && context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
|
|
771
|
+
&& context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
|
|
772
|
+
return projectRoot;
|
|
773
|
+
const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
|
|
774
|
+
if (merge)
|
|
775
|
+
return resolveProjectRoot(merge.target_workspace);
|
|
776
|
+
if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'work_launches'")) {
|
|
777
|
+
const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching', 'start_requested')", [work.work_id]);
|
|
778
|
+
if (launch?.provider_session_id === providerSessionId && run.run_root) {
|
|
779
|
+
const copy = JSON.parse(launch.profile_json).review_copy?.root;
|
|
780
|
+
if (copy) {
|
|
781
|
+
const target = path.resolve(run.run_root, copy);
|
|
782
|
+
const relative = path.relative(run.run_root, target);
|
|
783
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`))
|
|
784
|
+
throw new AppError("read_only_workspace_unbound", "Review copy escapes its owning RUN", 1);
|
|
785
|
+
return resolveProjectRoot(target);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (facts?.stageName === "specify" || facts?.stageName === "protocolize")
|
|
791
|
+
return projectRoot;
|
|
792
|
+
if (facts?.stageName === "merge" || facts?.invocation.operation === "merge_apply" || facts?.invocation.operation === "merge_repair") {
|
|
793
|
+
const pending = context.db.all("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND run_id = ? AND status IN ('dispatching','active','action_required') ORDER BY updated_at DESC", [project.id, run.id]);
|
|
794
|
+
const targets = [...new Set(pending.map(item => resolveProjectRoot(item.target_workspace)))];
|
|
795
|
+
if (targets.length > 1)
|
|
796
|
+
throw new AppError("merge_workspace_ambiguous", "Current MERGE requests disagree on the native execution workspace", 1, { run_id: run.id, target_workspaces: targets });
|
|
797
|
+
if (targets.length === 1)
|
|
798
|
+
return targets[0];
|
|
799
|
+
}
|
|
800
|
+
return resolveProjectRoot(run.workspace_root);
|
|
801
|
+
}
|
|
802
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
803
|
+
const agy = harness === "antigravity-cli";
|
|
804
|
+
const droid = harness === "droid-cli";
|
|
805
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
806
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
580
807
|
const phase = stringValue(event.phase);
|
|
581
808
|
if (phase !== "before" && phase !== "after")
|
|
582
|
-
throw new AppError(
|
|
809
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
583
810
|
const session = objectRecord(event.session);
|
|
584
811
|
const profile = objectRecord(event.profile);
|
|
585
812
|
const rawInput = objectRecord(event.input);
|
|
@@ -587,16 +814,16 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
587
814
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
588
815
|
const daemonId = stringValue(event.daemon_id);
|
|
589
816
|
const toolCallId = stringValue(event.tool_call_id);
|
|
590
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
817
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
591
818
|
const toolName = stringValue(event.tool);
|
|
592
819
|
const directory = stringValue(session.directory);
|
|
593
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
594
|
-
throw new AppError(
|
|
820
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
821
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
595
822
|
}
|
|
596
823
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
824
|
+
const observedCwd = resolveProjectRoot(directory);
|
|
825
|
+
if (droid && nativeParentId === providerSessionId)
|
|
826
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
600
827
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
601
828
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
602
829
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -606,67 +833,123 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
606
833
|
const inserted = recordHookEvent(context, {
|
|
607
834
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
608
835
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
609
|
-
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd:
|
|
610
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd:
|
|
836
|
+
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: observedCwd, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
837
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
|
|
611
838
|
});
|
|
612
839
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
613
840
|
}
|
|
614
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
841
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
615
842
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
616
843
|
}
|
|
617
844
|
const lifecycle = lifecycleFacts(command);
|
|
618
|
-
|
|
845
|
+
const auxiliary = continuationCommand(command);
|
|
846
|
+
if (!lifecycle && !auxiliary)
|
|
619
847
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
620
|
-
|
|
621
|
-
|
|
848
|
+
// Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
|
|
849
|
+
// supply a route or execution authority, including a short public Stage call.
|
|
850
|
+
const shellRefusal = lifecycle?.analysis.supported === false;
|
|
851
|
+
const nativeIdentity = { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId };
|
|
852
|
+
const provenScope = lifecycle && !shellRefusal ? managedNativeInvocationScope(context, expectedRoot, nativeIdentity, harness) : null;
|
|
853
|
+
const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
|
|
854
|
+
? observedLifecycleInvocation(context, command, provenScope ?? { projectRoot: expectedRoot, daemonId, rootSessionId: nativeIdentity.rootSessionId }, { identity: nativeIdentity })?.scope.runId : null;
|
|
855
|
+
if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
|
|
856
|
+
throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { effect: "no_effect", recoverable: false });
|
|
857
|
+
}
|
|
858
|
+
// These controlled platforms additionally qualify their native launch
|
|
859
|
+
// route against the stable project root when no CLI target is assigned.
|
|
860
|
+
const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId) ?? expectedRoot;
|
|
861
|
+
const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
|
|
862
|
+
if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
|
|
863
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, "Literal shell cd route does not match the assigned lifecycle workspace", 1, { expected: expectedCwd, actual: executionRoute, actual_provenance: "shell_literal_route", effect: "no_effect", recoverable: false });
|
|
622
864
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
865
|
+
if (!executionRoute && observedCwd !== expectedCwd) {
|
|
866
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, effect: "no_effect", recoverable: false, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
|
|
626
867
|
}
|
|
627
|
-
const
|
|
868
|
+
const bootstrapStageStart = !shellRefusal && lifecycle?.bootstrapStageStart;
|
|
869
|
+
let matchKey = lifecycle && !shellRefusal ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
628
870
|
if (bootstrapStageStart)
|
|
629
871
|
registerProject(context, { root: expectedRoot });
|
|
630
872
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
873
|
+
if (droid) {
|
|
874
|
+
const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
|
|
875
|
+
if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
|
|
876
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
631
879
|
const agentId = stringValue(session.agent_id);
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
880
|
+
const persist = (scope) => {
|
|
881
|
+
if (scope)
|
|
882
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
883
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: observedCwd });
|
|
884
|
+
const payload = {
|
|
885
|
+
session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
|
|
886
|
+
...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
|
|
887
|
+
...(executionRoute ? { lifecycle_execution_cwd: expectedCwd, lifecycle_execution_cwd_provenance: "literal_cd_intent_requires_cli_confirmation" } : {}),
|
|
888
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
889
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
890
|
+
};
|
|
891
|
+
const inserted = recordHookEvent(context, {
|
|
892
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
893
|
+
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
894
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
|
|
895
|
+
});
|
|
896
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName, eventKey: baseKey });
|
|
897
|
+
return inserted;
|
|
642
898
|
};
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
899
|
+
const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
|
|
900
|
+
if (lifecycle) {
|
|
901
|
+
let inserted = false;
|
|
902
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
903
|
+
command, harness, projectId: project.id, ...(!shellRefusal ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
|
|
904
|
+
recordReceipt: scope => { inserted = persist(scope); return baseKey; }
|
|
905
|
+
});
|
|
906
|
+
const admitted = { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
|
|
907
|
+
return agy || shellRefusal ? admitted : { ...admitted, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
908
|
+
}
|
|
909
|
+
const inserted = context.db.writeTransaction(() => persist());
|
|
910
|
+
return agy ? result(inserted) : { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
651
911
|
}
|
|
652
912
|
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
653
913
|
export function handleAgyEvent(context, input) {
|
|
654
914
|
const event = parseJsonObject(input.stdin || "{}", "Antigravity tool event");
|
|
655
915
|
if (event.schema_id !== "dd-flow/agy-tool-event@1")
|
|
656
916
|
throw new AppError("unsupported_agy_event_schema", "Unsupported Antigravity event schema", 1);
|
|
657
|
-
|
|
658
|
-
|
|
917
|
+
// workspacePaths lists available workspaces; its first element is not a
|
|
918
|
+
// native selected-cwd observation. The daemon's spawn cwd is a separate,
|
|
919
|
+
// launch-bound route, and the list only corroborates its membership.
|
|
920
|
+
const workspacePaths = Array.isArray(event.workspace_paths)
|
|
921
|
+
? event.workspace_paths.filter((value) => typeof value === "string" && path.isAbsolute(value)) : [];
|
|
922
|
+
const launchCwd = stringValue(event.launch_cwd);
|
|
923
|
+
const qualifiedLaunchCwd = launchCwd && path.isAbsolute(launchCwd) ? launchCwd : undefined;
|
|
924
|
+
const directory = qualifiedLaunchCwd ?? (workspacePaths.length === 1 ? workspacePaths[0] : undefined);
|
|
925
|
+
if (!directory && workspacePaths.length > 1)
|
|
926
|
+
throw new AppError("agy_workspace_route_unproven", "Antigravity did not identify the active route among multiple workspaces", 1, { workspace_paths: workspacePaths });
|
|
927
|
+
if (directory && qualifiedLaunchCwd && workspacePaths.length
|
|
928
|
+
&& !workspacePaths.some(value => resolveProjectRoot(value) === resolveProjectRoot(directory))) {
|
|
929
|
+
throw new AppError("agy_workspace_route_unproven", "Antigravity launch cwd is absent from the native workspace list", 1, { launch_cwd: directory, workspace_paths: workspacePaths });
|
|
930
|
+
}
|
|
659
931
|
const providerSessionId = stringValue(event.conversation_id);
|
|
660
932
|
const daemonId = stringValue(event.daemon_id);
|
|
661
933
|
const eventId = stringValue(event.event_id);
|
|
662
934
|
const toolName = stringValue(event.tool);
|
|
663
935
|
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
664
936
|
throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
|
|
937
|
+
const rawInput = objectRecord(event.input);
|
|
938
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
939
|
+
if (command && (lifecycleFacts(command) || continuationCommand(command)) && !qualifiedLaunchCwd) {
|
|
940
|
+
throw new AppError("agy_workspace_route_unproven", "Managed Antigravity call has no launch-bound execution cwd", 1, { phase: "prepare", effect: "no_effect", recoverable: false, workspace_paths: workspacePaths });
|
|
941
|
+
}
|
|
942
|
+
const invalidStep = !Number.isSafeInteger(event.step_index) || Number(event.step_index) < 0;
|
|
943
|
+
const invalidExecution = event.execution_num !== undefined && (!Number.isSafeInteger(event.execution_num) || Number(event.execution_num) < 0);
|
|
944
|
+
if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command)) && (invalidStep || invalidExecution)) {
|
|
945
|
+
throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require a native step identity and a valid execution identity when provided", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
946
|
+
}
|
|
665
947
|
const translated = {
|
|
666
948
|
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
667
949
|
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
668
950
|
message_id: event.step_index === undefined ? null : String(event.step_index),
|
|
669
951
|
session: { provider_session_id: providerSessionId, parent_provider_session_id: event.parent_conversation_id, directory },
|
|
952
|
+
cwd_provenance: qualifiedLaunchCwd ? "launch_bound" : "workspace_paths_singleton",
|
|
670
953
|
profile: { provider: objectRecord(event.profile).provider, model: objectRecord(event.profile).model, variant: objectRecord(event.profile).reasoning, agent: objectRecord(event.profile).mode },
|
|
671
954
|
outcome: { status: stringValue(event.error) ? "error" : "completed" }
|
|
672
955
|
};
|
|
@@ -694,15 +977,25 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
694
977
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
695
978
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
696
979
|
*/
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
980
|
+
/**
|
|
981
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
982
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
983
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
984
|
+
*/
|
|
985
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
702
986
|
const analysis = parseLifecycleCommand(command);
|
|
703
|
-
if (analysis.kind
|
|
987
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
704
988
|
return command;
|
|
705
|
-
|
|
989
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
990
|
+
return command;
|
|
991
|
+
}
|
|
992
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
993
|
+
if (analysis.invocation.rewriteSafe) {
|
|
994
|
+
const index = analysis.invocation.sourceRange?.end ?? shellSuffixOffset(command);
|
|
995
|
+
const suffix = command.slice(index);
|
|
996
|
+
return `${command.slice(0, index)} ${option}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
|
|
997
|
+
}
|
|
998
|
+
return command;
|
|
706
999
|
}
|
|
707
1000
|
/**
|
|
708
1001
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -719,29 +1012,46 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
|
|
|
719
1012
|
}
|
|
720
1013
|
function lifecycleFacts(command) {
|
|
721
1014
|
const analysis = parseLifecycleCommand(command);
|
|
1015
|
+
if (analysis.kind === "none" && analysis.unresolved && ["lifecycle", "auxiliary"].includes(analysis.participation))
|
|
1016
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle execution is unsupported; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
|
|
722
1017
|
if (analysis.kind === "none")
|
|
723
1018
|
return null;
|
|
724
1019
|
const invocation = analysis.invocation;
|
|
725
1020
|
const stageStart = invocation.operation === "stage_start";
|
|
1021
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
1022
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
726
1023
|
const stageResume = invocation.operation === "stage_resume";
|
|
727
1024
|
const workStart = invocation.operation === "work_start";
|
|
1025
|
+
const workFinish = invocation.operation === "work_finish";
|
|
1026
|
+
const workFail = invocation.operation === "work_fail";
|
|
728
1027
|
return {
|
|
729
1028
|
analysis,
|
|
730
1029
|
invocation,
|
|
731
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
732
1030
|
stageStart,
|
|
1031
|
+
stageFinish,
|
|
1032
|
+
stagePause,
|
|
733
1033
|
stageResume,
|
|
734
|
-
workStart,
|
|
1034
|
+
workStart, workFinish, workFail,
|
|
735
1035
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
736
1036
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
737
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
738
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
739
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
1037
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
1038
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
1039
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
740
1040
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
741
1041
|
};
|
|
742
1042
|
}
|
|
743
|
-
function lifecycleMatchKey(facts, projectRoot) {
|
|
744
|
-
|
|
1043
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
1044
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
1045
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
1046
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
1047
|
+
return null;
|
|
1048
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
1049
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
1050
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
1051
|
+
if (invocation.operation === "recovery_accept") {
|
|
1052
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
1053
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
1054
|
+
}
|
|
745
1055
|
if (bootstrapStageStart)
|
|
746
1056
|
return bootstrapMatchKey({
|
|
747
1057
|
projectRoot,
|
|
@@ -749,14 +1059,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
749
1059
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
750
1060
|
...(contextSha256 ? { contextSha256 } : {})
|
|
751
1061
|
});
|
|
752
|
-
if (stageStart &&
|
|
753
|
-
return stageStartMatchKey(
|
|
754
|
-
if (
|
|
755
|
-
return
|
|
1062
|
+
if (stageStart && effectiveRunId && stageName)
|
|
1063
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
1064
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
1065
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
1066
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
1067
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
1068
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
1069
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
756
1070
|
if (workStart && workId)
|
|
757
|
-
return workStartMatchKey(workId,
|
|
1071
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
1072
|
+
if (workFinish && workId)
|
|
1073
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
1074
|
+
if (workFail && workId)
|
|
1075
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
758
1076
|
return null;
|
|
759
1077
|
}
|
|
1078
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
1079
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
1080
|
+
export function observeCommandHook(context, command) {
|
|
1081
|
+
const facts = lifecycleFacts(command);
|
|
1082
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
1083
|
+
return;
|
|
1084
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
1085
|
+
if (!project)
|
|
1086
|
+
return; // Bootstrap has not registered a project yet.
|
|
1087
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
1088
|
+
if (!matchKey)
|
|
1089
|
+
return;
|
|
1090
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
1091
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
1092
|
+
return;
|
|
1093
|
+
if (!key) {
|
|
1094
|
+
try {
|
|
1095
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return;
|
|
1099
|
+
} // Dispatch still owns its mandatory admission error.
|
|
1100
|
+
}
|
|
1101
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ? AND match_key = ? AND status = 'observed'", [project.id, key, matchKey]);
|
|
1102
|
+
if (event)
|
|
1103
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
1104
|
+
}
|
|
1105
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
1106
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
1107
|
+
}
|
|
1108
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
1109
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
1110
|
+
}
|
|
1111
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
1112
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
1113
|
+
}
|
|
1114
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
1115
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
1116
|
+
}
|
|
1117
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
1118
|
+
const runReferences = entityReferenceVariants(runId);
|
|
1119
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
1120
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
1121
|
+
}
|
|
1122
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
1123
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
1124
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
1125
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
1126
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
1127
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
1128
|
+
}).eventKey;
|
|
1129
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1130
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
1131
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
1132
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
1133
|
+
}
|
|
1134
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1135
|
+
if (claimed.changes !== 1)
|
|
1136
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
1137
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
1138
|
+
}
|
|
1139
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
1140
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
1141
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
1142
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
1143
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
1144
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
1145
|
+
}).eventKey;
|
|
1146
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1147
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
1148
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
1149
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
1150
|
+
}
|
|
1151
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1152
|
+
if (claimed.changes !== 1)
|
|
1153
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
1154
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
1155
|
+
}
|
|
760
1156
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
761
1157
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
762
1158
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -772,6 +1168,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
772
1168
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
773
1169
|
if (!event?.session_id)
|
|
774
1170
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
1171
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
775
1172
|
return {
|
|
776
1173
|
hookEventId: event.id,
|
|
777
1174
|
harness: event.harness,
|
|
@@ -808,21 +1205,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
808
1205
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
809
1206
|
export function findRecentMatchingHookEvent(context, input) {
|
|
810
1207
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1208
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1209
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1210
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1211
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1212
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1213
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1214
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1215
|
+
AND (? IS NULL OR session_id = ?)
|
|
1216
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1217
|
+
let events = find();
|
|
1218
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1219
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
817
1220
|
const deadline = Date.now() + 250;
|
|
818
|
-
while (
|
|
1221
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
819
1222
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
820
|
-
|
|
1223
|
+
events = find();
|
|
821
1224
|
}
|
|
822
|
-
if (!
|
|
823
|
-
throw new AppError(input.errorCode, `${input.operation} requires
|
|
1225
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1226
|
+
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
|
|
824
1227
|
}
|
|
825
|
-
|
|
1228
|
+
if (events.length > 1) {
|
|
1229
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
|
|
1230
|
+
}
|
|
1231
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
826
1232
|
}
|
|
827
1233
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
828
1234
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -845,9 +1251,9 @@ export function bootstrapMatchKey(input) {
|
|
|
845
1251
|
})).digest("hex");
|
|
846
1252
|
}
|
|
847
1253
|
export function claimWorkStartHookEvent(context, input) {
|
|
848
|
-
const expected =
|
|
1254
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
849
1255
|
const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
850
|
-
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.
|
|
1256
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
851
1257
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
852
1258
|
}
|
|
853
1259
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -855,15 +1261,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
855
1261
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
856
1262
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
857
1263
|
}
|
|
858
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
859
1264
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
860
1265
|
export function assertStageStartHookEvent(context, input) {
|
|
861
|
-
const expected =
|
|
1266
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
862
1267
|
const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
863
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
864
|
-
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey,
|
|
1268
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1269
|
+
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
865
1270
|
}
|
|
866
|
-
|
|
1271
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1272
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1273
|
+
return identity;
|
|
867
1274
|
}
|
|
868
1275
|
export function claimStageStartHookEvent(context, input) {
|
|
869
1276
|
assertStageStartHookEvent(context, input);
|
|
@@ -893,24 +1300,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
893
1300
|
}
|
|
894
1301
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
895
1302
|
export function claimStageResumeHookEvent(context, input) {
|
|
896
|
-
const
|
|
897
|
-
const
|
|
898
|
-
const event = input.eventKey
|
|
899
|
-
|
|
900
|
-
: context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
|
|
901
|
-
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
1303
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1304
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1305
|
+
const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1306
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
902
1307
|
throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
|
|
903
1308
|
}
|
|
1309
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1310
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
904
1311
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
905
1312
|
if (claimed.changes !== 1)
|
|
906
1313
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
907
|
-
return
|
|
1314
|
+
return identity;
|
|
908
1315
|
}
|
|
909
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1316
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
910
1317
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
911
1318
|
operation: "work_start",
|
|
912
1319
|
work_id: workId,
|
|
913
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1320
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1321
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
914
1322
|
})).digest("hex");
|
|
915
1323
|
}
|
|
916
1324
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -950,6 +1358,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
950
1358
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
951
1359
|
}
|
|
952
1360
|
function resolveProfileName(profile) {
|
|
1361
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1362
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
953
1363
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
954
1364
|
}
|
|
955
1365
|
function sanitizeProfile(value) {
|
|
@@ -977,11 +1387,12 @@ function readTomlIfExists(file) {
|
|
|
977
1387
|
if (!fs.existsSync(file)) {
|
|
978
1388
|
return {};
|
|
979
1389
|
}
|
|
1390
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
980
1391
|
try {
|
|
981
|
-
return parse(
|
|
1392
|
+
return parse(text);
|
|
982
1393
|
}
|
|
983
1394
|
catch (error) {
|
|
984
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1395
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
985
1396
|
}
|
|
986
1397
|
}
|
|
987
1398
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -1000,7 +1411,7 @@ function objectTable(value) {
|
|
|
1000
1411
|
function createSharedLinks(sourceHome, targetHome) {
|
|
1001
1412
|
const linked = [];
|
|
1002
1413
|
const skipped = [];
|
|
1003
|
-
for (const entry of
|
|
1414
|
+
for (const entry of codexHomeSharedEntries) {
|
|
1004
1415
|
const source = path.join(sourceHome, entry);
|
|
1005
1416
|
const target = path.join(targetHome, entry);
|
|
1006
1417
|
if (!fs.existsSync(source)) {
|
|
@@ -1023,7 +1434,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
1023
1434
|
return { linked, skipped };
|
|
1024
1435
|
}
|
|
1025
1436
|
function removeSharedSymlinks(targetHome) {
|
|
1026
|
-
for (const entry of
|
|
1437
|
+
for (const entry of codexHomeSharedEntries) {
|
|
1027
1438
|
const target = path.join(targetHome, entry);
|
|
1028
1439
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
1029
1440
|
fs.rmSync(target);
|
|
@@ -1036,7 +1447,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1036
1447
|
}
|
|
1037
1448
|
}
|
|
1038
1449
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1039
|
-
return
|
|
1450
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1040
1451
|
const source = path.join(sourceHome, entry);
|
|
1041
1452
|
const target = path.join(targetHome, entry);
|
|
1042
1453
|
return {
|
|
@@ -1057,11 +1468,15 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1057
1468
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1058
1469
|
}
|
|
1059
1470
|
function commandHook(command, statusMessage) {
|
|
1060
|
-
|
|
1471
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1472
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1473
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1474
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1061
1475
|
}
|
|
1062
1476
|
function hookCommand(event) {
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1477
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1478
|
+
const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else dd-flow ${args}; fi`;
|
|
1479
|
+
return `/bin/bash -c 'export PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin"; log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
|
|
1065
1480
|
}
|
|
1066
1481
|
function resolveHookTarget(target) {
|
|
1067
1482
|
if (!target || target === "isolated") {
|
|
@@ -1236,11 +1651,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1236
1651
|
function installationScope(target, profile, hooksPath) {
|
|
1237
1652
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1238
1653
|
}
|
|
1239
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1654
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1240
1655
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1241
1656
|
const now = context.now();
|
|
1242
1657
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1243
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1244
1658
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1245
1659
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1246
1660
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1294,12 +1708,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1294
1708
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1295
1709
|
}
|
|
1296
1710
|
function recordHookEvent(context, input) {
|
|
1297
|
-
|
|
1298
|
-
if (existing)
|
|
1711
|
+
if (assertHookEventReplay(context, input))
|
|
1299
1712
|
return false;
|
|
1300
1713
|
context.db.run(`INSERT INTO hook_events
|
|
1301
|
-
|
|
1302
|
-
|
|
1714
|
+
(project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
1715
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1303
1716
|
input.projectId,
|
|
1304
1717
|
input.protocolId,
|
|
1305
1718
|
input.harness,
|
|
@@ -1326,6 +1739,23 @@ function recordHookEvent(context, input) {
|
|
|
1326
1739
|
]);
|
|
1327
1740
|
return true;
|
|
1328
1741
|
}
|
|
1742
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1743
|
+
function assertHookEventReplay(context, input) {
|
|
1744
|
+
const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
|
|
1745
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1746
|
+
if (existing) {
|
|
1747
|
+
const observed = {
|
|
1748
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1749
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1750
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1751
|
+
};
|
|
1752
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1753
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1754
|
+
}
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1329
1759
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1330
1760
|
if (explicitRoot)
|
|
1331
1761
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1352,14 +1782,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1352
1782
|
command
|
|
1353
1783
|
})).digest("hex");
|
|
1354
1784
|
}
|
|
1355
|
-
function commandFromPayload(payload) {
|
|
1356
|
-
const direct = stringValue(payload.command);
|
|
1357
|
-
if (direct) {
|
|
1358
|
-
return direct;
|
|
1359
|
-
}
|
|
1360
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1361
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1362
|
-
}
|
|
1363
1785
|
function toolNameFromPayload(payload) {
|
|
1364
1786
|
const toolInput = objectRecord(payload.tool_input);
|
|
1365
1787
|
return stringValue(toolInput.name);
|
|
@@ -1402,10 +1824,10 @@ function writeJsonFile(file, value) {
|
|
|
1402
1824
|
ensureDir(path.dirname(file));
|
|
1403
1825
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1404
1826
|
}
|
|
1405
|
-
function backupFile(file, now) {
|
|
1827
|
+
function backupFile(file, now, text) {
|
|
1406
1828
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1407
1829
|
const backup = `${file}.${suffix}.bak`;
|
|
1408
|
-
fs.
|
|
1830
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1409
1831
|
return backup;
|
|
1410
1832
|
}
|
|
1411
1833
|
function hashObject(value) {
|