@kontourai/flow-agents 3.5.0 → 3.6.0
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/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +8 -0
- package/build/src/builder-flow-run-adapter.d.ts +10 -1
- package/build/src/builder-flow-run-adapter.js +29 -1
- package/build/src/builder-flow-runtime.d.ts +18 -0
- package/build/src/builder-flow-runtime.js +205 -13
- package/build/src/builder-lifecycle-authority.d.ts +35 -0
- package/build/src/builder-lifecycle-authority.js +219 -0
- package/build/src/cli/assignment-provider.d.ts +10 -0
- package/build/src/cli/assignment-provider.js +61 -52
- package/build/src/cli/builder-run.js +46 -5
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +3 -0
- package/build/src/cli/workflow-sidecar.js +28 -6
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +521 -0
- package/build/src/cli.js +2 -0
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +2 -0
- package/build/src/lib/package-version.d.ts +2 -0
- package/build/src/lib/package-version.js +13 -0
- package/build/src/lib/pinned-cli-command.d.ts +6 -0
- package/build/src/lib/pinned-cli-command.js +21 -0
- package/context/contracts/artifact-contract.md +1 -1
- package/context/scripts/hooks/config-protection.js +8 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/context/scripts/hooks/stop-goal-fit.js +1 -1
- package/docs/context-map.md +2 -0
- package/docs/public-workflow-cli.md +63 -0
- package/docs/spec/builder-flow-runtime.md +37 -0
- package/docs/workflow-usage-guide.md +5 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_builder_entry_enforcement.sh +5 -4
- package/evals/integration/test_bundle_install.sh +59 -5
- package/evals/integration/test_public_workflow_cli.sh +259 -0
- package/package.json +2 -2
- package/schemas/builder-lifecycle-authorization.schema.json +57 -0
- package/schemas/lifecycle-authority-keys.schema.json +25 -0
- package/schemas/workflow-state.schema.json +1 -1
- package/scripts/hooks/config-protection.js +8 -1
- package/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/scripts/hooks/stop-goal-fit.js +1 -1
- package/src/builder-flow-run-adapter.ts +47 -0
- package/src/builder-flow-runtime.ts +216 -4
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider.ts +29 -9
- package/src/cli/builder-flow-runtime.test.mjs +404 -1
- package/src/cli/builder-run.ts +56 -5
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar.ts +28 -6
- package/src/cli/workflow.ts +471 -0
- package/src/cli.ts +2 -0
- package/src/index.ts +14 -0
- package/src/lib/package-version.ts +15 -0
- package/src/lib/pinned-cli-command.ts +23 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -266,6 +266,10 @@ jobs:
|
|
|
266
266
|
continue-on-error: true
|
|
267
267
|
run: bash evals/ci/run-baseline.sh --check bundle-install-integration
|
|
268
268
|
|
|
269
|
+
- name: Public workflow CLI integration
|
|
270
|
+
continue-on-error: true
|
|
271
|
+
run: bash evals/ci/run-baseline.sh --check public-workflow-cli-integration
|
|
272
|
+
|
|
269
273
|
- name: Bundle lifecycle integration
|
|
270
274
|
continue-on-error: true
|
|
271
275
|
run: bash evals/ci/run-baseline.sh --check bundle-lifecycle-integration
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.6.0](https://github.com/kontourai/flow-agents/compare/v3.5.0...v3.6.0) (2026-07-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **builder:** add authority-aware lifecycle controls ([#546](https://github.com/kontourai/flow-agents/issues/546)) ([5164902](https://github.com/kontourai/flow-agents/commit/51649020bc9a90dd77907f2aabdd9fb593f43504))
|
|
9
|
+
* **cli:** add public workflow command ([#548](https://github.com/kontourai/flow-agents/issues/548)) ([2dad479](https://github.com/kontourai/flow-agents/commit/2dad47974819100ecb850fa0bec645e911d168f8))
|
|
10
|
+
|
|
3
11
|
## [3.5.0](https://github.com/kontourai/flow-agents/compare/v3.4.3...v3.5.0) (2026-07-10)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FlowEvidenceEntry, type FlowRunState, type GateOutcome, type JsonObject } from "@kontourai/flow";
|
|
1
|
+
import { type FlowEvidenceEntry, type FlowLifecycleRequest, type FlowRunState, type GateOutcome, type JsonObject } from "@kontourai/flow";
|
|
2
2
|
export declare const BUILDER_BUILD_FLOW_ID = "builder.build";
|
|
3
3
|
export declare const BUILDER_BUILD_FLOW_RELATIVE_PATH = "kits/builder/flows/build.flow.json";
|
|
4
4
|
export interface BuilderBuildTrustBundleEvidenceInput {
|
|
@@ -41,6 +41,10 @@ export interface LoadBuilderBuildRunInput {
|
|
|
41
41
|
runId: string;
|
|
42
42
|
cwd?: string;
|
|
43
43
|
}
|
|
44
|
+
export interface ChangeBuilderBuildRunLifecycleInput extends LoadBuilderBuildRunInput {
|
|
45
|
+
request: FlowLifecycleRequest;
|
|
46
|
+
at?: string;
|
|
47
|
+
}
|
|
44
48
|
export interface BuilderBuildRunResult {
|
|
45
49
|
definitionId: typeof BUILDER_BUILD_FLOW_ID;
|
|
46
50
|
definitionVersion: string;
|
|
@@ -78,3 +82,8 @@ export declare function resolveBuilderBuildFlowDefinitionPath(startDir?: string)
|
|
|
78
82
|
export declare function startBuilderBuildRun(input: StartBuilderBuildRunInput): Promise<BuilderBuildRunResult>;
|
|
79
83
|
export declare function evaluateBuilderBuildRun(input: EvaluateBuilderBuildRunInput): Promise<BuilderBuildRunResult>;
|
|
80
84
|
export declare function loadBuilderBuildRun(input: LoadBuilderBuildRunInput): Promise<BuilderBuildRunResult>;
|
|
85
|
+
export declare function pauseBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult>;
|
|
86
|
+
export declare function resumeBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult>;
|
|
87
|
+
export declare function cancelBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult & {
|
|
88
|
+
idempotent: boolean;
|
|
89
|
+
}>;
|
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { attachEvidence, evaluateRun, expectationsForGate, loadRun, normalizeTrustBundle, openGates, readJson, startRun, validateDefinition, } from "@kontourai/flow";
|
|
6
|
+
import { attachEvidence, cancelRun, evaluateRun, expectationsForGate, loadRun, normalizeTrustBundle, openGates, pauseRun, readJson, resumeRun, startRun, validateDefinition, } from "@kontourai/flow";
|
|
7
7
|
import { resolveEffectiveFlowDefinition } from "./lib/flow-resolver.js";
|
|
8
8
|
export const BUILDER_BUILD_FLOW_ID = "builder.build";
|
|
9
9
|
export const BUILDER_BUILD_FLOW_RELATIVE_PATH = "kits/builder/flows/build.flow.json";
|
|
@@ -97,6 +97,34 @@ export async function loadBuilderBuildRun(input) {
|
|
|
97
97
|
assertCanonicalDefinition(input.runId, definition, run.definition);
|
|
98
98
|
return resultFromRun(run, input.runId);
|
|
99
99
|
}
|
|
100
|
+
export async function pauseBuilderBuildRun(input) {
|
|
101
|
+
return changeBuilderBuildRunLifecycle(input, pauseRun);
|
|
102
|
+
}
|
|
103
|
+
export async function resumeBuilderBuildRun(input) {
|
|
104
|
+
return changeBuilderBuildRunLifecycle(input, resumeRun);
|
|
105
|
+
}
|
|
106
|
+
export async function cancelBuilderBuildRun(input) {
|
|
107
|
+
const changed = await changeBuilderBuildRunLifecycleResult(input, cancelRun);
|
|
108
|
+
return { ...resultFromRun(changed, input.runId), idempotent: changed.idempotent };
|
|
109
|
+
}
|
|
110
|
+
async function changeBuilderBuildRunLifecycle(input, operation) {
|
|
111
|
+
const changed = await changeBuilderBuildRunLifecycleResult(input, operation);
|
|
112
|
+
return resultFromRun(changed, input.runId);
|
|
113
|
+
}
|
|
114
|
+
async function changeBuilderBuildRunLifecycleResult(input, operation) {
|
|
115
|
+
assertRuntimeInput(input, []);
|
|
116
|
+
if (!isRecord(input.request))
|
|
117
|
+
throw new BuilderBuildRunInputError("request", "must be a lifecycle request object");
|
|
118
|
+
const cwd = input.cwd ?? process.cwd();
|
|
119
|
+
const before = await loadBuilderBuildRun({ runId: input.runId, cwd });
|
|
120
|
+
const changed = await operation(input.runId, { cwd, ...input.request, ...(input.at ? { at: input.at } : {}) });
|
|
121
|
+
const definition = await loadShippedBuilderBuildDefinition(resolveBuilderBuildFlowDefinitionPath());
|
|
122
|
+
assertCanonicalDefinition(input.runId, definition, changed.definition);
|
|
123
|
+
if (changed.state.subject !== before.state.subject) {
|
|
124
|
+
throw new BuilderBuildRunInputError("flow_run.state.subject", "changed during lifecycle transition");
|
|
125
|
+
}
|
|
126
|
+
return changed;
|
|
127
|
+
}
|
|
100
128
|
function resultFromRun(run, runId) {
|
|
101
129
|
return {
|
|
102
130
|
definitionId: run.definition.id,
|
|
@@ -3,6 +3,12 @@ type AnyRecord = Record<string, any>;
|
|
|
3
3
|
export interface BuilderFlowSessionInput {
|
|
4
4
|
sessionDir: string;
|
|
5
5
|
}
|
|
6
|
+
export interface BuilderFlowAuthorizedLifecycleInput extends BuilderFlowSessionInput {
|
|
7
|
+
authorizationFile: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BuilderFlowAgentLifecycleInput extends BuilderFlowSessionInput {
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
6
12
|
export interface BuilderFlowSessionResult {
|
|
7
13
|
sessionDir: string;
|
|
8
14
|
projectRoot: string;
|
|
@@ -13,5 +19,17 @@ export interface BuilderFlowSessionResult {
|
|
|
13
19
|
export declare function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
|
|
14
20
|
export declare function syncBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
|
|
15
21
|
export declare function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
|
|
22
|
+
export declare function pauseBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult>;
|
|
23
|
+
export declare function resumeBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult>;
|
|
24
|
+
export declare function cancelBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & {
|
|
25
|
+
assignmentReleased: boolean;
|
|
26
|
+
idempotent: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
export declare function releaseBuilderFlowAssignment(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult & {
|
|
29
|
+
assignmentReleased: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function archiveBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & {
|
|
32
|
+
archiveDir: string;
|
|
33
|
+
}>;
|
|
16
34
|
export declare function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null>;
|
|
17
35
|
export {};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { flowAgentsPackageVersion } from "./lib/package-version.js";
|
|
6
|
+
import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
|
|
7
|
+
import { expectationsForGate, lifecycleRequestMatches, openGates, readJson, runDir, sha256File, } from "@kontourai/flow";
|
|
8
|
+
import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
|
|
9
|
+
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
|
|
10
|
+
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderBuildRun, evaluateBuilderBuildRun, loadBuilderBuildRun, pauseBuilderBuildRun, resumeBuilderBuildRun, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
|
|
6
11
|
export async function startBuilderFlowSession(input) {
|
|
7
12
|
const context = resolveSessionContext(input.sessionDir);
|
|
8
13
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
@@ -59,6 +64,187 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
59
64
|
attached: false,
|
|
60
65
|
};
|
|
61
66
|
}
|
|
67
|
+
export async function pauseBuilderFlowSession(input) {
|
|
68
|
+
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
69
|
+
}
|
|
70
|
+
export async function resumeBuilderFlowSession(input) {
|
|
71
|
+
return changeBuilderFlowSessionLifecycle(input, "resume");
|
|
72
|
+
}
|
|
73
|
+
export async function cancelBuilderFlowSession(input) {
|
|
74
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
75
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
76
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
77
|
+
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
78
|
+
const changed = await cancelBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
79
|
+
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
80
|
+
actorKey: prepared.authorization.assignment_actor_key,
|
|
81
|
+
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
82
|
+
tolerateNoActiveClaim: true,
|
|
83
|
+
});
|
|
84
|
+
const projection = projectFlowRun(prepared.context, changed, prepared.sidecarSnapshot.state);
|
|
85
|
+
writeProjection(prepared.context, projection, prepared.sidecarSnapshot.raw, "cancellation projection");
|
|
86
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
87
|
+
return { sessionDir: prepared.context.sessionDir, projectRoot: prepared.context.projectRoot, run: changed, projection, attached: false, assignmentReleased: released !== null, idempotent: changed.idempotent };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export async function releaseBuilderFlowAssignment(input) {
|
|
91
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
92
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
93
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
94
|
+
const run = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
|
|
95
|
+
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
96
|
+
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function archiveBuilderFlowSession(input) {
|
|
100
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
101
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
102
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "archive", context);
|
|
103
|
+
const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
|
|
104
|
+
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
105
|
+
if (priorConsumption && !recoveringPreparedArchive)
|
|
106
|
+
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
107
|
+
const run = await loadBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
108
|
+
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
109
|
+
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
110
|
+
}
|
|
111
|
+
const archiveRoot = path.join(prepared.context.artifactRoot, "archive");
|
|
112
|
+
const archiveDir = path.join(archiveRoot, prepared.context.slug);
|
|
113
|
+
if (pathExistsNoFollow(archiveDir))
|
|
114
|
+
throw new BuilderBuildRunInputError("archive", "destination already exists");
|
|
115
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
116
|
+
assertSafeDirectory(archiveRoot, prepared.context.artifactRoot, "archive root");
|
|
117
|
+
assertSafeDirectory(prepared.context.sessionDir, prepared.context.artifactRoot, "sessionDir");
|
|
118
|
+
if (!recoveringPreparedArchive && fs.readFileSync(prepared.context.stateFile, "utf8") !== prepared.sidecarSnapshot.raw) {
|
|
119
|
+
throw new BuilderBuildRunInputError("state.json", "changed during archive preparation");
|
|
120
|
+
}
|
|
121
|
+
const archivedState = recoveringPreparedArchive ? prepared.sidecarSnapshot.state : {
|
|
122
|
+
...prepared.sidecarSnapshot.state,
|
|
123
|
+
status: "archived",
|
|
124
|
+
phase: "done",
|
|
125
|
+
updated_at: new Date().toISOString(),
|
|
126
|
+
next_action: { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." },
|
|
127
|
+
};
|
|
128
|
+
if (!recoveringPreparedArchive) {
|
|
129
|
+
writeExistingFileNoFollow(prepared.context.stateFile, `${JSON.stringify(archivedState, null, 2)}\n`);
|
|
130
|
+
clearCurrentPointers(prepared.context.artifactRoot, prepared.context.slug);
|
|
131
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
132
|
+
}
|
|
133
|
+
fs.renameSync(prepared.context.sessionDir, archiveDir);
|
|
134
|
+
return {
|
|
135
|
+
sessionDir: archiveDir,
|
|
136
|
+
projectRoot: prepared.context.projectRoot,
|
|
137
|
+
run,
|
|
138
|
+
projection: archivedState,
|
|
139
|
+
attached: false,
|
|
140
|
+
archiveDir,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function changeBuilderFlowSessionLifecycle(input, operation) {
|
|
145
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
146
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
147
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
148
|
+
const change = operation === "pause" ? pauseBuilderBuildRun : resumeBuilderBuildRun;
|
|
149
|
+
const at = new Date().toISOString();
|
|
150
|
+
const run = await change({
|
|
151
|
+
cwd: context.projectRoot,
|
|
152
|
+
runId: context.slug,
|
|
153
|
+
request: { reason: input.reason, authority: { kind: "operator_request", actor: prepared.actorKey, request_ref: `flow-agents://assignment/${context.slug}/${operation}/${at}`, requested_at: at } },
|
|
154
|
+
});
|
|
155
|
+
const projection = projectFlowRun(context, run, prepared.sidecarSnapshot.state);
|
|
156
|
+
writeProjection(context, projection, prepared.sidecarSnapshot.raw, `${operation} projection`);
|
|
157
|
+
return {
|
|
158
|
+
sessionDir: context.sessionDir,
|
|
159
|
+
projectRoot: context.projectRoot,
|
|
160
|
+
run,
|
|
161
|
+
projection,
|
|
162
|
+
attached: false,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function prepareAuthorizedLifecycleChange(input, operation, context) {
|
|
167
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
168
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
169
|
+
const activeAssignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
170
|
+
const assignmentFile = assignmentFilePath(context.artifactRoot, context.slug);
|
|
171
|
+
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
172
|
+
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
|
|
173
|
+
: null;
|
|
174
|
+
const canonicalRun = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
|
|
175
|
+
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
176
|
+
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
177
|
+
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
178
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by a canonical actor before a lifecycle change");
|
|
179
|
+
}
|
|
180
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject) {
|
|
181
|
+
throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
182
|
+
}
|
|
183
|
+
const authorization = loadBuilderLifecycleAuthorization(input.authorizationFile, {
|
|
184
|
+
projectRoot: context.projectRoot,
|
|
185
|
+
operation,
|
|
186
|
+
runId: context.slug,
|
|
187
|
+
subject,
|
|
188
|
+
actorKey: assignment.actor_key,
|
|
189
|
+
...(operation === "cancel" && canonicalRun.state.status === "canceled" ? { allowExpired: true } : {}),
|
|
190
|
+
...(operation === "archive" && sidecarSnapshot.state.status === "archived" ? { allowExpired: true } : {}),
|
|
191
|
+
});
|
|
192
|
+
if (operation === "cancel" && canonicalRun.state.status === "canceled") {
|
|
193
|
+
const terminalEvent = canonicalRun.state.lifecycle?.at(-1);
|
|
194
|
+
if (!terminalEvent || terminalEvent.action !== "cancel" || !lifecycleRequestMatches(terminalEvent, authorization.request)) {
|
|
195
|
+
throw new BuilderBuildRunInputError("authorization.request", "does not match the canonical cancellation being recovered");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!sameActor(authorization.assignment_actor, assignment.actor)) {
|
|
199
|
+
throw new BuilderBuildRunInputError("authorization.assignment_actor", "must match the active assignment holder");
|
|
200
|
+
}
|
|
201
|
+
return { context, sidecarSnapshot, authorization };
|
|
202
|
+
}
|
|
203
|
+
function prepareAgentLifecycleChange(input, context) {
|
|
204
|
+
if (!input.reason.trim())
|
|
205
|
+
throw new BuilderBuildRunInputError("reason", "must be non-empty");
|
|
206
|
+
const resolved = resolveCurrentAssignmentActor();
|
|
207
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
208
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
209
|
+
const assignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
210
|
+
if (!assignment || assignment.status !== "claimed" || assignment.actor_key !== resolved.actorKey || !sameActor(assignment.actor, resolved.actor)) {
|
|
211
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by the current workflow actor");
|
|
212
|
+
}
|
|
213
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject)
|
|
214
|
+
throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
215
|
+
return { sidecarSnapshot, actor: resolved.actor, actorKey: resolved.actorKey };
|
|
216
|
+
}
|
|
217
|
+
function sameActor(left, right) {
|
|
218
|
+
return isDeepStrictEqual({ ...left, human: left.human ?? null }, { ...right, human: right.human ?? null });
|
|
219
|
+
}
|
|
220
|
+
function clearCurrentPointers(artifactRoot, slug) {
|
|
221
|
+
const candidates = [path.join(artifactRoot, "current.json")];
|
|
222
|
+
const actorRoot = path.join(artifactRoot, "current");
|
|
223
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
224
|
+
assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
225
|
+
candidates.push(...fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).map((name) => path.join(actorRoot, name)));
|
|
226
|
+
}
|
|
227
|
+
for (const file of candidates) {
|
|
228
|
+
if (!pathExistsNoFollow(file) || !fs.lstatSync(file).isFile())
|
|
229
|
+
continue;
|
|
230
|
+
const root = file === candidates[0] ? artifactRoot : actorRoot;
|
|
231
|
+
if (root === actorRoot)
|
|
232
|
+
assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
233
|
+
assertSafeFile(file, root, "current pointer");
|
|
234
|
+
let pointer;
|
|
235
|
+
try {
|
|
236
|
+
pointer = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (!(error instanceof SyntaxError))
|
|
240
|
+
throw error;
|
|
241
|
+
// Archival retains malformed unrelated pointers for explicit repair.
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (pointer.active_slug === slug)
|
|
245
|
+
fs.unlinkSync(file);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
62
248
|
export async function syncBuilderFlowSessionIfPresent(sessionDir) {
|
|
63
249
|
let context;
|
|
64
250
|
try {
|
|
@@ -216,7 +402,9 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
216
402
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
217
403
|
const gates = openGates(definition, run.state);
|
|
218
404
|
const complete = run.state.status === "completed";
|
|
219
|
-
const
|
|
405
|
+
const paused = run.state.status === "paused";
|
|
406
|
+
const canceled = run.state.status === "canceled";
|
|
407
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
|
|
220
408
|
if (!action) {
|
|
221
409
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
222
410
|
}
|
|
@@ -225,7 +413,7 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
225
413
|
.map((expectation) => `${expectation.id} (${expectation.bundle_claim.claimType}/${expectation.bundle_claim.subjectType ?? "any"})`));
|
|
226
414
|
const skills = action?.skills ?? [];
|
|
227
415
|
const operations = action?.operations ?? [];
|
|
228
|
-
const syncCommand =
|
|
416
|
+
const syncCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), ["workflow", "status", "--session-dir", `.kontourai/flow-agents/${context.slug}`, "--json"]);
|
|
229
417
|
const routeBack = latestRouteBack(run.state);
|
|
230
418
|
const skillText = skills.length ? `Activate ${skills.map((skill) => `\`${skill}\``).join(" then ")}.` : "No Builder skill is required.";
|
|
231
419
|
const operationText = operations.length ? ` Perform ${operations.map((operation) => `\`${operation}\``).join(" then ")}.` : "";
|
|
@@ -237,18 +425,22 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
237
425
|
: "";
|
|
238
426
|
const nextAction = complete
|
|
239
427
|
? { status: "done", summary: "Canonical Flow run is complete." }
|
|
240
|
-
:
|
|
241
|
-
status: "
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
428
|
+
: canceled
|
|
429
|
+
? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
|
|
430
|
+
: paused
|
|
431
|
+
? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
|
|
432
|
+
: {
|
|
433
|
+
status: "continue",
|
|
434
|
+
summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
|
|
435
|
+
skills,
|
|
436
|
+
operations,
|
|
437
|
+
command: syncCommand,
|
|
438
|
+
};
|
|
247
439
|
const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
|
|
248
440
|
return {
|
|
249
441
|
...sidecar,
|
|
250
|
-
status: complete ? "delivered" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
251
|
-
phase: complete ? "done" : phase,
|
|
442
|
+
status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
443
|
+
phase: complete || canceled ? "done" : phase,
|
|
252
444
|
updated_at: run.state.updated_at,
|
|
253
445
|
flow_run: {
|
|
254
446
|
run_id: run.runId,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FlowLifecycleRequest } from "@kontourai/flow";
|
|
2
|
+
import type { ActorStruct } from "./cli/assignment-provider.js";
|
|
3
|
+
type JsonRecord = Record<string, unknown>;
|
|
4
|
+
export type AuthorizedBuilderLifecycleOperation = "cancel" | "archive";
|
|
5
|
+
export interface BuilderLifecycleAuthorization {
|
|
6
|
+
schema_version: "1.0";
|
|
7
|
+
operation: AuthorizedBuilderLifecycleOperation;
|
|
8
|
+
run_id: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
assignment_actor_key: string;
|
|
11
|
+
assignment_actor: ActorStruct;
|
|
12
|
+
nonce: string;
|
|
13
|
+
expires_at: string;
|
|
14
|
+
request: FlowLifecycleRequest;
|
|
15
|
+
signature: {
|
|
16
|
+
algorithm: "ed25519";
|
|
17
|
+
key_id: string;
|
|
18
|
+
value: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function lifecycleAuthorityKeysPath(projectRoot: string): string;
|
|
22
|
+
export declare function loadBuilderLifecycleAuthorization(fileInput: string, expected: {
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
operation: AuthorizedBuilderLifecycleOperation;
|
|
25
|
+
runId: string;
|
|
26
|
+
subject: string;
|
|
27
|
+
actorKey: string;
|
|
28
|
+
now?: string;
|
|
29
|
+
allowExpired?: boolean;
|
|
30
|
+
}): BuilderLifecycleAuthorization;
|
|
31
|
+
export declare function builderLifecycleAuthorizationPayload(value: Omit<BuilderLifecycleAuthorization, "signature">): string;
|
|
32
|
+
export declare function assertAuthorizationUnused(artifactRoot: string, authorization: BuilderLifecycleAuthorization): void;
|
|
33
|
+
export declare function readAuthorizationConsumption(artifactRoot: string, authorization: BuilderLifecycleAuthorization): JsonRecord | null;
|
|
34
|
+
export declare function recordAuthorizationConsumed(artifactRoot: string, authorization: BuilderLifecycleAuthorization, at?: string): void;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
4
|
+
import { durableFlowAgentsRoot } from "./lib/local-artifact-root.js";
|
|
5
|
+
export function lifecycleAuthorityKeysPath(projectRoot) {
|
|
6
|
+
return path.join(durableFlowAgentsRoot(projectRoot), "lifecycle-authority-keys.json");
|
|
7
|
+
}
|
|
8
|
+
export function loadBuilderLifecycleAuthorization(fileInput, expected) {
|
|
9
|
+
const value = readRegularJson(fileInput, "lifecycle authorization");
|
|
10
|
+
assertExactKeys(value, ["schema_version", "operation", "run_id", "subject", "assignment_actor_key", "assignment_actor", "nonce", "expires_at", "request", "signature"], "authorization");
|
|
11
|
+
if (value.schema_version !== "1.0")
|
|
12
|
+
throw new Error("lifecycle authorization schema_version must be 1.0");
|
|
13
|
+
assertEqual(value.operation, expected.operation, "operation");
|
|
14
|
+
assertEqual(value.run_id, expected.runId, "run_id");
|
|
15
|
+
assertEqual(value.subject, expected.subject, "subject");
|
|
16
|
+
assertEqual(value.assignment_actor_key, expected.actorKey, "assignment_actor_key");
|
|
17
|
+
const assignmentActor = validateActor(value.assignment_actor);
|
|
18
|
+
const request = validateRequest(value.request);
|
|
19
|
+
const nonce = boundedText(value.nonce, "nonce", 256);
|
|
20
|
+
const expiresAt = dateTime(value.expires_at, "expires_at");
|
|
21
|
+
const requestedAt = Date.parse(request.authority.requested_at);
|
|
22
|
+
const now = Date.parse(expected.now ?? new Date().toISOString());
|
|
23
|
+
if (expiresAt < requestedAt)
|
|
24
|
+
throw new Error("lifecycle authorization expires_at must not precede request.authority.requested_at");
|
|
25
|
+
if (now > expiresAt && !expected.allowExpired)
|
|
26
|
+
throw new Error("lifecycle authorization is expired");
|
|
27
|
+
if (requestedAt > now + 5 * 60_000)
|
|
28
|
+
throw new Error("lifecycle authorization request time is in the future");
|
|
29
|
+
const signature = validateSignature(value.signature);
|
|
30
|
+
const authorization = {
|
|
31
|
+
schema_version: "1.0",
|
|
32
|
+
operation: expected.operation,
|
|
33
|
+
run_id: expected.runId,
|
|
34
|
+
subject: expected.subject,
|
|
35
|
+
assignment_actor_key: expected.actorKey,
|
|
36
|
+
assignment_actor: assignmentActor,
|
|
37
|
+
nonce,
|
|
38
|
+
expires_at: value.expires_at,
|
|
39
|
+
request,
|
|
40
|
+
signature,
|
|
41
|
+
};
|
|
42
|
+
verifyAuthorizationSignature(authorization, lifecycleAuthorityKeysPath(expected.projectRoot));
|
|
43
|
+
return authorization;
|
|
44
|
+
}
|
|
45
|
+
export function builderLifecycleAuthorizationPayload(value) {
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
export function assertAuthorizationUnused(artifactRoot, authorization) {
|
|
49
|
+
if (!readAuthorizationConsumption(artifactRoot, authorization))
|
|
50
|
+
return;
|
|
51
|
+
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
52
|
+
}
|
|
53
|
+
export function readAuthorizationConsumption(artifactRoot, authorization) {
|
|
54
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
55
|
+
if (!pathExistsNoFollow(file))
|
|
56
|
+
return null;
|
|
57
|
+
const record = readRegularJson(file, "consumed lifecycle authorization record");
|
|
58
|
+
if (record.run_id !== authorization.run_id
|
|
59
|
+
|| record.operation !== authorization.operation
|
|
60
|
+
|| record.nonce !== authorization.nonce
|
|
61
|
+
|| record.key_id !== authorization.signature.key_id
|
|
62
|
+
|| record.authorization_sha256 !== authorizationDigest(authorization)) {
|
|
63
|
+
throw new Error("consumed lifecycle authorization record does not match its integrity key");
|
|
64
|
+
}
|
|
65
|
+
return record;
|
|
66
|
+
}
|
|
67
|
+
export function recordAuthorizationConsumed(artifactRoot, authorization, at = new Date().toISOString()) {
|
|
68
|
+
const file = consumedAuthorizationPath(artifactRoot, authorization);
|
|
69
|
+
const directory = path.dirname(file);
|
|
70
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
71
|
+
const stat = fs.lstatSync(directory);
|
|
72
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !pathIsWithin(fs.realpathSync(directory), fs.realpathSync(artifactRoot)))
|
|
73
|
+
throw new Error("lifecycle authorization registry directory is unsafe");
|
|
74
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
75
|
+
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({ run_id: authorization.run_id, operation: authorization.operation, nonce: authorization.nonce, key_id: authorization.signature.key_id, authorization_sha256: authorizationDigest(authorization), at })}\n`);
|
|
78
|
+
fs.fsyncSync(descriptor);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
fs.closeSync(descriptor);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
fs.linkSync(temporary, file);
|
|
85
|
+
const directoryDescriptor = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
86
|
+
try {
|
|
87
|
+
fs.fsyncSync(directoryDescriptor);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.closeSync(directoryDescriptor);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
fs.rmSync(temporary, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function consumedAuthorizationPath(artifactRoot, authorization) {
|
|
98
|
+
const integrityKey = createHash("sha256").update(authorization.run_id).update("\0").update(authorization.nonce).digest("hex");
|
|
99
|
+
return path.join(artifactRoot, "lifecycle-authority", "consumed", `${integrityKey}.json`);
|
|
100
|
+
}
|
|
101
|
+
function authorizationDigest(authorization) {
|
|
102
|
+
return createHash("sha256").update(JSON.stringify(authorization)).digest("hex");
|
|
103
|
+
}
|
|
104
|
+
function verifyAuthorizationSignature(authorization, keysFile) {
|
|
105
|
+
const registry = readRegularJson(keysFile, "lifecycle authority key registry", true);
|
|
106
|
+
assertExactKeys(registry, ["schema_version", "keys"], "key registry");
|
|
107
|
+
if (registry.schema_version !== "1.0" || !Array.isArray(registry.keys))
|
|
108
|
+
throw new Error("lifecycle authority key registry must contain schema_version 1.0 and keys[]");
|
|
109
|
+
const key = registry.keys.find((candidate) => isRecord(candidate) && candidate.id === authorization.signature.key_id);
|
|
110
|
+
if (!isRecord(key) || key.algorithm !== "ed25519" || typeof key.public_key_pem !== "string" || key.public_key_pem.trim().length === 0) {
|
|
111
|
+
throw new Error(`lifecycle authorization key ${authorization.signature.key_id} is not trusted`);
|
|
112
|
+
}
|
|
113
|
+
const { signature: _signature, ...unsigned } = authorization;
|
|
114
|
+
let verified = false;
|
|
115
|
+
try {
|
|
116
|
+
verified = verify(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), createPublicKey(key.public_key_pem), Buffer.from(authorization.signature.value, "base64"));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
verified = false;
|
|
120
|
+
}
|
|
121
|
+
if (!verified)
|
|
122
|
+
throw new Error("lifecycle authorization signature is invalid");
|
|
123
|
+
}
|
|
124
|
+
function readRegularJson(fileInput, label, requireProtected = false) {
|
|
125
|
+
const file = path.resolve(fileInput);
|
|
126
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
127
|
+
try {
|
|
128
|
+
const stat = fs.fstatSync(descriptor);
|
|
129
|
+
if (!stat.isFile())
|
|
130
|
+
throw new Error(`${label} must be a regular file`);
|
|
131
|
+
if (stat.size > 64 * 1024)
|
|
132
|
+
throw new Error(`${label} exceeds 64 KiB`);
|
|
133
|
+
if (requireProtected && (stat.mode & 0o022) !== 0)
|
|
134
|
+
throw new Error(`${label} must not be group- or world-writable`);
|
|
135
|
+
const value = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
136
|
+
if (!isRecord(value))
|
|
137
|
+
throw new Error(`${label} must be a JSON object`);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
fs.closeSync(descriptor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateActor(value) {
|
|
145
|
+
if (!isRecord(value))
|
|
146
|
+
throw new Error("lifecycle authorization assignment_actor must be an object");
|
|
147
|
+
assertExactKeys(value, ["runtime", "session_id", "host", "human"], "assignment_actor");
|
|
148
|
+
if (!Object.prototype.hasOwnProperty.call(value, "human"))
|
|
149
|
+
throw new Error("lifecycle authorization assignment_actor.human is required");
|
|
150
|
+
for (const field of ["runtime", "session_id", "host"])
|
|
151
|
+
boundedText(value[field], `assignment_actor.${field}`, 256);
|
|
152
|
+
if (value.human !== undefined && value.human !== null)
|
|
153
|
+
boundedText(value.human, "assignment_actor.human", 256);
|
|
154
|
+
return { runtime: value.runtime, session_id: value.session_id, host: value.host, human: value.human == null ? null : value.human };
|
|
155
|
+
}
|
|
156
|
+
function validateRequest(value) {
|
|
157
|
+
if (!isRecord(value))
|
|
158
|
+
throw new Error("lifecycle authorization request must be an object");
|
|
159
|
+
assertExactKeys(value, ["reason", "authority"], "request");
|
|
160
|
+
const reason = boundedText(value.reason, "request.reason", 4096);
|
|
161
|
+
if (!isRecord(value.authority))
|
|
162
|
+
throw new Error("lifecycle authorization request.authority must be an object");
|
|
163
|
+
assertExactKeys(value.authority, ["kind", "actor", "request_ref", "requested_at"], "request.authority");
|
|
164
|
+
if (value.authority.kind !== "user_request" && value.authority.kind !== "operator_request")
|
|
165
|
+
throw new Error("lifecycle authorization request.authority.kind must be user_request or operator_request");
|
|
166
|
+
const actor = boundedText(value.authority.actor, "request.authority.actor", 256);
|
|
167
|
+
const requestRef = boundedText(value.authority.request_ref, "request.authority.request_ref", 2048);
|
|
168
|
+
dateTime(value.authority.requested_at, "request.authority.requested_at");
|
|
169
|
+
return { reason, authority: { kind: value.authority.kind, actor, request_ref: requestRef, requested_at: value.authority.requested_at } };
|
|
170
|
+
}
|
|
171
|
+
function validateSignature(value) {
|
|
172
|
+
if (!isRecord(value))
|
|
173
|
+
throw new Error("lifecycle authorization signature must be an object");
|
|
174
|
+
assertExactKeys(value, ["algorithm", "key_id", "value"], "signature");
|
|
175
|
+
if (value.algorithm !== "ed25519")
|
|
176
|
+
throw new Error("lifecycle authorization signature.algorithm must be ed25519");
|
|
177
|
+
return { algorithm: "ed25519", key_id: boundedText(value.key_id, "signature.key_id", 256), value: boundedText(value.value, "signature.value", 1024) };
|
|
178
|
+
}
|
|
179
|
+
function assertEqual(actual, expected, field) {
|
|
180
|
+
if (actual !== expected)
|
|
181
|
+
throw new Error(`lifecycle authorization ${field} does not match the requested Builder operation`);
|
|
182
|
+
}
|
|
183
|
+
function assertExactKeys(value, allowed, field) {
|
|
184
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
185
|
+
if (unexpected.length)
|
|
186
|
+
throw new Error(`lifecycle authorization ${field} contains unsupported field ${unexpected[0]}`);
|
|
187
|
+
}
|
|
188
|
+
function dateTime(value, field) {
|
|
189
|
+
const text = boundedText(value, field, 128);
|
|
190
|
+
if (!Number.isFinite(Date.parse(text)))
|
|
191
|
+
throw new Error(`lifecycle authorization ${field} must be a date-time`);
|
|
192
|
+
return Date.parse(text);
|
|
193
|
+
}
|
|
194
|
+
function boundedText(value, field, limit) {
|
|
195
|
+
if (!nonEmpty(value) || [...value].length > limit)
|
|
196
|
+
throw new Error(`lifecycle authorization ${field} must be a non-empty string of at most ${limit} characters`);
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
function nonEmpty(value) {
|
|
200
|
+
return typeof value === "string" && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
201
|
+
}
|
|
202
|
+
function isRecord(value) {
|
|
203
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
function pathExistsNoFollow(candidate) {
|
|
206
|
+
try {
|
|
207
|
+
fs.lstatSync(candidate);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error.code === "ENOENT")
|
|
212
|
+
return false;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function pathIsWithin(candidate, root) {
|
|
217
|
+
const relative = path.relative(root, candidate);
|
|
218
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
219
|
+
}
|