@afokapu/atdd-flow 0.3.73 → 0.3.75
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/package.json +1 -1
- package/src/adoption-authorizations.ts +88 -25
- package/src/handover.ts +1 -1
- package/src/stale-main-recovery.ts +40 -15
package/package.json
CHANGED
|
@@ -22,19 +22,47 @@ export type ExactSessionAdoptionAuthorization = {
|
|
|
22
22
|
target: { herdr_session: string; pane: string; cwd: string };
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// Ids are path components, so only A- plus a safe charset is accepted; this blocks traversal.
|
|
25
26
|
const validId = (value: string) => /^A-[A-Za-z0-9_-]+$/.test(value);
|
|
27
|
+
// A blank value can never bind an adoption, so whitespace-only strings count as missing.
|
|
26
28
|
const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
// Records and their digests live side by side in one create-only directory.
|
|
30
|
+
function authorizationDir(root: string) {
|
|
31
|
+
return join(root, ".atdd-flow", "exact-session-adoption-authorizations");
|
|
32
|
+
}
|
|
33
|
+
function path(root: string, id: string) {
|
|
34
|
+
return join(authorizationDir(root), `${id}.yaml`);
|
|
35
|
+
}
|
|
36
|
+
function hashPath(root: string, id: string) {
|
|
37
|
+
return join(authorizationDir(root), `${id}.sha256`);
|
|
38
|
+
}
|
|
39
|
+
// SHA-256 of the exact record bytes; any later edit changes the digest.
|
|
29
40
|
const digest = (content: string) => createHash("sha256").update(content).digest("hex");
|
|
30
41
|
|
|
42
|
+
// The tuple check is split by concern so every predicate stays small and independently readable.
|
|
43
|
+
function validHeader(record: ExactSessionAdoptionAuthorization) {
|
|
44
|
+
return record.schema === authorizationSchema && validId(record.id) && record.issued_by === "operator@desk" && nonEmpty(record.created_at);
|
|
45
|
+
}
|
|
46
|
+
// Seat and Pi session identity must be present.
|
|
47
|
+
function validSubject(record: ExactSessionAdoptionAuthorization) {
|
|
48
|
+
return nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path);
|
|
49
|
+
}
|
|
50
|
+
// The source Herdr placement must be present.
|
|
51
|
+
function validSource(record: ExactSessionAdoptionAuthorization) {
|
|
52
|
+
return nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane);
|
|
53
|
+
}
|
|
54
|
+
// The target Herdr placement and working directory must be present.
|
|
55
|
+
function validTarget(record: ExactSessionAdoptionAuthorization) {
|
|
56
|
+
return nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
|
|
57
|
+
}
|
|
31
58
|
function tuple(record: ExactSessionAdoptionAuthorization) {
|
|
32
|
-
return record
|
|
33
|
-
&& nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path)
|
|
34
|
-
&& nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane)
|
|
35
|
-
&& nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
|
|
59
|
+
return validHeader(record) && validSubject(record) && validSource(record) && validTarget(record);
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Issue a create-only operator authorization: the record and its digest are written with the `wx` flag,
|
|
64
|
+
* so an existing id can never be overwritten or silently reissued with a different tuple.
|
|
65
|
+
*/
|
|
38
66
|
export async function createExactSessionAdoptionAuthorization(root: string, id: string, args: string[]) {
|
|
39
67
|
if (!validId(id)) throw new Error("Exact-session adoption authorization id must use A- followed by letters, numbers, underscores, or hyphens.");
|
|
40
68
|
if (words(args, "--by") !== "operator@desk") throw new Error("Only operator@desk may issue an exact-session adoption authorization.");
|
|
@@ -53,40 +81,75 @@ export async function createExactSessionAdoptionAuthorization(root: string, id:
|
|
|
53
81
|
process.stdout.write(`${id}\n`);
|
|
54
82
|
}
|
|
55
83
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
// The adoption tuple a launch asks to use; the stored record must match it field for field.
|
|
85
|
+
type ExpectedTuple = Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">;
|
|
86
|
+
|
|
87
|
+
// The operator-issued record must bind every element of the adoption tuple, not a subset.
|
|
88
|
+
// Seat and Pi session identity.
|
|
89
|
+
function sameSubject(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
|
|
90
|
+
return record.seat === expected.seat && record.pi_session === expected.pi_session && record.pi_session_path === expected.pi_session_path;
|
|
91
|
+
}
|
|
92
|
+
// Where the session runs before the move.
|
|
93
|
+
function sameSource(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
|
|
94
|
+
return record.source.herdr_session === expected.source.herdr_session && record.source.pane === expected.source.pane;
|
|
95
|
+
}
|
|
96
|
+
// Where the session will run after the move, including its working directory.
|
|
97
|
+
function sameTarget(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
|
|
98
|
+
return record.target.herdr_session === expected.target.herdr_session && record.target.pane === expected.target.pane && record.target.cwd === expected.target.cwd;
|
|
99
|
+
}
|
|
100
|
+
function same(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
|
|
101
|
+
return sameSubject(record, expected) && sameSource(record, expected) && sameTarget(record, expected);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Narrow an unknown YAML value to a plain object so nested receipt fields can be read without casts.
|
|
105
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined => (value && typeof value === "object" ? (value as Record<string, unknown>) : undefined);
|
|
106
|
+
|
|
107
|
+
// A receipt names an authorization when relocation.authorization.id equals the id.
|
|
108
|
+
function receiptNames(receipt: unknown, id: string) {
|
|
109
|
+
return asRecord(asRecord(asRecord(receipt)?.relocation)?.authorization)?.id === id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A receipt that cannot be read or parsed never authorizes reuse, so it counts as not naming the id.
|
|
113
|
+
async function receiptFileNames(file: string, id: string) {
|
|
114
|
+
try { return receiptNames(yaml.parse<unknown>(await readFile(file, "utf8")), id); }
|
|
115
|
+
catch { return false; }
|
|
60
116
|
}
|
|
61
117
|
|
|
118
|
+
// A receipt that already names this authorization id means the one-shot authorization was consumed.
|
|
62
119
|
async function used(root: string, id: string) {
|
|
63
120
|
const folder = join(root, ".atdd-flow", "runtime-launch");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (authorization && typeof authorization === "object" && (authorization as Record<string, unknown>).id === id) return true;
|
|
71
|
-
} catch { /* Malformed receipts never authorize reuse. */ }
|
|
72
|
-
}
|
|
73
|
-
} catch { /* No previous receipt means unused. */ }
|
|
121
|
+
let files: string[];
|
|
122
|
+
try { files = await readdir(folder); }
|
|
123
|
+
catch { return false; } // No previous receipt means unused.
|
|
124
|
+
for (const file of files.filter((entry) => entry.endsWith(".yaml"))) {
|
|
125
|
+
if (await receiptFileNames(join(folder, file), id)) return true;
|
|
126
|
+
}
|
|
74
127
|
return false;
|
|
75
128
|
}
|
|
76
129
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
|
|
130
|
+
// Each reader fails with the same operator-facing message the combined resolver always used.
|
|
131
|
+
async function readRecord(root: string, id: string) {
|
|
80
132
|
let raw: string;
|
|
81
133
|
try { raw = await readFile(path(root, id), "utf8"); }
|
|
82
134
|
catch { throw new Error("Exact-session adoption requires an existing immutable operator authorization record."); }
|
|
83
|
-
|
|
84
|
-
try { record = yaml.parse<ExactSessionAdoptionAuthorization>(raw); }
|
|
135
|
+
try { return { raw, record: yaml.parse<ExactSessionAdoptionAuthorization>(raw) }; }
|
|
85
136
|
catch { throw new Error("Exact-session adoption requires a well-formed immutable operator authorization record."); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The digest written next to the record at creation proves the record was never edited afterwards.
|
|
140
|
+
async function readStoredHash(root: string, id: string, raw: string) {
|
|
86
141
|
let storedHash: string;
|
|
87
142
|
try { storedHash = (await readFile(hashPath(root, id), "utf8")).trim(); }
|
|
88
143
|
catch { throw new Error("Exact-session adoption requires a create-only immutable operator authorization record."); }
|
|
89
144
|
if (!/^[a-f0-9]{64}$/i.test(storedHash) || storedHash !== digest(raw)) throw new Error("Exact-session adoption requires an immutable operator authorization record.");
|
|
145
|
+
return storedHash;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resolve only a create-only operator record whose complete tuple matches before runtime projection. */
|
|
149
|
+
export async function resolveExactSessionAdoptionAuthorization(root: string, id: string, expected: ExpectedTuple) {
|
|
150
|
+
if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
|
|
151
|
+
const { raw, record } = await readRecord(root, id);
|
|
152
|
+
const storedHash = await readStoredHash(root, id, raw);
|
|
90
153
|
if (!tuple(record) || record.id !== id || !same(record, expected)) throw new Error("Exact-session adoption requires an immutable operator authorization record bound to the exact adoption tuple.");
|
|
91
154
|
if (await used(root, id)) throw new Error("Exact-session adoption authorization record was already used.");
|
|
92
155
|
return { id, hash: storedHash };
|
package/src/handover.ts
CHANGED
|
@@ -745,7 +745,7 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
745
745
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
746
746
|
});
|
|
747
747
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
748
|
-
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical) => verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies, canonical)) : undefined;
|
|
748
|
+
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical, once) => verifyLegacySource(intent.source, intent.target, authorization.hash, once(dependencies), canonical)) : undefined;
|
|
749
749
|
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
750
750
|
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
751
751
|
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// - test:atdd-flow:stale-main-binding-recovery
|
|
4
4
|
// - test:atdd-flow:stale-main-recovery-resumed
|
|
5
5
|
// - test:atdd-flow:stale-main-source-exited-path-a
|
|
6
|
+
// - test:atdd-flow:stale-main-s1-exited-recovery
|
|
6
7
|
// Runtime: bun
|
|
7
8
|
// Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
|
|
8
9
|
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
11
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
11
12
|
import { basename, dirname, join } from "node:path";
|
|
12
|
-
import { type Runtime, type Seat, yaml } from "./core";
|
|
13
|
+
import { type Runtime, type Seat, runOutput, yaml } from "./core";
|
|
13
14
|
import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -20,14 +21,15 @@ import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
|
|
|
20
21
|
export type StaleMainRecovery = {
|
|
21
22
|
canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
|
|
22
23
|
prior_receipt: { path: string; sha256: string; archive: string };
|
|
23
|
-
/** A superseded active request
|
|
24
|
-
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited };
|
|
24
|
+
/** A superseded active request, preserved by hash; `live_source_exited` proves an active recovery request's own live source Pi was absent from every live Pi agent when a newer source replaced it. */
|
|
25
|
+
prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
|
|
25
26
|
live_source: LiveSourceProof;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
/** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
|
|
29
30
|
export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
|
|
30
31
|
|
|
32
|
+
type Run = (argv: string[]) => Promise<string>;
|
|
31
33
|
type Source = HandoverReplacementRequest["source"];
|
|
32
34
|
type Target = HandoverReplacementRequest["target"];
|
|
33
35
|
type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
@@ -94,14 +96,36 @@ async function archiveBytes(root: string, folder: string, seat: string, bytes: s
|
|
|
94
96
|
* matches is replaceable only when its recorded source is exactly the stale canonical source, which the caller has
|
|
95
97
|
* already proven absent from every live Pi agent. The old bytes are archived by hash with that source-exit evidence.
|
|
96
98
|
*/
|
|
97
|
-
async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; verifiedAt: string }) {
|
|
99
|
+
async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; source: Source; verifiedAt: string; assertAbsent: (source: Source) => Promise<void> }) {
|
|
98
100
|
let bytes: string;
|
|
99
101
|
try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
|
|
100
102
|
const prior = yaml.parse<HandoverReplacementRequest>(bytes);
|
|
101
|
-
const
|
|
102
|
-
if (prior.seat !== seat ||
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const kind = priorKind(prior, isLive, exit);
|
|
104
|
+
if (prior.seat !== seat || kind === "live") throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime or whose matching source has exited.");
|
|
105
|
+
if (kind === "live_source_exited") await exit.assertAbsent(prior.source);
|
|
106
|
+
const proof = kind === "source_exited" ? { source_exited: sourceExited(exit.canonical, exit.verifiedAt) }
|
|
107
|
+
: kind === "live_source_exited" ? { live_source_exited: sourceExited(prior.source, exit.verifiedAt) } : {};
|
|
108
|
+
return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes), ...proof };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** How an active request relates to the recorded runtime: replaceable as-is, by proven exit of a named source, or still live. */
|
|
112
|
+
function priorKind(prior: HandoverReplacementRequest, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; source: Source }) {
|
|
113
|
+
if (!isLive(prior)) return "unmatched";
|
|
114
|
+
if (sourceIsCanonical(prior.source, exit.canonical)) return "source_exited";
|
|
115
|
+
return prior.stale_main_recovery && !sameSource(prior.source, exit.source) ? "live_source_exited" : "live";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stale-main recovery proves S0 and any replaced S1 absent from one complete global agent-list snapshot, read once. */
|
|
119
|
+
function agentListOnce() {
|
|
120
|
+
let list: Promise<string> | undefined;
|
|
121
|
+
return <T extends { command?: Run }>(dependencies: T): T => {
|
|
122
|
+
const run = dependencies.command ?? runOutput;
|
|
123
|
+
return { ...dependencies, command: (argv: string[]) => argv.slice(3, 5).join(" ") === "agent list" ? (list ??= run(argv)) : run(argv) };
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sameSource(a: Source, b: Source) {
|
|
128
|
+
return a.pi_session === b.pi_session && a.pi_session_path === b.pi_session_path;
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
function sourceIsCanonical(source: Source, canonical: StaleMainRecovery["canonical_source"]) {
|
|
@@ -109,8 +133,8 @@ function sourceIsCanonical(source: Source, canonical: StaleMainRecovery["canonic
|
|
|
109
133
|
&& source.herdr_session === canonical.herdr_session && source.pane === canonical.pane;
|
|
110
134
|
}
|
|
111
135
|
|
|
112
|
-
const sourceExited = (
|
|
113
|
-
({ pi_session:
|
|
136
|
+
const sourceExited = (source: Source, verified_at: string): SourceExited =>
|
|
137
|
+
({ pi_session: source.pi_session, pi_session_path: source.pi_session_path, herdr_session: source.herdr_session, pane: source.pane, verified_at });
|
|
114
138
|
|
|
115
139
|
/** Swap the active request through a staged file so readers never observe a partial request. */
|
|
116
140
|
export async function replaceActiveRequest(file: string, content: string) {
|
|
@@ -157,8 +181,9 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
|
|
|
157
181
|
}
|
|
158
182
|
|
|
159
183
|
function exitMatches(recovery: StaleMainRecovery) {
|
|
160
|
-
const exited = recovery.prior_request?.source_exited;
|
|
161
|
-
return !exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at))
|
|
184
|
+
const exited = recovery.prior_request?.source_exited, replaced = recovery.prior_request?.live_source_exited;
|
|
185
|
+
return (!exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at)))
|
|
186
|
+
&& (!replaced || (!sameSource(replaced, recovery.live_source) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
|
|
@@ -176,9 +201,9 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
|
|
|
176
201
|
}
|
|
177
202
|
|
|
178
203
|
/** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
|
|
179
|
-
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (
|
|
204
|
+
export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
|
|
180
205
|
const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
|
|
181
|
-
const live_source = await verify(canonical);
|
|
182
|
-
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, verifiedAt: live_source.verified_at });
|
|
206
|
+
const once = agentListOnce(), live_source = await verify(canonical, once);
|
|
207
|
+
const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async (source) => { await verify(source, once); } });
|
|
183
208
|
return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
|
|
184
209
|
}
|