@kontourai/flow-agents 3.8.0 → 3.10.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 +8 -0
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/build/src/builder-flow-runtime.js +16 -10
- package/build/src/cli/assignment-provider.js +4 -18
- package/build/src/cli/continuation-adapter.d.ts +26 -0
- package/build/src/cli/continuation-adapter.js +243 -0
- package/build/src/cli/validate-workflow-artifacts.js +21 -1
- package/build/src/cli/workflow-sidecar.js +15 -20
- package/build/src/cli/workflow.js +99 -6
- package/build/src/continuation-driver.d.ts +116 -0
- package/build/src/continuation-driver.js +527 -0
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/tools/build-universal-bundles.js +52 -25
- package/build/src/tools/validate-source-tree.js +5 -2
- package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
- package/context/scripts/hooks/stop-goal-fit.js +244 -73
- package/context/scripts/hooks/workflow-steering.js +24 -15
- package/context/scripts/telemetry/lib/pricing.sh +18 -6
- package/context/scripts/telemetry/lib/transport.sh +139 -37
- package/context/scripts/telemetry/lib/usage.sh +282 -32
- package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
- package/context/scripts/telemetry/telemetry.sh +76 -1
- package/docs/coordination-guide.md +21 -5
- package/docs/fixture-ownership.md +2 -1
- package/docs/public-workflow-cli.md +98 -0
- package/docs/spec/runtime-hook-surface.md +8 -4
- package/docs/workflow-usage-guide.md +7 -0
- package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
- package/evals/acceptance/prove-capture-teeth.sh +13 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
- package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
- package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
- package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
- package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
- package/evals/integration/test_actor_identity.sh +63 -0
- package/evals/integration/test_assignment_provider_local_file.sh +34 -0
- package/evals/integration/test_builder_entry_enforcement.sh +5 -1
- package/evals/integration/test_bundle_install.sh +106 -17
- package/evals/integration/test_bundle_lifecycle.sh +17 -3
- package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
- package/evals/integration/test_ci_actor_identity.sh +2 -2
- package/evals/integration/test_command_log_concurrency.sh +11 -0
- package/evals/integration/test_command_log_fork_classification.sh +11 -0
- package/evals/integration/test_command_log_integrity.sh +11 -0
- package/evals/integration/test_current_json_per_actor.sh +416 -21
- package/evals/integration/test_enforcer_expects_driven.sh +11 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
- package/evals/integration/test_evidence_capture_hook.sh +25 -6
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
- package/evals/integration/test_gate_bypass_chain.sh +11 -0
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
- package/evals/integration/test_goal_fit_hook.sh +257 -9
- package/evals/integration/test_goal_fit_rederive.sh +11 -0
- package/evals/integration/test_install_merge.sh +141 -3
- package/evals/integration/test_liveness_conflict_injection.sh +39 -3
- package/evals/integration/test_liveness_heartbeat.sh +46 -9
- package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
- package/evals/integration/test_public_workflow_cli.sh +6 -6
- package/evals/integration/test_session_resume_roundtrip.sh +30 -0
- package/evals/integration/test_stop_hook_release.sh +111 -11
- package/evals/integration/test_telemetry_tool_usage.sh +394 -0
- package/evals/integration/test_verify_hold.sh +64 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
- package/evals/integration/test_workflow_steering_hook.sh +20 -4
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +123 -0
- package/package.json +4 -4
- package/packaging/README.md +21 -0
- package/packaging/codex-legacy-agents-fingerprints.json +33 -0
- package/scripts/README.md +2 -1
- package/scripts/audit-codex-legacy-agents.js +43 -0
- package/scripts/classify-codex-legacy-agents.js +71 -0
- package/scripts/hooks/evidence-capture.js +18 -6
- package/scripts/hooks/lib/actor-identity.js +62 -29
- package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
- package/scripts/hooks/lib/current-pointer.js +114 -12
- package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
- package/scripts/hooks/stop-goal-fit.js +244 -73
- package/scripts/hooks/workflow-steering.js +24 -15
- package/scripts/install-codex-home.sh +8 -7
- package/scripts/telemetry/lib/pricing.sh +18 -6
- package/scripts/telemetry/lib/transport.sh +71 -0
- package/scripts/telemetry/lib/usage.sh +282 -32
- package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
- package/scripts/telemetry/telemetry.sh +76 -1
- package/src/builder-flow-runtime.ts +9 -3
- package/src/cli/assignment-provider.ts +5 -26
- package/src/cli/builder-flow-runtime.test.mjs +215 -2
- package/src/cli/continuation-adapter.ts +265 -0
- package/src/cli/continuation-driver.test.mjs +1303 -0
- package/src/cli/public-api.test.mjs +1 -0
- package/src/cli/validate-workflow-artifacts.ts +19 -1
- package/src/cli/workflow-sidecar.ts +18 -23
- package/src/cli/workflow.ts +113 -6
- package/src/continuation-driver.ts +638 -0
- package/src/index.ts +24 -0
- package/src/tools/build-universal-bundles.ts +61 -25
- package/src/tools/validate-source-tree.ts +5 -2
|
@@ -23,10 +23,8 @@
|
|
|
23
23
|
* passed through `sanitizeSegment` (64-char cap; strips `:`) before use
|
|
24
24
|
* — it is never returned verbatim.
|
|
25
25
|
* 2. A runtime-native session-id env var already ambient in the current
|
|
26
|
-
* process's own environment (
|
|
27
|
-
* `
|
|
28
|
-
* var names below are UNVERIFIED in this planning pass — accepted gap,
|
|
29
|
-
* see plan artifact — layer 3 is the correctness backstop for them).
|
|
26
|
+
* process's own environment (including Codex's `CODEX_THREAD_ID`, with
|
|
27
|
+
* `CODEX_SESSION_ID` retained as a backward-compatible legacy input).
|
|
30
28
|
* 3. A process-ancestry fallback: `process.ppid` plus that parent
|
|
31
29
|
* process's exact start timestamp (an absolute timestamp, not an
|
|
32
30
|
* elapsed-time subtraction, to avoid clock-drift flicker across
|
|
@@ -46,7 +44,8 @@
|
|
|
46
44
|
* ancestorActorSeed() → short opaque token from parent PID + start time, else ""
|
|
47
45
|
* sanitizeSegment(value) → value restricted to [A-Za-z0-9_.-], capped 64 chars
|
|
48
46
|
* serializeActor(actor) → actor struct serialized to a single grouping-key-safe string
|
|
49
|
-
*
|
|
47
|
+
* resolveActorIdentity(env) → { actor: string, source: string, actorStruct: object|null }
|
|
48
|
+
* resolveActor(env) → backward-compatible { actor: string, source: string } projection
|
|
50
49
|
* isUnresolvedActor(actor) → boolean (true when actor is empty or the retired literal
|
|
51
50
|
* "local", case-insensitive — single-sourced predicate shared by
|
|
52
51
|
* the lifecycle auto-emit path, the direct CLI liveness path, and
|
|
@@ -61,13 +60,12 @@ const { execFileSync } = require('child_process');
|
|
|
61
60
|
|
|
62
61
|
/** Candidate env var names for each runtime's native session id. */
|
|
63
62
|
const RUNTIME_SESSION_ID_VARS = {
|
|
64
|
-
'claude-code': 'CLAUDE_CODE_SESSION_ID',
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pi: 'PI_SESSION_ID',
|
|
63
|
+
'claude-code': ['CLAUDE_CODE_SESSION_ID'],
|
|
64
|
+
codex: ['CODEX_THREAD_ID', 'CODEX_SESSION_ID'],
|
|
65
|
+
// opencode/pi names remain candidate inputs. Detection failure never blocks
|
|
66
|
+
// resolution; process ancestry is the correctness backstop.
|
|
67
|
+
opencode: ['OPENCODE_SESSION_ID'],
|
|
68
|
+
pi: ['PI_SESSION_ID'],
|
|
71
69
|
};
|
|
72
70
|
|
|
73
71
|
/**
|
|
@@ -83,7 +81,7 @@ function detectRuntime(env = process.env) {
|
|
|
83
81
|
if (env.CLAUDECODE === '1' || String(env.CLAUDE_CODE_SESSION_ID || '').trim()) {
|
|
84
82
|
return 'claude-code';
|
|
85
83
|
}
|
|
86
|
-
if (String(env.CODEX_SESSION_ID || '').trim()) return 'codex';
|
|
84
|
+
if (String(env.CODEX_THREAD_ID || '').trim() || String(env.CODEX_SESSION_ID || '').trim()) return 'codex';
|
|
87
85
|
if (String(env.OPENCODE_SESSION_ID || '').trim()) return 'opencode';
|
|
88
86
|
if (String(env.PI_SESSION_ID || '').trim()) return 'pi';
|
|
89
87
|
return 'unknown';
|
|
@@ -99,12 +97,28 @@ function detectRuntime(env = process.env) {
|
|
|
99
97
|
* @returns {string} Non-empty session id, or "" if none present
|
|
100
98
|
*/
|
|
101
99
|
function runtimeSessionId(env = process.env) {
|
|
100
|
+
return runtimeSessionCandidate(env).value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Return the ordered native value together with the variable that supplied it. */
|
|
104
|
+
function runtimeSessionCandidate(env = process.env) {
|
|
102
105
|
env = env || {};
|
|
103
|
-
for (const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
+
for (const [runtime, varNames] of Object.entries(RUNTIME_SESSION_ID_VARS)) {
|
|
107
|
+
for (const varName of varNames) {
|
|
108
|
+
const candidate = String(env[varName] || '').trim();
|
|
109
|
+
if (candidate) return { value: candidate, variable: varName, runtime };
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
|
-
return '';
|
|
112
|
+
return { value: '', variable: '', runtime: '' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Privacy-safe, domain-separated token for a Codex thread identifier. */
|
|
116
|
+
function codexThreadToken(threadId) {
|
|
117
|
+
return `thread-${crypto.createHash('sha256')
|
|
118
|
+
.update('flow-agents:codex-thread:v1:')
|
|
119
|
+
.update(String(threadId))
|
|
120
|
+
.digest('hex')
|
|
121
|
+
.slice(0, 24)}`;
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
/**
|
|
@@ -362,11 +376,11 @@ function serializeActor(actor) {
|
|
|
362
376
|
* @param {NodeJS.ProcessEnv} [env] Environment to resolve from (default process.env)
|
|
363
377
|
* @returns {{actor: string, source: string}}
|
|
364
378
|
*/
|
|
365
|
-
function
|
|
379
|
+
function resolveActorIdentity(env = process.env) {
|
|
366
380
|
env = env || {};
|
|
367
381
|
|
|
368
382
|
if (env.FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED === '1' && env.NODE_ENV === 'test') {
|
|
369
|
-
return { actor: '', source: 'test-forced-unresolved' };
|
|
383
|
+
return { actor: '', source: 'test-forced-unresolved', actorStruct: null };
|
|
370
384
|
}
|
|
371
385
|
|
|
372
386
|
const explicit = String(env.FLOW_AGENTS_ACTOR || '').trim();
|
|
@@ -392,15 +406,26 @@ function resolveActor(env = process.env) {
|
|
|
392
406
|
);
|
|
393
407
|
} catch { /* best-effort diagnostic only */ }
|
|
394
408
|
} else {
|
|
395
|
-
|
|
409
|
+
const actor = sanitizeSegment(explicit);
|
|
410
|
+
return {
|
|
411
|
+
actor,
|
|
412
|
+
source: 'explicit-override',
|
|
413
|
+
// Explicit overrides deliberately retain their historical flat canonical key. The struct
|
|
414
|
+
// exists for durable descriptive records, but serializeActor(actorStruct) is not the key.
|
|
415
|
+
actorStruct: { runtime: 'explicit-override', session_id: actor, host: os.hostname() },
|
|
416
|
+
};
|
|
396
417
|
}
|
|
397
418
|
}
|
|
398
419
|
|
|
399
420
|
const runtime = detectRuntime(env);
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
const
|
|
403
|
-
|
|
421
|
+
const nativeSession = runtimeSessionCandidate(env);
|
|
422
|
+
if (nativeSession.value) {
|
|
423
|
+
const sessionId = nativeSession.variable === 'CODEX_THREAD_ID'
|
|
424
|
+
? codexThreadToken(nativeSession.value)
|
|
425
|
+
: nativeSession.value;
|
|
426
|
+
const actorStruct = { runtime, session_id: sessionId, host: os.hostname() };
|
|
427
|
+
const actor = serializeActor(actorStruct);
|
|
428
|
+
return { actor, source: `runtime-session-id:${runtime}`, actorStruct };
|
|
404
429
|
}
|
|
405
430
|
|
|
406
431
|
// #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
|
|
@@ -411,17 +436,24 @@ function resolveActor(env = process.env) {
|
|
|
411
436
|
// verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
|
|
412
437
|
const ci = detectCiActor(env);
|
|
413
438
|
if (ci && ci.session_id) {
|
|
414
|
-
const
|
|
415
|
-
|
|
439
|
+
const actorStruct = { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() };
|
|
440
|
+
const actor = serializeActor(actorStruct);
|
|
441
|
+
return { actor, source: `ci-runtime:${ci.runtime}`, actorStruct };
|
|
416
442
|
}
|
|
417
443
|
|
|
418
444
|
const seed = ancestorActorSeed();
|
|
419
445
|
if (seed) {
|
|
420
|
-
const
|
|
421
|
-
|
|
446
|
+
const actorStruct = { runtime: 'process-ancestry', session_id: `anc-${seed}`, host: os.hostname() };
|
|
447
|
+
const actor = serializeActor(actorStruct);
|
|
448
|
+
return { actor, source: 'process-ancestry', actorStruct };
|
|
422
449
|
}
|
|
423
450
|
|
|
424
|
-
return { actor: '', source: 'unresolved' };
|
|
451
|
+
return { actor: '', source: 'unresolved', actorStruct: null };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function resolveActor(env = process.env) {
|
|
455
|
+
const { actor, source } = resolveActorIdentity(env);
|
|
456
|
+
return { actor, source };
|
|
425
457
|
}
|
|
426
458
|
|
|
427
459
|
/**
|
|
@@ -445,6 +477,7 @@ module.exports = {
|
|
|
445
477
|
ancestorActorSeed,
|
|
446
478
|
sanitizeSegment,
|
|
447
479
|
serializeActor,
|
|
480
|
+
resolveActorIdentity,
|
|
448
481
|
resolveActor,
|
|
449
482
|
isUnresolvedActor,
|
|
450
483
|
};
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A transient, driver-owned capability for one adapter turn. Ed25519 proves
|
|
4
|
+
// that the record was issued by the still-running driver's private key; it is
|
|
5
|
+
// not a filesystem isolation boundary against a hostile same-UID process.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { createHash, generateKeyPairSync, randomBytes, randomUUID, sign, verify } = require('crypto');
|
|
10
|
+
const { isDeepStrictEqual } = require('util');
|
|
11
|
+
|
|
12
|
+
const SCHEMA_VERSION = '2.0';
|
|
13
|
+
const FILE_NAME = 'active-turn.json';
|
|
14
|
+
const MAX_ACTIVE_TURN_BYTES = 16 * 1024;
|
|
15
|
+
const MAX_DRIVER_RECORD_BYTES = 1024 * 1024;
|
|
16
|
+
const MAX_ASSIGNMENT_RECORD_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_LOCK_RECORD_BYTES = 16 * 1024;
|
|
18
|
+
const MAX_TIMEOUT_MS = 86_400_000;
|
|
19
|
+
|
|
20
|
+
function activeTurnFile(sessionDir) {
|
|
21
|
+
return path.join(path.resolve(sessionDir), 'continuation-driver', FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function issueActiveTurnAuthority(input) {
|
|
25
|
+
const sessionDir = path.resolve(input.sessionDir);
|
|
26
|
+
const parents = captureParents(sessionDir, true);
|
|
27
|
+
const issuedAt = input.now instanceof Date ? input.now : new Date();
|
|
28
|
+
const timeoutMs = input.timeoutMs;
|
|
29
|
+
assertPositiveInteger(timeoutMs, 'timeoutMs', 1, MAX_TIMEOUT_MS);
|
|
30
|
+
const assignmentActor = requiredString(input.assignmentActor, 'assignmentActor');
|
|
31
|
+
const assignmentActorStruct = requiredActorStruct(input.assignmentActorStruct, 'assignmentActorStruct');
|
|
32
|
+
const assignment = readActiveAssignment(sessionDir, parents, assignmentActor, assignmentActorStruct);
|
|
33
|
+
const keys = generateKeyPairSync('ed25519');
|
|
34
|
+
const turnSecret = randomBytes(32).toString('base64url');
|
|
35
|
+
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
36
|
+
const publicKeyDigest = sha256(publicKey);
|
|
37
|
+
const record = {
|
|
38
|
+
schema_version: SCHEMA_VERSION,
|
|
39
|
+
run_id: requiredSafeRunId(input.runId, 'runId'),
|
|
40
|
+
definition_id: requiredString(input.definitionId, 'definitionId'),
|
|
41
|
+
issued_step: requiredString(input.currentStep, 'currentStep'),
|
|
42
|
+
iteration: requiredInteger(input.iteration, 'iteration', 1, 100),
|
|
43
|
+
max_turns: requiredInteger(input.maxTurns, 'maxTurns', 1, 100),
|
|
44
|
+
adapter_command_identity: requiredString(input.adapterCommandIdentity, 'adapterCommandIdentity'),
|
|
45
|
+
assignment_actor: assignmentActor,
|
|
46
|
+
assignment_actor_struct: assignmentActorStruct,
|
|
47
|
+
assignment_record_sha256: assignment.sha256,
|
|
48
|
+
driver_lock: {
|
|
49
|
+
pid: requiredInteger(input.lock.pid, 'lock.pid', 1, 2_147_483_647),
|
|
50
|
+
token: requiredString(input.lock.token, 'lock.token'),
|
|
51
|
+
created_at: requiredTimestamp(input.lock.created_at, 'lock.created_at'),
|
|
52
|
+
},
|
|
53
|
+
turn_secret_sha256: sha256(turnSecret),
|
|
54
|
+
public_key_spki_b64: publicKey,
|
|
55
|
+
public_key_digest: publicKeyDigest,
|
|
56
|
+
timeout_ms: timeoutMs,
|
|
57
|
+
issued_at: issuedAt.toISOString(),
|
|
58
|
+
expires_at: new Date(issuedAt.getTime() + timeoutMs).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
if (record.run_id !== path.basename(sessionDir)) throw new Error('runId must match the session directory');
|
|
61
|
+
record.signature_b64 = sign(null, canonicalBytes(record), keys.privateKey).toString('base64');
|
|
62
|
+
writeRecord(sessionDir, parents, record);
|
|
63
|
+
return {
|
|
64
|
+
runId: record.run_id,
|
|
65
|
+
turnSecret,
|
|
66
|
+
publicKeyDigest,
|
|
67
|
+
record,
|
|
68
|
+
cleanup: () => removeActiveTurnAuthority(sessionDir, record.turn_secret_sha256, parents),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeActiveTurnAuthority(sessionDir, turnSecretSha256, issuedParents) {
|
|
73
|
+
try {
|
|
74
|
+
const parents = captureParents(sessionDir, false);
|
|
75
|
+
if (issuedParents && !sameParents(issuedParents, parents)) return false;
|
|
76
|
+
const file = activeTurnFile(sessionDir);
|
|
77
|
+
const loaded = readRegularJson(file, 'continuation active turn', parents, MAX_ACTIVE_TURN_BYTES);
|
|
78
|
+
if (loaded.value.turn_secret_sha256 !== turnSecretSha256) return false;
|
|
79
|
+
assertParentsStable(parents);
|
|
80
|
+
const current = fileIdentity(file, 'continuation active turn');
|
|
81
|
+
if (!sameIdentity(current, loaded.identity)) return false;
|
|
82
|
+
fs.unlinkSync(file);
|
|
83
|
+
assertParentsStable(parents);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
// Do not unlink through a changed parent. The signed record expires.
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validates the signed, live assignment capability without consulting Flow's
|
|
92
|
+
// current state. Public assignment-gated workflow commands use this narrow
|
|
93
|
+
// fallback after ordinary actor resolution fails; Stop adds canonical-state
|
|
94
|
+
// checks before treating its unfinished-gate warning as advisory.
|
|
95
|
+
function validateSignedActiveTurnAssignmentAuthority(input) {
|
|
96
|
+
try {
|
|
97
|
+
const runId = requiredSafeRunId(input.runId, 'runId');
|
|
98
|
+
const turnSecret = requiredTurnSecret(input.turnSecret);
|
|
99
|
+
const sessionDir = path.resolve(input.sessionDir);
|
|
100
|
+
if (path.basename(sessionDir) !== runId) return invalid('run does not match session');
|
|
101
|
+
const parents = captureParents(sessionDir, false);
|
|
102
|
+
const record = readRegularJson(activeTurnFile(sessionDir), 'continuation active turn', parents, MAX_ACTIVE_TURN_BYTES).value;
|
|
103
|
+
validateRecord(record);
|
|
104
|
+
if (!equalText(record.run_id, runId)) return invalid('run does not match signed authority');
|
|
105
|
+
if (!equalText(record.turn_secret_sha256, sha256(turnSecret))) return invalid('turn secret does not match');
|
|
106
|
+
if (!validSignature(record)) return invalid('authority signature does not verify');
|
|
107
|
+
const assignment = readActiveAssignment(sessionDir, parents, record.assignment_actor, record.assignment_actor_struct);
|
|
108
|
+
if (!equalText(record.assignment_record_sha256, assignment.sha256)) return invalid('active assignment record changed');
|
|
109
|
+
const mission = readRegularJson(path.join(sessionDir, 'continuation-driver', 'state.json'), 'continuation driver state', parents, MAX_DRIVER_RECORD_BYTES).value;
|
|
110
|
+
if (!mission || mission.schema_version !== '1.0'
|
|
111
|
+
|| mission.run_id !== record.run_id
|
|
112
|
+
|| mission.definition_id !== record.definition_id
|
|
113
|
+
|| mission.adapter_command_identity !== record.adapter_command_identity
|
|
114
|
+
|| mission.max_turns !== record.max_turns
|
|
115
|
+
|| mission.turns_started !== record.iteration
|
|
116
|
+
|| mission.active_turn_step !== record.issued_step
|
|
117
|
+
|| mission.active_turn_public_key_digest !== record.public_key_digest
|
|
118
|
+
|| mission.status !== 'active') return invalid('driver mission does not match');
|
|
119
|
+
const lockFile = path.join(sessionDir, 'continuation-driver', 'locks', `${record.driver_lock.pid}-${record.driver_lock.token}.lock`);
|
|
120
|
+
const lock = readRegularJson(lockFile, 'continuation driver lock', parents, MAX_LOCK_RECORD_BYTES).value;
|
|
121
|
+
if (!lock || lock.schema_version !== '1.0'
|
|
122
|
+
|| lock.pid !== record.driver_lock.pid
|
|
123
|
+
|| lock.token !== record.driver_lock.token
|
|
124
|
+
|| lock.created_at !== record.driver_lock.created_at
|
|
125
|
+
|| !processAlive(lock.pid)) return invalid('driver lock is not live');
|
|
126
|
+
const now = input.now instanceof Date ? input.now : new Date();
|
|
127
|
+
if (now.getTime() < Date.parse(record.issued_at) || now.getTime() > Date.parse(record.expires_at)) return invalid('authority is expired');
|
|
128
|
+
assertParentsStable(parents);
|
|
129
|
+
return { valid: true, record };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return invalid(error && error.message ? error.message : 'authority is malformed');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateActiveTurnAuthority(input) {
|
|
136
|
+
const base = validateSignedActiveTurnAssignmentAuthority(input);
|
|
137
|
+
if (!base.valid) return base;
|
|
138
|
+
try {
|
|
139
|
+
const canonical = input.canonicalState;
|
|
140
|
+
const record = base.record;
|
|
141
|
+
// The issued step remains bound to the driver mission, while canonical
|
|
142
|
+
// Flow may advance during the adapter's Stop callback.
|
|
143
|
+
if (!canonical || canonical.status !== 'active'
|
|
144
|
+
|| canonical.run_id !== record.run_id
|
|
145
|
+
|| canonical.definition_id !== record.definition_id
|
|
146
|
+
|| typeof canonical.current_step !== 'string' || !canonical.current_step) return invalid('canonical Flow state does not match');
|
|
147
|
+
return base;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return invalid(error && error.message ? error.message : 'canonical Flow state is malformed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function invalid(reason) {
|
|
154
|
+
return { valid: false, reason };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeRecord(sessionDir, parents, record) {
|
|
158
|
+
assertParentsStable(parents);
|
|
159
|
+
const file = activeTurnFile(sessionDir);
|
|
160
|
+
if (fs.existsSync(file)) fileIdentity(file, 'continuation active turn');
|
|
161
|
+
const temp = path.join(path.dirname(file), `.${FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
|
|
162
|
+
let tempIdentity = null;
|
|
163
|
+
try {
|
|
164
|
+
fs.writeFileSync(temp, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
|
|
165
|
+
tempIdentity = fileIdentity(temp, 'continuation active turn temporary file');
|
|
166
|
+
assertParentsStable(parents);
|
|
167
|
+
fs.renameSync(temp, file);
|
|
168
|
+
assertParentsStable(parents);
|
|
169
|
+
} finally {
|
|
170
|
+
removeOwnedTemp(temp, tempIdentity, parents);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function removeOwnedTemp(temp, expected, parents) {
|
|
175
|
+
if (!expected) return;
|
|
176
|
+
try {
|
|
177
|
+
assertParentsStable(parents);
|
|
178
|
+
if (!sameIdentity(fileIdentity(temp, 'continuation active turn temporary file'), expected)) return;
|
|
179
|
+
fs.unlinkSync(temp);
|
|
180
|
+
assertParentsStable(parents);
|
|
181
|
+
} catch {
|
|
182
|
+
// Parent or temp identity changed. Leave the bounded temporary record for
|
|
183
|
+
// operator cleanup rather than unlinking through a replacement path.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function captureParents(sessionDir, create) {
|
|
188
|
+
const session = safeDirectory(path.resolve(sessionDir), 'session directory', create);
|
|
189
|
+
const driver = safeDirectory(path.join(session.path, 'continuation-driver'), 'continuation driver directory', create);
|
|
190
|
+
const locks = safeDirectory(path.join(driver.path, 'locks'), 'continuation driver locks directory', create);
|
|
191
|
+
const assignment = safeDirectory(path.join(path.dirname(session.path), 'assignment'), 'workflow assignment directory', false);
|
|
192
|
+
return { session, driver, locks, assignment };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function safeDirectory(target, label, create) {
|
|
196
|
+
if (create) fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
197
|
+
const stat = fs.lstatSync(target);
|
|
198
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a non-symlink directory`);
|
|
199
|
+
const realpath = fs.realpathSync(target);
|
|
200
|
+
return { path: path.resolve(target), realpath, dev: stat.dev, ino: stat.ino };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assertParentsStable(parents) {
|
|
204
|
+
for (const parent of [parents.session, parents.driver, parents.locks, parents.assignment]) {
|
|
205
|
+
const current = safeDirectory(parent.path, 'continuation authority parent', false);
|
|
206
|
+
if (!sameIdentity(parent, current) || current.realpath !== parent.realpath) throw new Error('continuation authority parent identity changed');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sameParents(left, right) {
|
|
211
|
+
return ['session', 'driver', 'locks', 'assignment'].every((name) => sameIdentity(left[name], right[name]) && left[name].realpath === right[name].realpath);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sameIdentity(left, right) {
|
|
215
|
+
return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readActiveAssignment(sessionDir, parents, actor, actorStruct) {
|
|
219
|
+
assertParentsStable(parents);
|
|
220
|
+
const artifactRoot = path.dirname(sessionDir);
|
|
221
|
+
const assignmentFile = path.join(parents.assignment.path, `${path.basename(sessionDir)}.json`);
|
|
222
|
+
const loaded = readRegularJson(assignmentFile, 'workflow assignment', parents, MAX_ASSIGNMENT_RECORD_BYTES);
|
|
223
|
+
const assignment = loaded.value;
|
|
224
|
+
if (!assignment || assignment.status !== 'claimed' || assignment.artifact_dir !== path.basename(sessionDir) || assignment.actor_key !== actor
|
|
225
|
+
|| !isDeepStrictEqual(normalizeActorStruct(assignment.actor), normalizeActorStruct(actorStruct))) {
|
|
226
|
+
throw new Error('workflow assignment is not the active turn actor');
|
|
227
|
+
}
|
|
228
|
+
assertParentsStable(parents);
|
|
229
|
+
return { sha256: sha256(loaded.bytes) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readRegularJson(file, label, parents, maxBytes) {
|
|
233
|
+
if (parents) assertParentsStable(parents);
|
|
234
|
+
const expected = fileIdentity(file, label);
|
|
235
|
+
if (expected.size > maxBytes) throw new Error(`${label} exceeds maximum size`);
|
|
236
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
237
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
238
|
+
try {
|
|
239
|
+
const opened = fs.fstatSync(fd);
|
|
240
|
+
if (!opened.isFile() || opened.size > maxBytes || !sameIdentity(expected, opened)) throw new Error(`${label} identity changed while opening`);
|
|
241
|
+
const bytes = fs.readFileSync(fd);
|
|
242
|
+
const after = fs.fstatSync(fd);
|
|
243
|
+
if (!sameIdentity(expected, after)) throw new Error(`${label} identity changed while reading`);
|
|
244
|
+
const final = fileIdentity(file, label);
|
|
245
|
+
if (!sameIdentity(expected, final)) throw new Error(`${label} identity changed after reading`);
|
|
246
|
+
const value = JSON.parse(bytes.toString('utf8'));
|
|
247
|
+
if (parents) assertParentsStable(parents);
|
|
248
|
+
return { value, bytes, identity: expected };
|
|
249
|
+
} finally {
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function fileIdentity(file, label) {
|
|
255
|
+
const stat = fs.lstatSync(file);
|
|
256
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a bounded non-symlink regular file`);
|
|
257
|
+
return { dev: stat.dev, ino: stat.ino, size: stat.size };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateRecord(record) {
|
|
261
|
+
if (!record || typeof record !== 'object' || Array.isArray(record) || record.schema_version !== SCHEMA_VERSION) throw new Error('authority schema is unsupported');
|
|
262
|
+
for (const field of ['run_id', 'definition_id', 'issued_step', 'adapter_command_identity', 'assignment_actor', 'assignment_record_sha256', 'turn_secret_sha256', 'public_key_spki_b64', 'public_key_digest', 'signature_b64', 'issued_at', 'expires_at']) requiredString(record[field], field);
|
|
263
|
+
requiredSafeRunId(record.run_id, 'run_id');
|
|
264
|
+
requiredActorStruct(record.assignment_actor_struct, 'assignment_actor_struct');
|
|
265
|
+
if (!/^[0-9a-f]{64}$/i.test(record.assignment_record_sha256)
|
|
266
|
+
|| !/^[0-9a-f]{64}$/i.test(record.turn_secret_sha256)
|
|
267
|
+
|| !/^[0-9a-f]{64}$/i.test(record.public_key_digest)) throw new Error('authority digest is malformed');
|
|
268
|
+
requiredInteger(record.iteration, 'iteration', 1, 100);
|
|
269
|
+
requiredInteger(record.max_turns, 'max_turns', 1, 100);
|
|
270
|
+
if (record.iteration > record.max_turns) throw new Error('authority iteration exceeds mission budget');
|
|
271
|
+
assertPositiveInteger(record.timeout_ms, 'timeout_ms', 1, MAX_TIMEOUT_MS);
|
|
272
|
+
const issued = Date.parse(requiredTimestamp(record.issued_at, 'issued_at'));
|
|
273
|
+
const expires = Date.parse(requiredTimestamp(record.expires_at, 'expires_at'));
|
|
274
|
+
if (expires !== issued + record.timeout_ms) throw new Error('authority expiry does not match timeout');
|
|
275
|
+
if (!record.driver_lock || typeof record.driver_lock !== 'object' || Array.isArray(record.driver_lock)) throw new Error('authority lock is malformed');
|
|
276
|
+
requiredInteger(record.driver_lock.pid, 'driver_lock.pid', 1, 2_147_483_647);
|
|
277
|
+
requiredString(record.driver_lock.token, 'driver_lock.token');
|
|
278
|
+
requiredTimestamp(record.driver_lock.created_at, 'driver_lock.created_at');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validSignature(record) {
|
|
282
|
+
const unsigned = { ...record };
|
|
283
|
+
const signature = Buffer.from(unsigned.signature_b64, 'base64');
|
|
284
|
+
delete unsigned.signature_b64;
|
|
285
|
+
const publicKey = { key: Buffer.from(unsigned.public_key_spki_b64, 'base64'), format: 'der', type: 'spki' };
|
|
286
|
+
return equalText(sha256(unsigned.public_key_spki_b64), unsigned.public_key_digest)
|
|
287
|
+
&& verify(null, canonicalBytes(unsigned), publicKey, signature);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function canonicalBytes(value) {
|
|
291
|
+
return Buffer.from(JSON.stringify(canonicalize(value)), 'utf8');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function canonicalize(value) {
|
|
295
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
296
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sha256(value) {
|
|
301
|
+
return createHash('sha256').update(value).digest('hex');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function equalText(left, right) {
|
|
305
|
+
const a = Buffer.from(String(left));
|
|
306
|
+
const b = Buffer.from(String(right));
|
|
307
|
+
return a.length === b.length && require('crypto').timingSafeEqual(a, b);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function processAlive(pid) {
|
|
311
|
+
try {
|
|
312
|
+
process.kill(pid, 0);
|
|
313
|
+
return true;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return error && error.code === 'EPERM';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function requiredString(value, label) {
|
|
320
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 4096 || value.includes('\0')) throw new Error(`${label} must be a bounded non-empty string`);
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function requiredSafeRunId(value, label) {
|
|
325
|
+
const text = requiredString(value, label);
|
|
326
|
+
if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(text)) throw new Error(`${label} must be a safe Flow run id`);
|
|
327
|
+
return text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function requiredTurnSecret(value) {
|
|
331
|
+
const text = requiredString(value, 'turnSecret');
|
|
332
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(text)) throw new Error('turnSecret must be a 32-byte base64url secret');
|
|
333
|
+
return text;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function requiredActorStruct(value, label) {
|
|
337
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an actor object`);
|
|
338
|
+
const keys = Object.keys(value);
|
|
339
|
+
if (!keys.every((key) => key === 'runtime' || key === 'session_id' || key === 'host' || key === 'human')
|
|
340
|
+
|| !['runtime', 'session_id', 'host'].every((key) => Object.prototype.hasOwnProperty.call(value, key))) throw new Error(`${label} has unsupported fields`);
|
|
341
|
+
for (const key of ['runtime', 'session_id', 'host']) requiredBoundedActorField(value[key], `${label}.${key}`);
|
|
342
|
+
if (Object.prototype.hasOwnProperty.call(value, 'human') && value.human !== null) requiredBoundedActorField(value.human, `${label}.human`);
|
|
343
|
+
return {
|
|
344
|
+
runtime: value.runtime,
|
|
345
|
+
session_id: value.session_id,
|
|
346
|
+
host: value.host,
|
|
347
|
+
...(Object.prototype.hasOwnProperty.call(value, 'human') ? { human: value.human } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizeActorStruct(value) {
|
|
352
|
+
try {
|
|
353
|
+
const actor = requiredActorStruct(value, 'assignment actor');
|
|
354
|
+
return { ...actor, human: actor.human ?? null };
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function requiredBoundedActorField(value, label) {
|
|
361
|
+
if (typeof value !== 'string' || !value || value.length > 256 || /[\u0000-\u001f\u007f-\u009f]/.test(value)) throw new Error(`${label} must be a bounded non-empty string`);
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function requiredInteger(value, label, min, max) {
|
|
366
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} through ${max}`);
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function assertPositiveInteger(value, label, min, max) {
|
|
371
|
+
requiredInteger(value, label, min, max);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function requiredTimestamp(value, label) {
|
|
375
|
+
const text = requiredString(value, label);
|
|
376
|
+
if (!Number.isFinite(Date.parse(text))) throw new Error(`${label} must be an ISO timestamp`);
|
|
377
|
+
return text;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = {
|
|
381
|
+
activeTurnFile,
|
|
382
|
+
issueActiveTurnAuthority,
|
|
383
|
+
removeActiveTurnAuthority,
|
|
384
|
+
validateActiveTurnAuthority,
|
|
385
|
+
validateSignedActiveTurnAssignmentAuthority,
|
|
386
|
+
};
|