@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79
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 +8 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- 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/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +7 -11
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +85 -42
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +207 -94
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
package/dist/services/hooks.js
CHANGED
|
@@ -2,17 +2,18 @@ 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
12
|
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
12
|
-
import { observeLifecycleInvocation
|
|
13
|
-
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand,
|
|
13
|
+
import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
14
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload } from "./lifecycle-command.js";
|
|
14
15
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
15
|
-
import { activeFlowSessionsForProject,
|
|
16
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
16
17
|
const defaultProfileName = "default";
|
|
17
18
|
const maxSanitizedSummaryLength = 1600;
|
|
18
19
|
export const codexHomeSharedEntries = [
|
|
@@ -57,17 +58,23 @@ export function planCodexHome(context, input) {
|
|
|
57
58
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
|
-
export function
|
|
61
|
+
export function prepareCodexHome(context, input) {
|
|
61
62
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
62
63
|
const profile = resolveProfileName(input.profile);
|
|
63
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;
|
|
64
72
|
const now = context.now();
|
|
65
73
|
ensureDir(paths.targetHome);
|
|
66
74
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
67
75
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
68
76
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
69
77
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
70
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
71
78
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
72
79
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
73
80
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -138,7 +145,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
138
145
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
139
146
|
};
|
|
140
147
|
}
|
|
141
|
-
export function
|
|
148
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
142
149
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
143
150
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
144
151
|
}
|
|
@@ -146,9 +153,30 @@ export function removeCodexHome(context, input) {
|
|
|
146
153
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
147
154
|
const profile = resolveProfileName(input.profile);
|
|
148
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);
|
|
149
175
|
if (!record) {
|
|
150
176
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
151
177
|
}
|
|
178
|
+
if (record.status === "removed")
|
|
179
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
152
180
|
const removedEntries = [];
|
|
153
181
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
154
182
|
const target = path.join(record.target_home, relative);
|
|
@@ -209,7 +237,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
209
237
|
recorded
|
|
210
238
|
};
|
|
211
239
|
}
|
|
212
|
-
export function
|
|
240
|
+
export function prepareCodexHooks(context, input) {
|
|
213
241
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
214
242
|
const target = resolveHookTarget(input.target);
|
|
215
243
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -219,9 +247,16 @@ export function installCodexHooks(context, input) {
|
|
|
219
247
|
hooks_path: location.hooksPath
|
|
220
248
|
});
|
|
221
249
|
}
|
|
222
|
-
const
|
|
223
|
-
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;
|
|
224
258
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
259
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
225
260
|
ensureDir(path.dirname(location.hooksPath));
|
|
226
261
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
227
262
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -240,20 +275,11 @@ export function installCodexHooks(context, input) {
|
|
|
240
275
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
241
276
|
};
|
|
242
277
|
}
|
|
243
|
-
export function removeCodexHooks(context, input) {
|
|
244
|
-
const project
|
|
245
|
-
const target = resolveHookTarget(input.target);
|
|
246
|
-
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
247
|
-
if (target === "default" && !input.yes) {
|
|
248
|
-
throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
|
|
249
|
-
target,
|
|
250
|
-
hooks_path: location.hooksPath
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
const existing = readJsonIfExists(location.hooksPath) ?? {};
|
|
278
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
279
|
+
const { project, target, location, existing, text } = prepared;
|
|
254
280
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
255
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
256
281
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
282
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
257
283
|
ensureDir(path.dirname(location.hooksPath));
|
|
258
284
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
259
285
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -276,7 +302,6 @@ export function handleCodexHook(context, input) {
|
|
|
276
302
|
// therefore the only valid identity for a fresh Work.
|
|
277
303
|
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
278
304
|
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
279
|
-
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
280
305
|
const turnId = stringValue(payload.turn_id);
|
|
281
306
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
282
307
|
const command = lifecycleCommandFromPayload(payload);
|
|
@@ -285,108 +310,131 @@ export function handleCodexHook(context, input) {
|
|
|
285
310
|
const lifecycle = lifecycleFacts(command);
|
|
286
311
|
if (!lifecycle)
|
|
287
312
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
313
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
288
314
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
289
315
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
permissionDecision: "deny",
|
|
300
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
301
|
-
}
|
|
302
|
-
};
|
|
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
|
+
}
|
|
303
325
|
}
|
|
304
|
-
const
|
|
305
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
326
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
306
327
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
307
328
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// A terminal command outside a registered flow gets no trusted receipt;
|
|
317
|
-
// the CLI will fail closed if it is actually executed. This keeps hooks
|
|
318
|
-
// inert for unrelated shell-parser probes and stale project directories.
|
|
319
|
-
if (stageFinish || stagePause || workFinish || workFail)
|
|
320
|
-
return { ok: true, observed: false, reason: "project_not_registered" };
|
|
321
|
-
throw error;
|
|
322
|
-
}
|
|
323
|
-
if (!project)
|
|
329
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
330
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
331
|
+
registerProject(context, { root: bootstrapRoot });
|
|
332
|
+
}
|
|
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 });
|
|
324
337
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
338
|
+
}
|
|
339
|
+
let matchKey = lifecycleMatchKey(lifecycle, project.root);
|
|
325
340
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
326
341
|
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
327
342
|
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
328
343
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
329
344
|
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
330
345
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
331
|
-
|
|
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 });
|
|
332
348
|
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
?
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
344
|
-
const inserted = recordHookEvent(context, {
|
|
345
|
-
projectId: project.id,
|
|
346
|
-
protocolId,
|
|
347
|
-
harness: "codex-desktop",
|
|
348
|
-
providerSessionId: providerSessionId ?? null,
|
|
349
|
-
parentSessionId,
|
|
350
|
-
// Pinned by the owning app-server process, never derived from current RUN
|
|
351
|
-
// state or an agent-supplied shell command/payload.
|
|
352
|
-
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
353
|
-
sessionId: effectiveSessionId,
|
|
354
|
-
agentId: rawChildProviderSessionId ?? null,
|
|
355
|
-
turnId: turnId ?? null,
|
|
356
|
-
eventName,
|
|
357
|
-
toolName: toolName ?? null,
|
|
358
|
-
status: "observed",
|
|
359
|
-
payload,
|
|
360
|
-
eventKey,
|
|
361
|
-
matchKey,
|
|
362
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
363
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
364
|
-
});
|
|
365
|
-
if (effectiveSessionId) {
|
|
366
|
-
recordFlowSessionObservation(context, {
|
|
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, {
|
|
367
359
|
projectId: project.id,
|
|
368
|
-
sessionId: effectiveSessionId,
|
|
369
|
-
runId: observedSession?.run_id ?? null,
|
|
370
360
|
protocolId,
|
|
371
|
-
|
|
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,
|
|
372
371
|
toolName: toolName ?? null,
|
|
373
|
-
|
|
372
|
+
status: "observed",
|
|
373
|
+
payload,
|
|
374
|
+
eventKey,
|
|
375
|
+
matchKey,
|
|
376
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
377
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
374
378
|
});
|
|
375
|
-
|
|
376
|
-
|
|
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 = {
|
|
377
392
|
ok: true,
|
|
378
|
-
observed: inserted,
|
|
379
|
-
duplicate: !inserted,
|
|
380
393
|
event_key: eventKey,
|
|
381
394
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
382
395
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
383
|
-
protocol_id:
|
|
384
|
-
|
|
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
|
|
385
433
|
? {
|
|
386
434
|
hookSpecificOutput: {
|
|
387
435
|
hookEventName: "PreToolUse",
|
|
388
436
|
permissionDecision: "allow",
|
|
389
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
437
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
390
438
|
}
|
|
391
439
|
}
|
|
392
440
|
: {})
|
|
@@ -398,7 +446,24 @@ function codexNativeParentId(payload, childId) {
|
|
|
398
446
|
if (!transcript || !path.isAbsolute(transcript))
|
|
399
447
|
return null;
|
|
400
448
|
try {
|
|
401
|
-
const
|
|
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");
|
|
402
467
|
if (!firstLine)
|
|
403
468
|
return null;
|
|
404
469
|
const record = JSON.parse(firstLine);
|
|
@@ -456,9 +521,6 @@ function recordZcodeLifecycle(context, input, invocationId) {
|
|
|
456
521
|
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
457
522
|
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
458
523
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
459
|
-
if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
|
|
460
|
-
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
|
|
461
|
-
}
|
|
462
524
|
const meta = objectRecord(hook._meta);
|
|
463
525
|
const ddZcode = objectRecord(meta.ddZcode);
|
|
464
526
|
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
@@ -474,56 +536,46 @@ function recordZcodeLifecycle(context, input, invocationId) {
|
|
|
474
536
|
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
475
537
|
if (!toolUseId)
|
|
476
538
|
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
477
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
478
|
-
if (lifecycle.analysis.reason !== "shell_composition")
|
|
479
|
-
throw new AppError("compound_lifecycle_command", "Ambiguous shell cannot authorize a lifecycle replacement", 1, { reason: lifecycle.analysis.reason });
|
|
480
|
-
if (invocationId) {
|
|
481
|
-
const error = { code: "compound_lifecycle_command", message: "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", exitCode: 1,
|
|
482
|
-
details: { standalone_command: lifecycle.invocation.command } };
|
|
483
|
-
settleLifecycleNoEffectRejection(context, invocationId, lifecycle.invocation.command, error, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId });
|
|
484
|
-
return { ok: true, observed: false, invocation_id: invocationId, no_effect: true, error };
|
|
485
|
-
}
|
|
486
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
|
|
487
|
-
}
|
|
488
539
|
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
489
540
|
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
490
|
-
if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
|
|
491
|
-
throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
|
|
492
541
|
if (lifecycle.bootstrapStageStart)
|
|
493
542
|
registerProject(context, { root: expectedRoot });
|
|
494
543
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
495
544
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
496
545
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
497
546
|
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
498
|
-
|
|
547
|
+
let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
499
548
|
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
500
|
-
const persist = () => {
|
|
549
|
+
const persist = (scope) => {
|
|
550
|
+
if (scope)
|
|
551
|
+
matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
|
|
501
552
|
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
502
|
-
const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
|
|
503
|
-
...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
|
|
504
|
-
parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
|
|
505
|
-
model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
|
|
506
|
-
mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
|
|
507
|
-
}, sessionId) : undefined;
|
|
508
553
|
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
509
554
|
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
510
555
|
const inserted = recordHookEvent(context, {
|
|
511
|
-
projectId: project.id, protocolId:
|
|
556
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
512
557
|
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
513
558
|
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
514
559
|
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
515
560
|
});
|
|
516
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
561
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
517
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 };
|
|
518
563
|
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
519
564
|
};
|
|
520
|
-
|
|
521
|
-
return persist();
|
|
565
|
+
let persistedResult;
|
|
522
566
|
const receipt = observeLifecycleInvocation(context, {
|
|
523
|
-
|
|
524
|
-
|
|
567
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
568
|
+
harness: "zcode-acp",
|
|
569
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
570
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
525
571
|
});
|
|
526
|
-
|
|
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();
|
|
527
579
|
}
|
|
528
580
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
529
581
|
export function handleGrokEvent(context, input) {
|
|
@@ -541,18 +593,8 @@ export function handleGrokEvent(context, input) {
|
|
|
541
593
|
const lifecycle = lifecycleFacts(command);
|
|
542
594
|
if (!lifecycle)
|
|
543
595
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
544
|
-
|
|
545
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
546
|
-
standalone_command: lifecycle.invocation.command
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
596
|
+
const { bootstrapStageStart } = lifecycle;
|
|
550
597
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
551
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
552
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
553
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
598
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
557
599
|
if (bootstrapStageStart)
|
|
558
600
|
registerProject(context, { root: expectedRoot });
|
|
@@ -573,27 +615,22 @@ export function handleGrokEvent(context, input) {
|
|
|
573
615
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
574
616
|
})).digest("hex");
|
|
575
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 });
|
|
576
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
577
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
578
|
-
}, sessionId) : undefined;
|
|
579
618
|
const payload = {
|
|
580
619
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
581
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)
|
|
582
621
|
};
|
|
583
622
|
const inserted = recordHookEvent(context, {
|
|
584
|
-
projectId: project.id, protocolId:
|
|
623
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
585
624
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
586
625
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
587
626
|
});
|
|
588
627
|
recordFlowSessionObservation(context, {
|
|
589
|
-
projectId: project.id, sessionId,
|
|
628
|
+
projectId: project.id, sessionId,
|
|
590
629
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
591
630
|
});
|
|
592
631
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
593
632
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
594
|
-
return
|
|
595
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
596
|
-
: result;
|
|
633
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
597
634
|
}
|
|
598
635
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
599
636
|
export function handleOpenCodeEvent(context, input) {
|
|
@@ -656,13 +693,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
656
693
|
const lifecycle = lifecycleFacts(command);
|
|
657
694
|
if (!lifecycle)
|
|
658
695
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
659
|
-
|
|
660
|
-
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
661
|
-
}
|
|
662
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
663
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
664
|
-
throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
665
|
-
}
|
|
696
|
+
const { bootstrapStageStart } = lifecycle;
|
|
666
697
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
667
698
|
if (bootstrapStageStart)
|
|
668
699
|
registerProject(context, { root: expectedRoot });
|
|
@@ -675,23 +706,17 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
675
706
|
}
|
|
676
707
|
const agentId = stringValue(session.agent_id);
|
|
677
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 });
|
|
678
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
679
|
-
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
680
|
-
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
681
|
-
reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
|
|
682
|
-
metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { 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 }
|
|
683
|
-
}, sessionId) : undefined;
|
|
684
709
|
const payload = {
|
|
685
710
|
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
686
711
|
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
687
712
|
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
688
713
|
};
|
|
689
714
|
const inserted = recordHookEvent(context, {
|
|
690
|
-
projectId: project.id, protocolId:
|
|
715
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
691
716
|
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
692
717
|
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
693
718
|
});
|
|
694
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
719
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
695
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 };
|
|
696
721
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
697
722
|
}
|
|
@@ -740,10 +765,6 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
740
765
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
741
766
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
742
767
|
*/
|
|
743
|
-
function lifecycleCommandFromPayload(payload) {
|
|
744
|
-
const command = commandFromPayload(payload);
|
|
745
|
-
return command ? unwrapShellCommand(command) : undefined;
|
|
746
|
-
}
|
|
747
768
|
/**
|
|
748
769
|
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
749
770
|
* belongs to that command too, but an option appended after its delimiter is a
|
|
@@ -754,8 +775,6 @@ export function commandWithHookEvent(command, eventKey) {
|
|
|
754
775
|
if (analysis.kind !== "standalone")
|
|
755
776
|
return command;
|
|
756
777
|
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
757
|
-
if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
|
|
758
|
-
throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
|
|
759
778
|
return command;
|
|
760
779
|
}
|
|
761
780
|
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
@@ -825,7 +844,6 @@ function lifecycleFacts(command) {
|
|
|
825
844
|
return {
|
|
826
845
|
analysis,
|
|
827
846
|
invocation,
|
|
828
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
829
847
|
stageStart,
|
|
830
848
|
stageFinish,
|
|
831
849
|
stagePause,
|
|
@@ -850,11 +868,17 @@ function allowsStdinLifecycleCompound(facts) {
|
|
|
850
868
|
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
851
869
|
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
852
870
|
}
|
|
853
|
-
function lifecycleMatchKey(facts, projectRoot) {
|
|
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;
|
|
854
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;
|
|
855
879
|
if (invocation.operation === "recovery_accept") {
|
|
856
880
|
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
857
|
-
return run && recovery ? recoveryAcceptMatchKey(run, recovery,
|
|
881
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
858
882
|
}
|
|
859
883
|
if (bootstrapStageStart)
|
|
860
884
|
return bootstrapMatchKey({
|
|
@@ -863,20 +887,20 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
863
887
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
864
888
|
...(contextSha256 ? { contextSha256 } : {})
|
|
865
889
|
});
|
|
866
|
-
if (stageStart &&
|
|
867
|
-
return stageStartMatchKey(
|
|
868
|
-
if (stageFinish &&
|
|
869
|
-
return stageLifecycleMatchKey("stage_finish",
|
|
870
|
-
if (stagePause &&
|
|
871
|
-
return stageLifecycleMatchKey("stage_pause",
|
|
872
|
-
if (stageResume &&
|
|
873
|
-
return stageResumeMatchKey(
|
|
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);
|
|
874
898
|
if (workStart && workId)
|
|
875
|
-
return workStartMatchKey(workId,
|
|
899
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
876
900
|
if (workFinish && workId)
|
|
877
|
-
return workLifecycleMatchKey("work_finish", workId,
|
|
901
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
878
902
|
if (workFail && workId)
|
|
879
|
-
return workLifecycleMatchKey("work_fail", workId,
|
|
903
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
880
904
|
return null;
|
|
881
905
|
}
|
|
882
906
|
/** Observe exact immutable command attribution before reading model-supplied
|
|
@@ -892,6 +916,8 @@ export function observeCommandHook(context, command) {
|
|
|
892
916
|
if (!matchKey)
|
|
893
917
|
return;
|
|
894
918
|
let key = commandOption(facts.invocation, "hook-event-id");
|
|
919
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
920
|
+
return;
|
|
895
921
|
if (!key) {
|
|
896
922
|
try {
|
|
897
923
|
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
@@ -1011,7 +1037,7 @@ export function findRecentMatchingHookEvent(context, input) {
|
|
|
1011
1037
|
events = find();
|
|
1012
1038
|
}
|
|
1013
1039
|
if (events.length === 0 || !events[0]?.event_key) {
|
|
1014
|
-
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery
|
|
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);
|
|
1015
1041
|
}
|
|
1016
1042
|
if (events.length > 1) {
|
|
1017
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) });
|
|
@@ -1147,6 +1173,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
1147
1173
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
1148
1174
|
}
|
|
1149
1175
|
function resolveProfileName(profile) {
|
|
1176
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1177
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
1150
1178
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
1151
1179
|
}
|
|
1152
1180
|
function sanitizeProfile(value) {
|
|
@@ -1174,11 +1202,12 @@ function readTomlIfExists(file) {
|
|
|
1174
1202
|
if (!fs.existsSync(file)) {
|
|
1175
1203
|
return {};
|
|
1176
1204
|
}
|
|
1205
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
1177
1206
|
try {
|
|
1178
|
-
return parse(
|
|
1207
|
+
return parse(text);
|
|
1179
1208
|
}
|
|
1180
1209
|
catch (error) {
|
|
1181
|
-
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 } : {}) });
|
|
1182
1211
|
}
|
|
1183
1212
|
}
|
|
1184
1213
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -1254,7 +1283,10 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1254
1283
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1255
1284
|
}
|
|
1256
1285
|
function commandHook(command, statusMessage) {
|
|
1257
|
-
|
|
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 };
|
|
1258
1290
|
}
|
|
1259
1291
|
function hookCommand(event) {
|
|
1260
1292
|
const args = `codex hook handle --event ${event} --json`;
|
|
@@ -1434,11 +1466,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1434
1466
|
function installationScope(target, profile, hooksPath) {
|
|
1435
1467
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1436
1468
|
}
|
|
1437
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1469
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1438
1470
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1439
1471
|
const now = context.now();
|
|
1440
1472
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1441
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1442
1473
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1443
1474
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1444
1475
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1566,14 +1597,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1566
1597
|
command
|
|
1567
1598
|
})).digest("hex");
|
|
1568
1599
|
}
|
|
1569
|
-
function commandFromPayload(payload) {
|
|
1570
|
-
const direct = stringValue(payload.command);
|
|
1571
|
-
if (direct) {
|
|
1572
|
-
return direct;
|
|
1573
|
-
}
|
|
1574
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1575
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1576
|
-
}
|
|
1577
1600
|
function toolNameFromPayload(payload) {
|
|
1578
1601
|
const toolInput = objectRecord(payload.tool_input);
|
|
1579
1602
|
return stringValue(toolInput.name);
|
|
@@ -1616,10 +1639,10 @@ function writeJsonFile(file, value) {
|
|
|
1616
1639
|
ensureDir(path.dirname(file));
|
|
1617
1640
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1618
1641
|
}
|
|
1619
|
-
function backupFile(file, now) {
|
|
1642
|
+
function backupFile(file, now, text) {
|
|
1620
1643
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1621
1644
|
const backup = `${file}.${suffix}.bak`;
|
|
1622
|
-
fs.
|
|
1645
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1623
1646
|
return backup;
|
|
1624
1647
|
}
|
|
1625
1648
|
function hashObject(value) {
|