@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +81 -9
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1342 -375
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -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 +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -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/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +553 -285
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +87 -7
- package/dist/services/lifecycle-invocations.js +828 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +779 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +342 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +65 -23
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +189 -85
- package/dist/services/vnext-code.js +257 -243
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +134 -19
- package/dist/services/vnext-merge.js +285 -102
- package/dist/services/vnext-plan-review.js +148 -94
- package/dist/services/vnext-plan.js +141 -72
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +87 -64
- package/dist/services/work-registry.js +707 -156
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +444 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +11 -4
- package/tools/repair-paused-run-status.mjs +59 -0
package/dist/services/hooks.js
CHANGED
|
@@ -2,18 +2,21 @@ 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
7
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
|
-
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
8
|
+
import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
|
+
import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
|
|
10
11
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
-
import {
|
|
12
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
13
|
+
import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
14
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload } from "./lifecycle-command.js";
|
|
12
15
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
|
-
import { activeFlowSessionsForProject,
|
|
16
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
14
17
|
const defaultProfileName = "default";
|
|
15
18
|
const maxSanitizedSummaryLength = 1600;
|
|
16
|
-
const
|
|
19
|
+
export const codexHomeSharedEntries = [
|
|
17
20
|
"auth.json",
|
|
18
21
|
"auth.bk",
|
|
19
22
|
"sessions",
|
|
@@ -50,22 +53,28 @@ export function planCodexHome(context, input) {
|
|
|
50
53
|
config_path: paths.configPath,
|
|
51
54
|
hooks_path: paths.hooksPath,
|
|
52
55
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
53
|
-
shared_entries:
|
|
56
|
+
shared_entries: codexHomeSharedEntries,
|
|
54
57
|
exists: fs.existsSync(paths.targetHome),
|
|
55
58
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
56
59
|
};
|
|
57
60
|
}
|
|
58
|
-
export function
|
|
61
|
+
export function prepareCodexHome(context, input) {
|
|
59
62
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
60
63
|
const profile = resolveProfileName(input.profile);
|
|
61
64
|
const paths = resolveHomePaths(context, project, profile, input);
|
|
65
|
+
if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
|
|
66
|
+
throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
|
|
67
|
+
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
68
|
+
return { project, profile, paths, config };
|
|
69
|
+
}
|
|
70
|
+
export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
|
|
71
|
+
const { project, profile, paths, config } = prepared;
|
|
62
72
|
const now = context.now();
|
|
63
73
|
ensureDir(paths.targetHome);
|
|
64
74
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
65
75
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
66
76
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
67
77
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
68
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
69
78
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
70
79
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
71
80
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -136,7 +145,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
136
145
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
|
-
export function
|
|
148
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
140
149
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
141
150
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
142
151
|
}
|
|
@@ -144,9 +153,30 @@ export function removeCodexHome(context, input) {
|
|
|
144
153
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
145
154
|
const profile = resolveProfileName(input.profile);
|
|
146
155
|
const record = homeProfile(context, project.id, profile);
|
|
156
|
+
if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
|
|
157
|
+
let marker;
|
|
158
|
+
try {
|
|
159
|
+
const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
|
|
160
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
161
|
+
throw new Error("Invalid marker");
|
|
162
|
+
marker = value;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
|
|
166
|
+
}
|
|
167
|
+
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)) {
|
|
168
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { project, profile, record, mode };
|
|
172
|
+
}
|
|
173
|
+
export function removeCodexHome(context, input) {
|
|
174
|
+
const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
|
|
147
175
|
if (!record) {
|
|
148
176
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
149
177
|
}
|
|
178
|
+
if (record.status === "removed")
|
|
179
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
150
180
|
const removedEntries = [];
|
|
151
181
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
152
182
|
const target = path.join(record.target_home, relative);
|
|
@@ -207,7 +237,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
207
237
|
recorded
|
|
208
238
|
};
|
|
209
239
|
}
|
|
210
|
-
export function
|
|
240
|
+
export function prepareCodexHooks(context, input) {
|
|
211
241
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
212
242
|
const target = resolveHookTarget(input.target);
|
|
213
243
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -217,9 +247,16 @@ export function installCodexHooks(context, input) {
|
|
|
217
247
|
hooks_path: location.hooksPath
|
|
218
248
|
});
|
|
219
249
|
}
|
|
220
|
-
const
|
|
221
|
-
const
|
|
250
|
+
const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
|
|
251
|
+
const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
|
|
252
|
+
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)))))
|
|
253
|
+
return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
|
|
254
|
+
return { project, target, location, existing, text };
|
|
255
|
+
}
|
|
256
|
+
export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
257
|
+
const { project, target, location, existing, text } = prepared;
|
|
222
258
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
259
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
223
260
|
ensureDir(path.dirname(location.hooksPath));
|
|
224
261
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
225
262
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -238,20 +275,11 @@ export function installCodexHooks(context, input) {
|
|
|
238
275
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
239
276
|
};
|
|
240
277
|
}
|
|
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) ?? {};
|
|
278
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
279
|
+
const { project, target, location, existing, text } = prepared;
|
|
252
280
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
253
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
254
281
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
282
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
255
283
|
ensureDir(path.dirname(location.hooksPath));
|
|
256
284
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
257
285
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -268,7 +296,12 @@ export function handleCodexHook(context, input) {
|
|
|
268
296
|
if (eventName !== "PreToolUse") {
|
|
269
297
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
270
298
|
}
|
|
271
|
-
const
|
|
299
|
+
const rootProviderSessionId = stringValue(payload.session_id) ?? null;
|
|
300
|
+
// Codex deliberately retains its legacy root session_id inside a
|
|
301
|
+
// thread-spawned subagent. `agent_id` is the current child Thread id and
|
|
302
|
+
// therefore the only valid identity for a fresh Work.
|
|
303
|
+
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
304
|
+
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
272
305
|
const turnId = stringValue(payload.turn_id);
|
|
273
306
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
274
307
|
const command = lifecycleCommandFromPayload(payload);
|
|
@@ -277,198 +310,272 @@ export function handleCodexHook(context, input) {
|
|
|
277
310
|
const lifecycle = lifecycleFacts(command);
|
|
278
311
|
if (!lifecycle)
|
|
279
312
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
313
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
280
314
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
281
315
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
permissionDecision: "deny",
|
|
292
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
293
|
-
}
|
|
294
|
-
};
|
|
316
|
+
const { bootstrapStageStart } = lifecycle;
|
|
317
|
+
const invocationId = commandOption(lifecycle.invocation, "invocation-id");
|
|
318
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
319
|
+
const proof = context.codexHookProof;
|
|
320
|
+
if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
|
|
321
|
+
|| proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
|
|
322
|
+
|| proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
|
|
323
|
+
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" });
|
|
324
|
+
}
|
|
295
325
|
}
|
|
296
|
-
const
|
|
297
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
326
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
298
327
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
299
328
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
300
|
-
|
|
301
|
-
|
|
329
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
330
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
331
|
+
registerProject(context, { root: bootstrapRoot });
|
|
302
332
|
}
|
|
303
|
-
const project = projectForHook(context,
|
|
304
|
-
if (!project)
|
|
333
|
+
const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
|
|
334
|
+
if (!project) {
|
|
335
|
+
if (invocationId)
|
|
336
|
+
throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
|
|
305
337
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
312
|
-
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
338
|
+
}
|
|
339
|
+
let matchKey = lifecycleMatchKey(lifecycle, project.root);
|
|
340
|
+
// Codex retains the root session_id for a thread-spawned child, while the
|
|
341
|
+
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
342
|
+
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
313
343
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
330
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
331
|
-
});
|
|
332
|
-
if (effectiveSessionId) {
|
|
333
|
-
recordFlowSessionObservation(context, {
|
|
344
|
+
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
345
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
346
|
+
const eventCwd = stringValue(payload.cwd) ?? project.root;
|
|
347
|
+
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 });
|
|
348
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
349
|
+
// Resolve legacy transcript discovery before acquiring the receipt write lock.
|
|
350
|
+
const transcriptPath = stringValue(payload.transcript_path)
|
|
351
|
+
?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
|
|
352
|
+
const persist = (scope) => {
|
|
353
|
+
if (scope)
|
|
354
|
+
matchKey = lifecycleMatchKey(lifecycle, project.root, scope);
|
|
355
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
|
|
356
|
+
const effectiveSessionId = storageId;
|
|
357
|
+
const protocolId = binding?.protocol_id ?? null;
|
|
358
|
+
const inserted = recordHookEvent(context, {
|
|
334
359
|
projectId: project.id,
|
|
335
|
-
sessionId: effectiveSessionId,
|
|
336
|
-
runId: observedSession?.run_id ?? null,
|
|
337
360
|
protocolId,
|
|
338
|
-
|
|
361
|
+
harness: "codex-desktop",
|
|
362
|
+
providerSessionId: providerSessionId ?? null,
|
|
363
|
+
parentSessionId,
|
|
364
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
365
|
+
// state or an agent-supplied shell command/payload.
|
|
366
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
367
|
+
sessionId: effectiveSessionId,
|
|
368
|
+
agentId: rawChildProviderSessionId ?? null,
|
|
369
|
+
turnId: turnId ?? null,
|
|
370
|
+
eventName,
|
|
339
371
|
toolName: toolName ?? null,
|
|
340
|
-
|
|
372
|
+
status: "observed",
|
|
373
|
+
payload,
|
|
374
|
+
eventKey,
|
|
375
|
+
matchKey,
|
|
376
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
377
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
341
378
|
});
|
|
342
|
-
|
|
343
|
-
|
|
379
|
+
if (effectiveSessionId) {
|
|
380
|
+
recordFlowSessionObservation(context, {
|
|
381
|
+
projectId: project.id,
|
|
382
|
+
sessionId: effectiveSessionId,
|
|
383
|
+
protocolId,
|
|
384
|
+
cwd: eventCwd,
|
|
385
|
+
toolName: toolName ?? null,
|
|
386
|
+
eventKey
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return { inserted, protocolId };
|
|
390
|
+
};
|
|
391
|
+
const common = {
|
|
344
392
|
ok: true,
|
|
345
|
-
observed: inserted,
|
|
346
|
-
duplicate: !inserted,
|
|
347
393
|
event_key: eventKey,
|
|
348
|
-
session:
|
|
349
|
-
|
|
350
|
-
|
|
394
|
+
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
395
|
+
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
396
|
+
protocol_id: null
|
|
397
|
+
};
|
|
398
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
399
|
+
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
400
|
+
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
401
|
+
if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
|
|
402
|
+
throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
|
|
403
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
404
|
+
...(invocationId ? { id: invocationId } : {}),
|
|
405
|
+
command,
|
|
406
|
+
harness: "codex-desktop",
|
|
407
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
|
|
408
|
+
recordReceipt: scope => { persist(scope); return eventKey; }
|
|
409
|
+
});
|
|
410
|
+
const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
411
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
412
|
+
if (admittedInvocationId)
|
|
413
|
+
return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
414
|
+
return {
|
|
415
|
+
...common,
|
|
416
|
+
protocol_id: stored?.protocol_id ?? null,
|
|
417
|
+
observed: !receipt.duplicate,
|
|
418
|
+
duplicate: receipt.duplicate,
|
|
419
|
+
hookSpecificOutput: {
|
|
420
|
+
hookEventName: "PreToolUse",
|
|
421
|
+
permissionDecision: "allow",
|
|
422
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const persisted = persist();
|
|
427
|
+
return {
|
|
428
|
+
...common,
|
|
429
|
+
protocol_id: persisted.protocolId,
|
|
430
|
+
observed: persisted.inserted,
|
|
431
|
+
duplicate: !persisted.inserted,
|
|
432
|
+
...(providerSessionId
|
|
351
433
|
? {
|
|
352
434
|
hookSpecificOutput: {
|
|
353
435
|
hookEventName: "PreToolUse",
|
|
354
436
|
permissionDecision: "allow",
|
|
355
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
437
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
356
438
|
}
|
|
357
439
|
}
|
|
358
440
|
: {})
|
|
359
441
|
};
|
|
360
442
|
}
|
|
361
|
-
/**
|
|
443
|
+
/** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
|
|
444
|
+
function codexNativeParentId(payload, childId) {
|
|
445
|
+
const transcript = stringValue(payload.transcript_path);
|
|
446
|
+
if (!transcript || !path.isAbsolute(transcript))
|
|
447
|
+
return null;
|
|
448
|
+
try {
|
|
449
|
+
const descriptor = fs.openSync(transcript, "r");
|
|
450
|
+
const chunks = [];
|
|
451
|
+
try {
|
|
452
|
+
for (;;) {
|
|
453
|
+
const buffer = Buffer.alloc(4096);
|
|
454
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
455
|
+
if (!length)
|
|
456
|
+
break;
|
|
457
|
+
const newline = buffer.subarray(0, length).indexOf(10);
|
|
458
|
+
chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
|
|
459
|
+
if (newline >= 0)
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
fs.closeSync(descriptor);
|
|
465
|
+
}
|
|
466
|
+
const firstLine = Buffer.concat(chunks).toString("utf8");
|
|
467
|
+
if (!firstLine)
|
|
468
|
+
return null;
|
|
469
|
+
const record = JSON.parse(firstLine);
|
|
470
|
+
return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
|
|
471
|
+
? record.payload.parent_thread_id
|
|
472
|
+
: null;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
479
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
362
480
|
export function handleZcodeEvent(context, input) {
|
|
363
481
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
364
|
-
|
|
482
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
483
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
365
484
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
369
|
-
return { ok: true, observed: false, reason: "
|
|
370
|
-
const updateMeta = objectRecord(update._meta);
|
|
371
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
372
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
373
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
374
|
-
const rawInput = objectRecord(update.rawInput);
|
|
485
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
486
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
487
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
488
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
375
489
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
376
|
-
|
|
490
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
491
|
+
if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
|
|
492
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
493
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
494
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
495
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
496
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
497
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
498
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
502
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
503
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
504
|
+
*/
|
|
505
|
+
export function handleZcodeHook(context, input) {
|
|
506
|
+
return recordZcodeLifecycle(context, input);
|
|
507
|
+
}
|
|
508
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
509
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
510
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
511
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
512
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
513
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
514
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
515
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
516
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
377
517
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
378
|
-
}
|
|
379
518
|
const lifecycle = lifecycleFacts(command);
|
|
380
519
|
if (!lifecycle)
|
|
381
520
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
382
|
-
if (lifecycle.
|
|
383
|
-
|
|
384
|
-
standalone_command: lifecycle.invocation.command
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
521
|
+
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
522
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
388
523
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
524
|
+
const meta = objectRecord(hook._meta);
|
|
525
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
526
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
527
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
528
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
529
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
530
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
531
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
394
532
|
}
|
|
395
|
-
|
|
396
|
-
|
|
533
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
534
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
535
|
+
}
|
|
536
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
537
|
+
if (!toolUseId)
|
|
538
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
539
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
540
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
541
|
+
if (lifecycle.bootstrapStageStart)
|
|
397
542
|
registerProject(context, { root: expectedRoot });
|
|
398
543
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
399
|
-
const envelopeMeta = objectRecord(notification._meta);
|
|
400
|
-
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
401
|
-
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
402
|
-
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
403
|
-
const daemonId = stringValue(ddZcode.daemonId);
|
|
404
|
-
if (!rootProviderSessionId)
|
|
405
|
-
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
406
|
-
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
407
|
-
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
408
544
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
409
|
-
const parentSessionId =
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
command
|
|
428
|
-
provider: stringValue(observedProfile.provider),
|
|
429
|
-
model: stringValue(observedProfile.model),
|
|
430
|
-
reasoning: stringValue(observedProfile.reasoning),
|
|
431
|
-
mode: stringValue(observedProfile.mode),
|
|
432
|
-
agent_type: stringValue(runtime.source)
|
|
545
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
546
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
547
|
+
let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
548
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
549
|
+
const persist = (scope) => {
|
|
550
|
+
if (scope)
|
|
551
|
+
matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
|
|
552
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
553
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
554
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
555
|
+
const inserted = recordHookEvent(context, {
|
|
556
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
557
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
558
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
559
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
560
|
+
});
|
|
561
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
562
|
+
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 };
|
|
563
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
433
564
|
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
565
|
+
let persistedResult;
|
|
566
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
567
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
437
568
|
harness: "zcode-acp",
|
|
438
|
-
providerSessionId,
|
|
439
|
-
|
|
440
|
-
daemonId: daemonId ?? null,
|
|
441
|
-
sessionId,
|
|
442
|
-
agentId: agentId ?? null,
|
|
443
|
-
turnId: null,
|
|
444
|
-
eventName: "PreToolUse",
|
|
445
|
-
toolName: toolName ?? "Bash",
|
|
446
|
-
status: "observed",
|
|
447
|
-
payload,
|
|
448
|
-
eventKey,
|
|
449
|
-
matchKey,
|
|
450
|
-
transcriptPath: null,
|
|
451
|
-
cwd: expectedRoot
|
|
452
|
-
});
|
|
453
|
-
recordFlowSessionObservation(context, {
|
|
454
|
-
projectId: project.id,
|
|
455
|
-
sessionId,
|
|
456
|
-
runId: observedSession?.run_id ?? null,
|
|
457
|
-
protocolId: observedSession?.protocol_id ?? null,
|
|
458
|
-
cwd: expectedRoot,
|
|
459
|
-
toolName: toolName ?? "Bash",
|
|
460
|
-
eventKey
|
|
569
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
570
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
461
571
|
});
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
observed:
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
470
|
-
daemon_id: daemonId ?? null
|
|
471
|
-
};
|
|
572
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
573
|
+
if (admittedInvocationId)
|
|
574
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
575
|
+
// Without an issued managed attempt this is still ordinary hook evidence.
|
|
576
|
+
// Preserve persist()'s replay result instead of overwriting it with the
|
|
577
|
+
// admission layer's non-authoritative duplicate flag.
|
|
578
|
+
return persistedResult ?? persist();
|
|
472
579
|
}
|
|
473
580
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
474
581
|
export function handleGrokEvent(context, input) {
|
|
@@ -486,18 +593,8 @@ export function handleGrokEvent(context, input) {
|
|
|
486
593
|
const lifecycle = lifecycleFacts(command);
|
|
487
594
|
if (!lifecycle)
|
|
488
595
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
489
|
-
|
|
490
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
491
|
-
standalone_command: lifecycle.invocation.command
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
596
|
+
const { bootstrapStageStart } = lifecycle;
|
|
495
597
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
496
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
497
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
498
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
598
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
502
599
|
if (bootstrapStageStart)
|
|
503
600
|
registerProject(context, { root: expectedRoot });
|
|
@@ -514,41 +611,49 @@ export function handleGrokEvent(context, input) {
|
|
|
514
611
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
515
612
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
516
613
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
517
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
518
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
519
|
-
}, sessionId) : undefined;
|
|
520
614
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
521
615
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
522
616
|
})).digest("hex");
|
|
617
|
+
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: expectedRoot });
|
|
523
618
|
const payload = {
|
|
524
619
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
525
620
|
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)
|
|
526
621
|
};
|
|
527
622
|
const inserted = recordHookEvent(context, {
|
|
528
|
-
projectId: project.id, protocolId:
|
|
623
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
529
624
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
530
625
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
531
626
|
});
|
|
532
627
|
recordFlowSessionObservation(context, {
|
|
533
|
-
projectId: project.id, sessionId,
|
|
628
|
+
projectId: project.id, sessionId,
|
|
534
629
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
535
630
|
});
|
|
536
631
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
632
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
538
|
-
return
|
|
539
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
540
|
-
: result;
|
|
633
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
541
634
|
}
|
|
542
635
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
543
636
|
export function handleOpenCodeEvent(context, input) {
|
|
544
637
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
545
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
546
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
547
638
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
548
639
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
640
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
641
|
+
}
|
|
642
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
643
|
+
export function handleDroidEvent(context, input) {
|
|
644
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
645
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
646
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
647
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
648
|
+
}
|
|
649
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
650
|
+
const agy = harness === "antigravity-cli";
|
|
651
|
+
const droid = harness === "droid-cli";
|
|
652
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
653
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
549
654
|
const phase = stringValue(event.phase);
|
|
550
655
|
if (phase !== "before" && phase !== "after")
|
|
551
|
-
throw new AppError(
|
|
656
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
552
657
|
const session = objectRecord(event.session);
|
|
553
658
|
const profile = objectRecord(event.profile);
|
|
554
659
|
const rawInput = objectRecord(event.input);
|
|
@@ -556,16 +661,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
556
661
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
557
662
|
const daemonId = stringValue(event.daemon_id);
|
|
558
663
|
const toolCallId = stringValue(event.tool_call_id);
|
|
559
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
664
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
560
665
|
const toolName = stringValue(event.tool);
|
|
561
666
|
const directory = stringValue(session.directory);
|
|
562
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
563
|
-
throw new AppError(
|
|
667
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
668
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
564
669
|
}
|
|
565
670
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
566
671
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
567
|
-
throw new AppError(
|
|
672
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
568
673
|
}
|
|
674
|
+
if (droid && nativeParentId === providerSessionId)
|
|
675
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
569
676
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
677
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
571
678
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -576,45 +683,40 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
576
683
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
577
684
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
578
685
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
579
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
686
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
580
687
|
});
|
|
581
688
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
582
689
|
}
|
|
583
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
690
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
584
691
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
585
692
|
}
|
|
586
693
|
const lifecycle = lifecycleFacts(command);
|
|
587
694
|
if (!lifecycle)
|
|
588
695
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
589
|
-
|
|
590
|
-
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
591
|
-
}
|
|
592
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
593
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
594
|
-
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
595
|
-
}
|
|
696
|
+
const { bootstrapStageStart } = lifecycle;
|
|
596
697
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
597
698
|
if (bootstrapStageStart)
|
|
598
699
|
registerProject(context, { root: expectedRoot });
|
|
599
700
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
701
|
+
if (droid) {
|
|
702
|
+
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]);
|
|
703
|
+
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)) {
|
|
704
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
600
707
|
const agentId = stringValue(session.agent_id);
|
|
601
|
-
|
|
602
|
-
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
603
|
-
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
604
|
-
reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
|
|
605
|
-
metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
|
|
606
|
-
}, sessionId) : undefined;
|
|
708
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
|
|
607
709
|
const payload = {
|
|
608
710
|
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
609
|
-
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
|
|
610
|
-
mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
|
|
711
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
712
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
611
713
|
};
|
|
612
714
|
const inserted = recordHookEvent(context, {
|
|
613
|
-
projectId: project.id, protocolId:
|
|
715
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
614
716
|
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
615
|
-
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
717
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
616
718
|
});
|
|
617
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
719
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
618
720
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
|
|
619
721
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
620
722
|
}
|
|
@@ -663,15 +765,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
663
765
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
664
766
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
665
767
|
*/
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
768
|
+
/**
|
|
769
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
770
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
771
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
772
|
+
*/
|
|
773
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
671
774
|
const analysis = parseLifecycleCommand(command);
|
|
672
|
-
if (analysis.kind !== "standalone"
|
|
775
|
+
if (analysis.kind !== "standalone")
|
|
673
776
|
return command;
|
|
674
|
-
|
|
777
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
778
|
+
return command;
|
|
779
|
+
}
|
|
780
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
781
|
+
if (analysis.invocation.rewriteSafe) {
|
|
782
|
+
const index = shellSuffixOffset(command);
|
|
783
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
784
|
+
}
|
|
785
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
786
|
+
const index = firstHeredoc(command);
|
|
787
|
+
if (index >= 0)
|
|
788
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
789
|
+
}
|
|
790
|
+
return command;
|
|
791
|
+
}
|
|
792
|
+
function firstHeredoc(command) {
|
|
793
|
+
let quoteState = null;
|
|
794
|
+
let escaped = false;
|
|
795
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
796
|
+
const char = command[index];
|
|
797
|
+
if (escaped) {
|
|
798
|
+
escaped = false;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (char === "\\" && quoteState !== "'") {
|
|
802
|
+
escaped = true;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (quoteState) {
|
|
806
|
+
if (char === quoteState)
|
|
807
|
+
quoteState = null;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (char === "'" || char === '"') {
|
|
811
|
+
quoteState = char;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
815
|
+
return index;
|
|
816
|
+
}
|
|
817
|
+
return -1;
|
|
675
818
|
}
|
|
676
819
|
/**
|
|
677
820
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -692,25 +835,51 @@ function lifecycleFacts(command) {
|
|
|
692
835
|
return null;
|
|
693
836
|
const invocation = analysis.invocation;
|
|
694
837
|
const stageStart = invocation.operation === "stage_start";
|
|
838
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
839
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
695
840
|
const stageResume = invocation.operation === "stage_resume";
|
|
696
841
|
const workStart = invocation.operation === "work_start";
|
|
842
|
+
const workFinish = invocation.operation === "work_finish";
|
|
843
|
+
const workFail = invocation.operation === "work_fail";
|
|
697
844
|
return {
|
|
698
845
|
analysis,
|
|
699
846
|
invocation,
|
|
700
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
701
847
|
stageStart,
|
|
848
|
+
stageFinish,
|
|
849
|
+
stagePause,
|
|
702
850
|
stageResume,
|
|
703
|
-
workStart,
|
|
851
|
+
workStart, workFinish, workFail,
|
|
704
852
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
705
853
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
706
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
707
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
708
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
854
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
855
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
856
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
709
857
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
710
858
|
};
|
|
711
859
|
}
|
|
712
|
-
|
|
713
|
-
|
|
860
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
861
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
862
|
+
// not a second authority. Everything else remains standalone-only.
|
|
863
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
864
|
+
const operation = facts.invocation.operation;
|
|
865
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
866
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
867
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
868
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
869
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
870
|
+
}
|
|
871
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
872
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
873
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
874
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
875
|
+
return null;
|
|
876
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
877
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
878
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
879
|
+
if (invocation.operation === "recovery_accept") {
|
|
880
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
881
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
882
|
+
}
|
|
714
883
|
if (bootstrapStageStart)
|
|
715
884
|
return bootstrapMatchKey({
|
|
716
885
|
projectRoot,
|
|
@@ -718,14 +887,90 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
718
887
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
719
888
|
...(contextSha256 ? { contextSha256 } : {})
|
|
720
889
|
});
|
|
721
|
-
if (stageStart &&
|
|
722
|
-
return stageStartMatchKey(
|
|
723
|
-
if (
|
|
724
|
-
return
|
|
890
|
+
if (stageStart && effectiveRunId && stageName)
|
|
891
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
892
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
893
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
894
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
895
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
896
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
897
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
725
898
|
if (workStart && workId)
|
|
726
|
-
return workStartMatchKey(workId,
|
|
899
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
900
|
+
if (workFinish && workId)
|
|
901
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
902
|
+
if (workFail && workId)
|
|
903
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
727
904
|
return null;
|
|
728
905
|
}
|
|
906
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
907
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
908
|
+
export function observeCommandHook(context, command) {
|
|
909
|
+
const facts = lifecycleFacts(command);
|
|
910
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
911
|
+
return;
|
|
912
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
913
|
+
if (!project)
|
|
914
|
+
return; // Bootstrap has not registered a project yet.
|
|
915
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
916
|
+
if (!matchKey)
|
|
917
|
+
return;
|
|
918
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
919
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
920
|
+
return;
|
|
921
|
+
if (!key) {
|
|
922
|
+
try {
|
|
923
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
return;
|
|
927
|
+
} // Dispatch still owns its mandatory admission error.
|
|
928
|
+
}
|
|
929
|
+
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]);
|
|
930
|
+
if (event)
|
|
931
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
932
|
+
}
|
|
933
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
934
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
935
|
+
}
|
|
936
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
937
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
938
|
+
}
|
|
939
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
940
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
941
|
+
}
|
|
942
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
943
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
944
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
945
|
+
const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
|
|
946
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
947
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
948
|
+
}).eventKey;
|
|
949
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
950
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
951
|
+
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 });
|
|
952
|
+
}
|
|
953
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
954
|
+
if (claimed.changes !== 1)
|
|
955
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
956
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
957
|
+
}
|
|
958
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
959
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
960
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
961
|
+
const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
962
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
963
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
964
|
+
}).eventKey;
|
|
965
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
966
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
967
|
+
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 });
|
|
968
|
+
}
|
|
969
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
970
|
+
if (claimed.changes !== 1)
|
|
971
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
972
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
973
|
+
}
|
|
729
974
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
730
975
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
731
976
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -741,6 +986,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
741
986
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
742
987
|
if (!event?.session_id)
|
|
743
988
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
989
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
744
990
|
return {
|
|
745
991
|
hookEventId: event.id,
|
|
746
992
|
harness: event.harness,
|
|
@@ -777,21 +1023,26 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
777
1023
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
778
1024
|
export function findRecentMatchingHookEvent(context, input) {
|
|
779
1025
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
780
|
-
const find = () => context.db.
|
|
1026
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
781
1027
|
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1028
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1029
|
+
AND (? IS NULL OR session_id = ?)
|
|
1030
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1031
|
+
let events = find();
|
|
1032
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1033
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
786
1034
|
const deadline = Date.now() + 250;
|
|
787
|
-
while (
|
|
1035
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
788
1036
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
789
|
-
|
|
1037
|
+
events = find();
|
|
790
1038
|
}
|
|
791
|
-
if (!
|
|
792
|
-
throw new AppError(input.errorCode, `${input.operation} requires
|
|
1039
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1040
|
+
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);
|
|
793
1041
|
}
|
|
794
|
-
|
|
1042
|
+
if (events.length > 1) {
|
|
1043
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
|
|
1044
|
+
}
|
|
1045
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
795
1046
|
}
|
|
796
1047
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
797
1048
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -814,7 +1065,7 @@ export function bootstrapMatchKey(input) {
|
|
|
814
1065
|
})).digest("hex");
|
|
815
1066
|
}
|
|
816
1067
|
export function claimWorkStartHookEvent(context, input) {
|
|
817
|
-
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
|
|
1068
|
+
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
|
|
818
1069
|
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]);
|
|
819
1070
|
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
|
|
820
1071
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
@@ -832,7 +1083,9 @@ export function assertStageStartHookEvent(context, input) {
|
|
|
832
1083
|
if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
|
|
833
1084
|
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_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
834
1085
|
}
|
|
835
|
-
|
|
1086
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1087
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1088
|
+
return identity;
|
|
836
1089
|
}
|
|
837
1090
|
export function claimStageStartHookEvent(context, input) {
|
|
838
1091
|
assertStageStartHookEvent(context, input);
|
|
@@ -863,23 +1116,24 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
863
1116
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
864
1117
|
export function claimStageResumeHookEvent(context, input) {
|
|
865
1118
|
const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
|
|
866
|
-
const
|
|
867
|
-
const event = input.eventKey
|
|
868
|
-
? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
|
|
869
|
-
: 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]);
|
|
1119
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1120
|
+
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]);
|
|
870
1121
|
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
871
1122
|
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 });
|
|
872
1123
|
}
|
|
1124
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1125
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
873
1126
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
874
1127
|
if (claimed.changes !== 1)
|
|
875
1128
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
876
|
-
return
|
|
1129
|
+
return identity;
|
|
877
1130
|
}
|
|
878
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1131
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
879
1132
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
880
1133
|
operation: "work_start",
|
|
881
1134
|
work_id: workId,
|
|
882
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1135
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1136
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
883
1137
|
})).digest("hex");
|
|
884
1138
|
}
|
|
885
1139
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -919,6 +1173,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
919
1173
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
920
1174
|
}
|
|
921
1175
|
function resolveProfileName(profile) {
|
|
1176
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1177
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
922
1178
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
923
1179
|
}
|
|
924
1180
|
function sanitizeProfile(value) {
|
|
@@ -946,11 +1202,12 @@ function readTomlIfExists(file) {
|
|
|
946
1202
|
if (!fs.existsSync(file)) {
|
|
947
1203
|
return {};
|
|
948
1204
|
}
|
|
1205
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
949
1206
|
try {
|
|
950
|
-
return parse(
|
|
1207
|
+
return parse(text);
|
|
951
1208
|
}
|
|
952
1209
|
catch (error) {
|
|
953
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1210
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
954
1211
|
}
|
|
955
1212
|
}
|
|
956
1213
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -969,7 +1226,7 @@ function objectTable(value) {
|
|
|
969
1226
|
function createSharedLinks(sourceHome, targetHome) {
|
|
970
1227
|
const linked = [];
|
|
971
1228
|
const skipped = [];
|
|
972
|
-
for (const entry of
|
|
1229
|
+
for (const entry of codexHomeSharedEntries) {
|
|
973
1230
|
const source = path.join(sourceHome, entry);
|
|
974
1231
|
const target = path.join(targetHome, entry);
|
|
975
1232
|
if (!fs.existsSync(source)) {
|
|
@@ -992,7 +1249,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
992
1249
|
return { linked, skipped };
|
|
993
1250
|
}
|
|
994
1251
|
function removeSharedSymlinks(targetHome) {
|
|
995
|
-
for (const entry of
|
|
1252
|
+
for (const entry of codexHomeSharedEntries) {
|
|
996
1253
|
const target = path.join(targetHome, entry);
|
|
997
1254
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
998
1255
|
fs.rmSync(target);
|
|
@@ -1005,7 +1262,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1005
1262
|
}
|
|
1006
1263
|
}
|
|
1007
1264
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1008
|
-
return
|
|
1265
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1009
1266
|
const source = path.join(sourceHome, entry);
|
|
1010
1267
|
const target = path.join(targetHome, entry);
|
|
1011
1268
|
return {
|
|
@@ -1026,10 +1283,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1026
1283
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1027
1284
|
}
|
|
1028
1285
|
function commandHook(command, statusMessage) {
|
|
1029
|
-
|
|
1286
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1287
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1288
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1289
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1030
1290
|
}
|
|
1031
1291
|
function hookCommand(event) {
|
|
1032
|
-
const
|
|
1292
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1293
|
+
const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin" dd-flow ${args}; fi`;
|
|
1033
1294
|
return `/bin/bash -c '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"'`;
|
|
1034
1295
|
}
|
|
1035
1296
|
function resolveHookTarget(target) {
|
|
@@ -1205,11 +1466,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1205
1466
|
function installationScope(target, profile, hooksPath) {
|
|
1206
1467
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1207
1468
|
}
|
|
1208
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1469
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1209
1470
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1210
1471
|
const now = context.now();
|
|
1211
1472
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1212
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1213
1473
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1214
1474
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1215
1475
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1263,12 +1523,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1263
1523
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1264
1524
|
}
|
|
1265
1525
|
function recordHookEvent(context, input) {
|
|
1266
|
-
|
|
1267
|
-
if (existing)
|
|
1526
|
+
if (assertHookEventReplay(context, input))
|
|
1268
1527
|
return false;
|
|
1269
1528
|
context.db.run(`INSERT INTO hook_events
|
|
1270
|
-
|
|
1271
|
-
|
|
1529
|
+
(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)
|
|
1530
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1272
1531
|
input.projectId,
|
|
1273
1532
|
input.protocolId,
|
|
1274
1533
|
input.harness,
|
|
@@ -1295,6 +1554,23 @@ function recordHookEvent(context, input) {
|
|
|
1295
1554
|
]);
|
|
1296
1555
|
return true;
|
|
1297
1556
|
}
|
|
1557
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1558
|
+
function assertHookEventReplay(context, input) {
|
|
1559
|
+
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
|
|
1560
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1561
|
+
if (existing) {
|
|
1562
|
+
const observed = {
|
|
1563
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1564
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1565
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1566
|
+
};
|
|
1567
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1568
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1569
|
+
}
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1298
1574
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1299
1575
|
if (explicitRoot)
|
|
1300
1576
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1321,14 +1597,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1321
1597
|
command
|
|
1322
1598
|
})).digest("hex");
|
|
1323
1599
|
}
|
|
1324
|
-
function commandFromPayload(payload) {
|
|
1325
|
-
const direct = stringValue(payload.command);
|
|
1326
|
-
if (direct) {
|
|
1327
|
-
return direct;
|
|
1328
|
-
}
|
|
1329
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1330
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1331
|
-
}
|
|
1332
1600
|
function toolNameFromPayload(payload) {
|
|
1333
1601
|
const toolInput = objectRecord(payload.tool_input);
|
|
1334
1602
|
return stringValue(toolInput.name);
|
|
@@ -1371,10 +1639,10 @@ function writeJsonFile(file, value) {
|
|
|
1371
1639
|
ensureDir(path.dirname(file));
|
|
1372
1640
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1373
1641
|
}
|
|
1374
|
-
function backupFile(file, now) {
|
|
1642
|
+
function backupFile(file, now, text) {
|
|
1375
1643
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1376
1644
|
const backup = `${file}.${suffix}.bak`;
|
|
1377
|
-
fs.
|
|
1645
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1378
1646
|
return backup;
|
|
1379
1647
|
}
|
|
1380
1648
|
function hashObject(value) {
|