@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122
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 +9 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/hook-ingress.js +4 -3
- package/dist/cli/native-hook-ingress.js +34 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
- package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
- package/dist/runtime/context.js +1 -1
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/services/code-checks.js +40 -0
- package/dist/services/codex-hook-delivery.js +6 -2
- package/dist/services/hooks.js +51 -59
- package/dist/services/lifecycle-command.js +1 -383
- package/dist/services/lifecycle-contract.js +1 -30
- package/dist/services/lifecycle-invocations.js +34 -13
- package/dist/services/managed-processes.js +42 -6
- package/dist/services/run-control.js +7 -1
- package/dist/services/runs.js +60 -47
- package/dist/services/runtime-scope-stop.js +23 -2
- package/dist/services/stage-report-renderer.js +19 -0
- package/dist/services/verification-matrix.js +325 -0
- package/dist/services/vnext-code-review.js +25 -8
- package/dist/services/vnext-code.js +38 -11
- package/dist/services/vnext-merge.js +30 -7
- package/dist/services/vnext-plan-review.js +22 -6
- package/dist/services/vnext-plan.js +45 -17
- package/dist/services/work-registry.js +13 -1
- package/dist/storage/database.js +43 -9
- package/package.json +4 -1
package/dist/services/hooks.js
CHANGED
|
@@ -481,8 +481,8 @@ function codexNativeParentId(payload, childId) {
|
|
|
481
481
|
return null;
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
|
-
/** ACP
|
|
485
|
-
*
|
|
484
|
+
/** Qualified ACP admission binds public commands to an exact issued assignment.
|
|
485
|
+
* Its provenance is an observation, never a synchronous PreToolUse assertion. */
|
|
486
486
|
export function handleZcodeEvent(context, input) {
|
|
487
487
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
488
488
|
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
@@ -494,8 +494,10 @@ export function handleZcodeEvent(context, input) {
|
|
|
494
494
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
495
495
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
496
496
|
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
497
|
-
if (parsed.kind === "none")
|
|
497
|
+
if (parsed.kind === "none" && !parsed.unresolved)
|
|
498
498
|
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
499
|
+
if (parsed.kind === "none")
|
|
500
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
|
|
499
501
|
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
500
502
|
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
501
503
|
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
@@ -557,7 +559,7 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
|
|
|
557
559
|
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
558
560
|
const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
|
|
559
561
|
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
560
|
-
const eventNameForReceipt =
|
|
562
|
+
const eventNameForReceipt = nativePreTool ? "PreToolUse" : "ToolCallObserved";
|
|
561
563
|
const persist = (scope) => {
|
|
562
564
|
if (scope)
|
|
563
565
|
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
@@ -583,6 +585,24 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
|
|
|
583
585
|
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
584
586
|
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
585
587
|
});
|
|
588
|
+
if (receipt.duplicate) {
|
|
589
|
+
// Keep first admission immutable. A second transport is independently
|
|
590
|
+
// retained corroboration, never another claim or an invented ACP cwd.
|
|
591
|
+
const retained = context.db.get("SELECT event_name, match_key, harness, provider_session_id, parent_session_id, daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
592
|
+
if (retained && retained.event_name !== eventNameForReceipt) {
|
|
593
|
+
if (retained.harness !== "zcode-acp" || retained.provider_session_id !== providerSessionId || retained.parent_session_id !== parentSessionId || retained.daemon_id !== daemonId) {
|
|
594
|
+
throw new AppError("hook_event_replay_mismatch", "Corroborating transport has different native ownership", 1, { event_key: receipt.eventKey });
|
|
595
|
+
}
|
|
596
|
+
context.db.writeTransaction(() => recordHookEvent(context, { projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
597
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? null, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "corroborating",
|
|
598
|
+
payload: { ...hook, command, corroborates_event_key: receipt.eventKey, admission_transport: nativePreTool ? "native_pre_tool" : "acp_observation" },
|
|
599
|
+
eventKey: `${receipt.eventKey}:${eventNameForReceipt}`, matchKey: null, transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd }));
|
|
600
|
+
}
|
|
601
|
+
else if (retained) {
|
|
602
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: receipt.eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId,
|
|
603
|
+
sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey: retained.match_key, cwd: observedCwd });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
586
606
|
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
587
607
|
if (admittedInvocationId)
|
|
588
608
|
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
@@ -706,8 +726,9 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
|
|
|
706
726
|
if (work) {
|
|
707
727
|
// PROTOCOLIZE provisions the feature checkout after its coordinator
|
|
708
728
|
// Session has launched in the stable project root. Its transition Work
|
|
709
|
-
// must keep that physical route until the Stage is complete.
|
|
710
|
-
|
|
729
|
+
// must keep that physical route until the Stage is complete. Child Works
|
|
730
|
+
// share stage membership, not the root coordinator's execution directory.
|
|
731
|
+
if (work.parent_work_id === null && context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
|
|
711
732
|
&& context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
|
|
712
733
|
return projectRoot;
|
|
713
734
|
const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
|
|
@@ -785,14 +806,24 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
785
806
|
const auxiliary = continuationCommand(command);
|
|
786
807
|
if (!lifecycle && !auxiliary)
|
|
787
808
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
809
|
+
// Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
|
|
810
|
+
// supply a route or execution authority, including a short public Stage call.
|
|
811
|
+
const shellRefusal = lifecycle?.analysis.supported === false;
|
|
788
812
|
const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
|
|
789
813
|
? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
|
|
814
|
+
if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
|
|
815
|
+
throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { effect: "no_effect", recoverable: false });
|
|
793
816
|
}
|
|
794
|
-
const
|
|
795
|
-
|
|
817
|
+
const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
|
|
818
|
+
const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd;
|
|
819
|
+
if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
|
|
820
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, "Literal shell cd route does not match the assigned lifecycle workspace", 1, { expected: expectedCwd, actual: executionRoute, actual_provenance: "shell_literal_route", effect: "no_effect", recoverable: false });
|
|
821
|
+
}
|
|
822
|
+
if (!executionRoute && observedCwd !== expectedCwd) {
|
|
823
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, effect: "no_effect", recoverable: false, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
|
|
824
|
+
}
|
|
825
|
+
const bootstrapStageStart = !shellRefusal && lifecycle?.bootstrapStageStart;
|
|
826
|
+
let matchKey = lifecycle && !shellRefusal ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
796
827
|
if (bootstrapStageStart)
|
|
797
828
|
registerProject(context, { root: expectedRoot });
|
|
798
829
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
@@ -810,6 +841,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
810
841
|
const payload = {
|
|
811
842
|
session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
|
|
812
843
|
...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
|
|
844
|
+
...(executionRoute ? { lifecycle_execution_cwd: expectedCwd, lifecycle_execution_cwd_provenance: "literal_cd_intent_requires_cli_confirmation" } : {}),
|
|
813
845
|
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
814
846
|
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
815
847
|
};
|
|
@@ -822,10 +854,10 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
822
854
|
return inserted;
|
|
823
855
|
};
|
|
824
856
|
const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
|
|
825
|
-
if (agy && lifecycle) {
|
|
857
|
+
if ((agy || shellRefusal) && lifecycle) {
|
|
826
858
|
let inserted = false;
|
|
827
859
|
const receipt = observeLifecycleInvocation(context, {
|
|
828
|
-
command, harness, projectId: project.id, identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
|
|
860
|
+
command, harness, projectId: project.id, ...(!shellRefusal && (agy || executionRoute) ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
|
|
829
861
|
recordReceipt: scope => { inserted = persist(scope); return baseKey; }
|
|
830
862
|
});
|
|
831
863
|
return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
|
|
@@ -908,50 +940,19 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
908
940
|
*/
|
|
909
941
|
export function commandWithHookEvent(command, eventKey) {
|
|
910
942
|
const analysis = parseLifecycleCommand(command);
|
|
911
|
-
if (analysis.kind
|
|
943
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
912
944
|
return command;
|
|
913
945
|
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
914
946
|
return command;
|
|
915
947
|
}
|
|
916
948
|
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
917
949
|
if (analysis.invocation.rewriteSafe) {
|
|
918
|
-
const index = shellSuffixOffset(command);
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
922
|
-
const index = firstHeredoc(command);
|
|
923
|
-
if (index >= 0)
|
|
924
|
-
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
950
|
+
const index = analysis.invocation.sourceRange?.end ?? shellSuffixOffset(command);
|
|
951
|
+
const suffix = command.slice(index);
|
|
952
|
+
return `${command.slice(0, index)} ${option}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
|
|
925
953
|
}
|
|
926
954
|
return command;
|
|
927
955
|
}
|
|
928
|
-
function firstHeredoc(command) {
|
|
929
|
-
let quoteState = null;
|
|
930
|
-
let escaped = false;
|
|
931
|
-
for (let index = 0; index < command.length - 1; index += 1) {
|
|
932
|
-
const char = command[index];
|
|
933
|
-
if (escaped) {
|
|
934
|
-
escaped = false;
|
|
935
|
-
continue;
|
|
936
|
-
}
|
|
937
|
-
if (char === "\\" && quoteState !== "'") {
|
|
938
|
-
escaped = true;
|
|
939
|
-
continue;
|
|
940
|
-
}
|
|
941
|
-
if (quoteState) {
|
|
942
|
-
if (char === quoteState)
|
|
943
|
-
quoteState = null;
|
|
944
|
-
continue;
|
|
945
|
-
}
|
|
946
|
-
if (char === "'" || char === '"') {
|
|
947
|
-
quoteState = char;
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
951
|
-
return index;
|
|
952
|
-
}
|
|
953
|
-
return -1;
|
|
954
|
-
}
|
|
955
956
|
/**
|
|
956
957
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
957
958
|
* commands. This intentionally does not evaluate Bash: ambiguous composition
|
|
@@ -967,6 +968,8 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
|
|
|
967
968
|
}
|
|
968
969
|
function lifecycleFacts(command) {
|
|
969
970
|
const analysis = parseLifecycleCommand(command);
|
|
971
|
+
if (analysis.kind === "none" && analysis.unresolved)
|
|
972
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
|
|
970
973
|
if (analysis.kind === "none")
|
|
971
974
|
return null;
|
|
972
975
|
const invocation = analysis.invocation;
|
|
@@ -993,17 +996,6 @@ function lifecycleFacts(command) {
|
|
|
993
996
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
994
997
|
};
|
|
995
998
|
}
|
|
996
|
-
// Worker completion and the two HITL commands intentionally take their
|
|
997
|
-
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
998
|
-
// not a second authority. Everything else remains standalone-only.
|
|
999
|
-
function allowsStdinLifecycleCompound(facts) {
|
|
1000
|
-
const operation = facts.invocation.operation;
|
|
1001
|
-
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
1002
|
-
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
1003
|
-
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
1004
|
-
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
1005
|
-
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
1006
|
-
}
|
|
1007
999
|
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
1008
1000
|
// Observe the actual argument without validating it. A mistyped project
|
|
1009
1001
|
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
@@ -1,383 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { parse, quote } from "shell-quote";
|
|
3
|
-
import { lifecycleOperationForArgv } from "./lifecycle-contract.js";
|
|
4
|
-
/** Native hooks and the lightweight ingress must recognize the same shell. */
|
|
5
|
-
export function lifecycleCommandFromPayload(payload) {
|
|
6
|
-
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
7
|
-
const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
|
|
8
|
-
return typeof command === "string" ? unwrapShellCommand(command) : undefined;
|
|
9
|
-
}
|
|
10
|
-
const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
|
|
11
|
-
const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
|
|
12
|
-
/** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
|
|
13
|
-
export function parseLifecycleCommand(command) {
|
|
14
|
-
return parseLifecycleCommandInner(command, false);
|
|
15
|
-
}
|
|
16
|
-
/** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
|
|
17
|
-
export function continuationCommand(command) {
|
|
18
|
-
let entries;
|
|
19
|
-
try {
|
|
20
|
-
entries = parse(command, name => `$${name}`);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
if (!entries.every(entry => typeof entry === "string"))
|
|
26
|
-
return null;
|
|
27
|
-
const words = entries;
|
|
28
|
-
const start = words.findIndex(word => ["stage", "work"].includes(word));
|
|
29
|
-
if (start < 1)
|
|
30
|
-
return null;
|
|
31
|
-
const launcher = words.slice(0, start);
|
|
32
|
-
while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
|
|
33
|
-
launcher.shift();
|
|
34
|
-
const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
|
|
35
|
-
const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
|
|
36
|
-
if (!direct && !node)
|
|
37
|
-
return null;
|
|
38
|
-
const args = parseCommandArgs(words.slice(start));
|
|
39
|
-
const route = args.positional.slice(0, 3).join(" ");
|
|
40
|
-
const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
|
|
41
|
-
return operation ? { operation, args } : null;
|
|
42
|
-
}
|
|
43
|
-
/** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
|
|
44
|
-
export function unwrapShellCommand(command) {
|
|
45
|
-
let entries;
|
|
46
|
-
try {
|
|
47
|
-
entries = parse(command, (name) => `$${name}`);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return command;
|
|
51
|
-
}
|
|
52
|
-
if (!entries.every((entry) => typeof entry === "string"))
|
|
53
|
-
return command;
|
|
54
|
-
const words = entries;
|
|
55
|
-
let index = 0;
|
|
56
|
-
while (index < words.length && assignment(words[index]))
|
|
57
|
-
index += 1;
|
|
58
|
-
const executable = path.basename(words[index] ?? "");
|
|
59
|
-
if (!new Set(["bash", "sh", "zsh"]).has(executable))
|
|
60
|
-
return command;
|
|
61
|
-
if (!new Set(["-c", "-lc", "-cl"]).has(words[index + 1] ?? "") || words.length !== index + 3)
|
|
62
|
-
return command;
|
|
63
|
-
return words[index + 2] ?? command;
|
|
64
|
-
}
|
|
65
|
-
function parseLifecycleCommandInner(command, wrapped) {
|
|
66
|
-
const heredoc = stripHeredocBodies(command);
|
|
67
|
-
let entries;
|
|
68
|
-
try {
|
|
69
|
-
entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// A command that cannot be tokenized cannot create a trusted claim. The
|
|
73
|
-
// real lifecycle CLI will consequently fail its binding requirement.
|
|
74
|
-
return { kind: "none" };
|
|
75
|
-
}
|
|
76
|
-
const { segments, composed } = commandSegments(entries);
|
|
77
|
-
const found = [];
|
|
78
|
-
const exportedEnv = {};
|
|
79
|
-
for (const segment of segments) {
|
|
80
|
-
Object.assign(exportedEnv, exportedAssignments(segment));
|
|
81
|
-
const direct = invocationFromSegment(segment, heredoc.present, wrapped, exportedEnv);
|
|
82
|
-
if (direct) {
|
|
83
|
-
found.push({ invocation: direct, nestedCompound: segment.dynamic });
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const nested = wrappedInvocationFromSegment(segment);
|
|
87
|
-
if (nested && nested.kind !== "none")
|
|
88
|
-
found.push({ invocation: { ...nested.invocation, heredoc: nested.invocation.heredoc || heredoc.present, rewriteSafe: false, wrapped: true }, nestedCompound: nested.kind === "compound" });
|
|
89
|
-
}
|
|
90
|
-
if (!found.length)
|
|
91
|
-
return { kind: "none" };
|
|
92
|
-
const first = found[0];
|
|
93
|
-
if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
|
|
94
|
-
return {
|
|
95
|
-
kind: "compound",
|
|
96
|
-
invocation: { ...first.invocation, rewriteSafe: false },
|
|
97
|
-
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
return { kind: "standalone", invocation: first.invocation };
|
|
101
|
-
}
|
|
102
|
-
function commandSegments(entries) {
|
|
103
|
-
const segments = [];
|
|
104
|
-
let words = [];
|
|
105
|
-
let dynamic = false;
|
|
106
|
-
let composed = false;
|
|
107
|
-
const flush = () => {
|
|
108
|
-
if (words.length || dynamic)
|
|
109
|
-
segments.push({ words, dynamic });
|
|
110
|
-
words = [];
|
|
111
|
-
dynamic = false;
|
|
112
|
-
};
|
|
113
|
-
for (let index = 0; index < entries.length; index += 1) {
|
|
114
|
-
const entry = entries[index];
|
|
115
|
-
if (typeof entry === "string") {
|
|
116
|
-
words.push(entry);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if ("comment" in entry)
|
|
120
|
-
break;
|
|
121
|
-
if (!("op" in entry) || typeof entry.op !== "string") {
|
|
122
|
-
dynamic = true;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
const operator = entry.op;
|
|
126
|
-
if (operator === "glob") {
|
|
127
|
-
dynamic = true;
|
|
128
|
-
words.push("pattern" in entry && typeof entry.pattern === "string" ? entry.pattern : "");
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (controlOperators.has(operator)) {
|
|
132
|
-
composed = true;
|
|
133
|
-
flush();
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (redirectionOperators.has(operator)) {
|
|
137
|
-
const target = entries[index + 1];
|
|
138
|
-
if (typeof target === "string" || (target && typeof target === "object" && "op" in target && target.op === "glob"))
|
|
139
|
-
index += 1;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
dynamic = true;
|
|
143
|
-
}
|
|
144
|
-
flush();
|
|
145
|
-
return { segments, composed };
|
|
146
|
-
}
|
|
147
|
-
function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
|
|
148
|
-
let index = 0;
|
|
149
|
-
const env = { ...inheritedEnv };
|
|
150
|
-
while (index < segment.words.length && assignment(segment.words[index])) {
|
|
151
|
-
addAssignment(env, segment.words[index]);
|
|
152
|
-
index += 1;
|
|
153
|
-
}
|
|
154
|
-
if (path.basename(segment.words[index] ?? "") === "env") {
|
|
155
|
-
index += 1;
|
|
156
|
-
if (segment.words[index]?.startsWith("-"))
|
|
157
|
-
return null;
|
|
158
|
-
while (index < segment.words.length && assignment(segment.words[index])) {
|
|
159
|
-
addAssignment(env, segment.words[index]);
|
|
160
|
-
index += 1;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
let executable = segment.words[index];
|
|
164
|
-
let nodeLauncher = false;
|
|
165
|
-
let launcher;
|
|
166
|
-
if (executable && path.basename(executable) === "node"
|
|
167
|
-
&& (path.basename(segment.words[index + 1] ?? "") === "cli.js" || segment.words[index + 1] === "$DD_FLOW_BIN")) {
|
|
168
|
-
launcher = executable;
|
|
169
|
-
executable = segment.words[++index];
|
|
170
|
-
nodeLauncher = true;
|
|
171
|
-
}
|
|
172
|
-
if (!executable || path.basename(executable) !== "dd-flow" && executable !== "$DD_FLOW_BIN" && !(nodeLauncher && path.basename(executable) === "cli.js"))
|
|
173
|
-
return null;
|
|
174
|
-
const argv = segment.words.slice(index + 1);
|
|
175
|
-
const operation = lifecycleOperation(argv);
|
|
176
|
-
if (!operation)
|
|
177
|
-
return null;
|
|
178
|
-
return {
|
|
179
|
-
operation,
|
|
180
|
-
executable,
|
|
181
|
-
...(launcher ? { launcher } : {}),
|
|
182
|
-
argv,
|
|
183
|
-
env,
|
|
184
|
-
args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
|
|
185
|
-
command: quote(segment.words),
|
|
186
|
-
rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
|
|
187
|
-
wrapped,
|
|
188
|
-
heredoc
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
function exportedAssignments(segment) {
|
|
192
|
-
const result = {};
|
|
193
|
-
if (segment.words[0] !== "export")
|
|
194
|
-
return result;
|
|
195
|
-
for (const word of segment.words.slice(1)) {
|
|
196
|
-
if (!assignment(word))
|
|
197
|
-
return {};
|
|
198
|
-
addAssignment(result, word);
|
|
199
|
-
}
|
|
200
|
-
return result;
|
|
201
|
-
}
|
|
202
|
-
function wrappedInvocationFromSegment(segment) {
|
|
203
|
-
let index = 0;
|
|
204
|
-
while (index < segment.words.length && assignment(segment.words[index]))
|
|
205
|
-
index += 1;
|
|
206
|
-
const executable = path.basename(segment.words[index] ?? "");
|
|
207
|
-
if (!new Set(["bash", "sh", "zsh"]).has(executable))
|
|
208
|
-
return null;
|
|
209
|
-
const flag = segment.words[index + 1];
|
|
210
|
-
const payload = segment.words[index + 2];
|
|
211
|
-
if (!payload || !new Set(["-c", "-lc", "-cl"]).has(flag ?? "") || segment.words.length !== index + 3)
|
|
212
|
-
return null;
|
|
213
|
-
return parseLifecycleCommandInner(payload, true);
|
|
214
|
-
}
|
|
215
|
-
function lifecycleOperation(argv) {
|
|
216
|
-
return lifecycleOperationForArgv(argv);
|
|
217
|
-
}
|
|
218
|
-
export function parseCommandArgs(argv) {
|
|
219
|
-
const positional = [];
|
|
220
|
-
const options = new Map();
|
|
221
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
222
|
-
const value = argv[index];
|
|
223
|
-
if (value?.startsWith("--")) {
|
|
224
|
-
const equals = value.indexOf("=");
|
|
225
|
-
if (equals > 2) {
|
|
226
|
-
const key = value.slice(2, equals);
|
|
227
|
-
options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]);
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
const key = value.slice(2);
|
|
231
|
-
const next = argv[index + 1];
|
|
232
|
-
const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
|
|
233
|
-
options.set(key, [...(options.get(key) ?? []), optionValue]);
|
|
234
|
-
if (optionValue !== "__dd_flow_flag__")
|
|
235
|
-
index += 1;
|
|
236
|
-
}
|
|
237
|
-
else if (value)
|
|
238
|
-
positional.push(value);
|
|
239
|
-
}
|
|
240
|
-
return { positional, options };
|
|
241
|
-
}
|
|
242
|
-
export function commandOption(invocation, name) {
|
|
243
|
-
const values = invocation.args.options.get(name);
|
|
244
|
-
const value = values?.[values.length - 1];
|
|
245
|
-
return value === "__dd_flow_flag__" ? undefined : value;
|
|
246
|
-
}
|
|
247
|
-
export function commandHasOption(invocation, name) {
|
|
248
|
-
return invocation.args.options.has(name);
|
|
249
|
-
}
|
|
250
|
-
export function commandPosition(invocation, index) {
|
|
251
|
-
return invocation.args.positional[index];
|
|
252
|
-
}
|
|
253
|
-
function assignment(word) {
|
|
254
|
-
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
255
|
-
}
|
|
256
|
-
/** Source offsets matter: shell-quote loses the difference between `2>&1`
|
|
257
|
-
* and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
|
|
258
|
-
function shellSyntaxOffsets(command) {
|
|
259
|
-
const offsets = [];
|
|
260
|
-
let quoted = null, escaped = false;
|
|
261
|
-
for (let i = 0; i < command.length; i++) {
|
|
262
|
-
const char = command[i];
|
|
263
|
-
if (escaped) {
|
|
264
|
-
escaped = false;
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
if (char === "\\" && quoted !== "'") {
|
|
268
|
-
escaped = true;
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (quoted) {
|
|
272
|
-
if (char === quoted)
|
|
273
|
-
quoted = null;
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (char === "'" || char === '"') {
|
|
277
|
-
quoted = char;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
|
|
281
|
-
offsets.push(i);
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
if (char === "<" || char === ">")
|
|
285
|
-
offsets.push(i);
|
|
286
|
-
}
|
|
287
|
-
return offsets;
|
|
288
|
-
}
|
|
289
|
-
function descriptorStart(command, index) {
|
|
290
|
-
let start = index;
|
|
291
|
-
while (start > 0 && /[0-9]/.test(command[start - 1]))
|
|
292
|
-
start--;
|
|
293
|
-
return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
|
|
294
|
-
}
|
|
295
|
-
export function shellSuffixOffset(command) {
|
|
296
|
-
const offset = shellSyntaxOffsets(command)[0];
|
|
297
|
-
return offset === undefined ? command.length : descriptorStart(command, offset);
|
|
298
|
-
}
|
|
299
|
-
function withoutFileDescriptors(command) {
|
|
300
|
-
for (const index of shellSyntaxOffsets(command).reverse()) {
|
|
301
|
-
if (command[index] === "#")
|
|
302
|
-
continue;
|
|
303
|
-
const start = descriptorStart(command, index);
|
|
304
|
-
command = command.slice(0, start) + command.slice(index);
|
|
305
|
-
}
|
|
306
|
-
return command;
|
|
307
|
-
}
|
|
308
|
-
function addAssignment(env, word) {
|
|
309
|
-
const separator = word.indexOf("=");
|
|
310
|
-
env[word.slice(0, separator)] = word.slice(separator + 1);
|
|
311
|
-
}
|
|
312
|
-
function stripHeredocBodies(command) {
|
|
313
|
-
const lines = command.split(/\r?\n/);
|
|
314
|
-
const output = [];
|
|
315
|
-
const pending = [];
|
|
316
|
-
let active;
|
|
317
|
-
let present = false;
|
|
318
|
-
let count = 0, quoted = true;
|
|
319
|
-
for (const line of lines) {
|
|
320
|
-
if (active) {
|
|
321
|
-
const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
322
|
-
if (candidate === active.delimiter)
|
|
323
|
-
active = pending.shift();
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (line.trim())
|
|
327
|
-
output.push(line);
|
|
328
|
-
const found = heredocDelimiters(line);
|
|
329
|
-
count += found.length;
|
|
330
|
-
quoted &&= found.every(item => item.quoted);
|
|
331
|
-
if (found.length) {
|
|
332
|
-
present = true;
|
|
333
|
-
active = found[0];
|
|
334
|
-
pending.push(...found.slice(1));
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
|
|
338
|
-
}
|
|
339
|
-
function heredocDelimiters(line) {
|
|
340
|
-
const result = [];
|
|
341
|
-
let quoteState = null;
|
|
342
|
-
let escaped = false;
|
|
343
|
-
for (let index = 0; index < line.length - 1; index += 1) {
|
|
344
|
-
const char = line[index];
|
|
345
|
-
if (escaped) {
|
|
346
|
-
escaped = false;
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (char === "\\" && quoteState !== "'") {
|
|
350
|
-
escaped = true;
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (quoteState) {
|
|
354
|
-
if (char === quoteState)
|
|
355
|
-
quoteState = null;
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
if (char === "'" || char === '"') {
|
|
359
|
-
quoteState = char;
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<")
|
|
363
|
-
continue;
|
|
364
|
-
index += 2;
|
|
365
|
-
const stripTabs = line[index] === "-";
|
|
366
|
-
if (stripTabs)
|
|
367
|
-
index += 1;
|
|
368
|
-
while (/\s/.test(line[index] ?? ""))
|
|
369
|
-
index += 1;
|
|
370
|
-
const delimiterQuote = line[index] === "'" || line[index] === '"' ? line[index++] : null;
|
|
371
|
-
const start = index;
|
|
372
|
-
if (delimiterQuote)
|
|
373
|
-
while (index < line.length && line[index] !== delimiterQuote)
|
|
374
|
-
index += 1;
|
|
375
|
-
else
|
|
376
|
-
while (index < line.length && !/[\s;&|<>]/.test(line[index]))
|
|
377
|
-
index += 1;
|
|
378
|
-
const delimiter = line.slice(start, index);
|
|
379
|
-
if (delimiter)
|
|
380
|
-
result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
|
|
381
|
-
}
|
|
382
|
-
return result;
|
|
383
|
-
}
|
|
1
|
+
export { lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand, unwrapShellCommand, parseCommandArgs, commandOption, commandHasOption, commandPosition, shellSuffixOffset } from "../harness-runtime/lib/lifecycle-command.mjs";
|
|
@@ -1,30 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
"session register": "session_register",
|
|
3
|
-
"stage start": "stage_start",
|
|
4
|
-
"stage finish": "stage_finish",
|
|
5
|
-
"stage pause": "stage_pause",
|
|
6
|
-
"stage resume": "stage_resume",
|
|
7
|
-
"work start": "work_start",
|
|
8
|
-
"work finish": "work_finish",
|
|
9
|
-
"work fail": "work_fail",
|
|
10
|
-
"merge apply": "merge_apply",
|
|
11
|
-
"merge repair": "merge_repair",
|
|
12
|
-
"run recovery accept": "recovery_accept"
|
|
13
|
-
};
|
|
14
|
-
export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
|
|
15
|
-
export function lifecycleOperationForArgv(argv) {
|
|
16
|
-
if (argv.some(arg => arg === "--help" || arg === "-h"))
|
|
17
|
-
return null;
|
|
18
|
-
for (const [route, operation] of Object.entries(lifecycleRoutes)) {
|
|
19
|
-
const parts = route.split(" ");
|
|
20
|
-
if (parts.every((part, index) => argv[index] === part))
|
|
21
|
-
return operation;
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
export function lifecycleRouteForOperation(operation) {
|
|
26
|
-
const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
|
|
27
|
-
if (!entry)
|
|
28
|
-
throw new Error(`Unknown lifecycle operation: ${operation}`);
|
|
29
|
-
return entry[0];
|
|
30
|
-
}
|
|
1
|
+
export { lifecycleRoutes, lifecycleInternalOptions, lifecycleOperationForArgv, lifecycleRouteForOperation } from "../harness-runtime/lib/lifecycle-contract.mjs";
|