vouchington-tooling 0.2.0 → 0.3.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/README.md +32 -0
- package/dist/agent-blackboard/index.d.mts +41 -0
- package/dist/agent-blackboard/index.mjs +94 -0
- package/dist/agent-blackboard/session-id.d.mts +1 -0
- package/dist/agent-blackboard/session-id.mjs +5 -0
- package/dist/agent-blackboard/snapshot-cleanup-directory.d.mts +11 -0
- package/dist/agent-blackboard/snapshot-cleanup-directory.mjs +66 -0
- package/dist/agent-blackboard/snapshot-cleanup-key.d.mts +14 -0
- package/dist/agent-blackboard/snapshot-cleanup-key.mjs +129 -0
- package/dist/agent-blackboard/snapshot-cleanup-receipt.d.mts +6 -0
- package/dist/agent-blackboard/snapshot-cleanup-receipt.mjs +117 -0
- package/dist/agent-blackboard/snapshot-cleanup-resume.d.mts +16 -0
- package/dist/agent-blackboard/snapshot-cleanup-resume.mjs +98 -0
- package/dist/agent-blackboard/snapshot-partition-cleanup.d.mts +13 -0
- package/dist/agent-blackboard/snapshot-partition-cleanup.mjs +136 -0
- package/dist/agent-blackboard/snapshot-partition-format.d.mts +32 -0
- package/dist/agent-blackboard/snapshot-partition-format.mjs +151 -0
- package/dist/agent-blackboard/snapshot-partition-io.d.mts +4 -0
- package/dist/agent-blackboard/snapshot-partition-io.mjs +37 -0
- package/dist/agent-blackboard/snapshot-partition-read.d.mts +9 -0
- package/dist/agent-blackboard/snapshot-partition-read.mjs +70 -0
- package/dist/agent-blackboard/snapshot-partition-validate.d.mts +4 -0
- package/dist/agent-blackboard/snapshot-partition-validate.mjs +40 -0
- package/dist/agent-blackboard/snapshot-partition-write.d.mts +2 -0
- package/dist/agent-blackboard/snapshot-partition-write.mjs +91 -0
- package/dist/agent-blackboard/snapshot-partitions.d.mts +10 -0
- package/dist/agent-blackboard/snapshot-partitions.mjs +102 -0
- package/dist/agent-blackboard/snapshot-types.d.mts +67 -0
- package/dist/agent-blackboard/snapshot-types.mjs +1 -0
- package/dist/agent-blackboard/snapshot.d.mts +3 -0
- package/dist/agent-blackboard/snapshot.mjs +2 -0
- package/dist/cli/commands/agent-blackboard.d.mts +3 -0
- package/dist/cli/commands/agent-blackboard.mjs +114 -0
- package/dist/cli/commands/retrospective-facts.d.mts +1 -0
- package/dist/cli/commands/retrospective-facts.mjs +33 -0
- package/dist/cli/index.mjs +11 -0
- package/dist/cli/parse.d.mts +7 -1
- package/dist/cli/parse.mjs +6 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +12 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/retrospective-facts/exec.d.mts +3 -0
- package/dist/retrospective-facts/exec.mjs +19 -0
- package/dist/retrospective-facts/foreign.d.mts +2 -0
- package/dist/retrospective-facts/foreign.mjs +36 -0
- package/dist/retrospective-facts/format.d.mts +10 -0
- package/dist/retrospective-facts/format.mjs +64 -0
- package/dist/retrospective-facts/index.d.mts +3 -0
- package/dist/retrospective-facts/index.mjs +26 -0
- package/dist/retrospective-facts/local.d.mts +2 -0
- package/dist/retrospective-facts/local.mjs +144 -0
- package/dist/retrospective-facts/shared.d.mts +17 -0
- package/dist/retrospective-facts/shared.mjs +1 -0
- package/package.json +21 -2
- package/scripts/gha/harness-admission-lane.sh +26 -0
- package/scripts/gha/harness-assert-gates.sh +24 -0
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Libraries and the `vouchington` CLI.
|
|
|
6
6
|
npm install vouchington-tooling
|
|
7
7
|
# optional, only if you import vouchington-tooling/sql-ast
|
|
8
8
|
npm install @libpg-query/parser
|
|
9
|
+
# optional, only for vouchington-tooling/agent-blackboard and agent-blackboard CLI commands
|
|
10
|
+
npm install agent-blackboard@^0.3.1
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
## CLI
|
|
@@ -39,8 +41,16 @@ vouchington lint-links --offline
|
|
|
39
41
|
vouchington materialize-pr-context
|
|
40
42
|
vouchington wait-for-apt-locks
|
|
41
43
|
vouchington retrospective-transcript --jsonl /path/to/transcript.jsonl
|
|
44
|
+
vouchington retrospective-facts --pr 49 --repo vouchington/vouchington-infra --raw
|
|
45
|
+
vouchington agent-blackboard probe
|
|
46
|
+
vouchington agent-blackboard journal append --session-id <uuid> --agent codex --version 1 --file note.md
|
|
47
|
+
vouchington agent-blackboard journal entries --session-id <uuid>
|
|
48
|
+
vouchington agent-blackboard snapshot partition --snapshot <snapshot.jsonl> --checksum <sha256> --counts <counts.json>
|
|
49
|
+
vouchington agent-blackboard snapshot cleanup --snapshot <snapshot.jsonl> --partition-directory <partitions-dir> --receipt <receipt-json>
|
|
42
50
|
vouchington install-playwright-chromium-arm64
|
|
43
51
|
vouchington ghcr-package-retention example%2Fapi
|
|
52
|
+
vouchington harness-admission-lane 4
|
|
53
|
+
vouchington harness-assert-gates HARNESS_DISPATCH_ENABLED HARNESS_SHEPHERD_ENABLED
|
|
44
54
|
vouchington nuget-central-version trusted.props candidate.props metadata.json out.props
|
|
45
55
|
vouchington swift-semantic-equal BASE HEAD App.swift
|
|
46
56
|
vouchington post-review
|
|
@@ -68,6 +78,27 @@ layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discove
|
|
|
68
78
|
Grok session root. Without `--session-id`, it reads those session identities from the host
|
|
69
79
|
environment.
|
|
70
80
|
|
|
81
|
+
`retrospective-facts` keeps local Git evidence separate from GitHub PR data. `Commits ahead of
|
|
82
|
+
origin/main` is populated only from a local ancestry range; GitHub responses instead populate
|
|
83
|
+
`PR commits`. API-derived file and directory counts are labelled `GitHub API`. When a named local
|
|
84
|
+
branch is absent, the command refreshes `origin/<branch>` before using it and refuses a stale
|
|
85
|
+
remote ref when that refresh fails.
|
|
86
|
+
For an explicit `--repo`, it performs no local Git checks: `Merged to main` is `yes` only when
|
|
87
|
+
GitHub reports a merged PR whose `baseRefName` is `main`; a merged PR into another base is reported
|
|
88
|
+
as not merged to main, and a missing base is unavailable.
|
|
89
|
+
|
|
90
|
+
Agent Blackboard support is optional: only the `agent-blackboard` subpath and its CLI commands
|
|
91
|
+
need `agent-blackboard@^0.3.1`. Snapshot cleanup accepts only package-generated temporary paths.
|
|
92
|
+
It captures a target under a private tombstone, validates partition names, permissions, JSONL,
|
|
93
|
+
ordering, terminal manifests, and the identity-bound cleanup receipt before deleting files, and
|
|
94
|
+
restores the original path on a validation failure. Once deletion begins, it retains a private
|
|
95
|
+
tombstone plus signed resume metadata instead; retry cleanup with the original partition-directory
|
|
96
|
+
path and the same receipt until it completes. The partition command returns that receipt; directory
|
|
97
|
+
cleanup requires it.
|
|
98
|
+
The receipt is authenticated with an owner-only per-user HMAC key stored under a dedicated
|
|
99
|
+
`0700` directory in the system temporary directory. This small host-local state is the trust
|
|
100
|
+
boundary: a copied or caller-created receipt cannot authorize cleanup without that key.
|
|
101
|
+
|
|
71
102
|
Host-lock environment:
|
|
72
103
|
|
|
73
104
|
| Variable | Default | Meaning |
|
|
@@ -144,6 +175,7 @@ import {
|
|
|
144
175
|
readDiagnosticReportSummaries,
|
|
145
176
|
} from 'vouchington-tooling/vitest-diagnostics'
|
|
146
177
|
import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-transcript'
|
|
178
|
+
import { appendJournal, probeBlackboard } from 'vouchington-tooling/agent-blackboard'
|
|
147
179
|
import { buildSessionFrictionReport, recordFriction } from 'vouchington-tooling/session-friction'
|
|
148
180
|
```
|
|
149
181
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mts';
|
|
2
|
+
export { assertSessionId } from './session-id.mts';
|
|
3
|
+
export type * from './snapshot-types.mts';
|
|
4
|
+
export type BlackboardConnection = {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
token: string;
|
|
7
|
+
readRetry: Record<string, never>;
|
|
8
|
+
};
|
|
9
|
+
export type BlackboardClientModule = {
|
|
10
|
+
Sessions: new (connection: BlackboardConnection) => {
|
|
11
|
+
ensure(input: unknown): Promise<{
|
|
12
|
+
status: 'created' | 'exists';
|
|
13
|
+
}>;
|
|
14
|
+
list(input: unknown): Promise<unknown>;
|
|
15
|
+
get(id: string): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
Entries: new (connection: BlackboardConnection) => {
|
|
18
|
+
append(input: unknown): Promise<{
|
|
19
|
+
createdAt: string;
|
|
20
|
+
}>;
|
|
21
|
+
get(input: unknown): AsyncIterable<unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
type BlackboardClientLoader = () => Promise<BlackboardClientModule>;
|
|
25
|
+
export type BlackboardClientDependencies = {
|
|
26
|
+
loadClient?: BlackboardClientLoader;
|
|
27
|
+
};
|
|
28
|
+
export declare function resolveBlackboardConnection(env?: NodeJS.ProcessEnv): BlackboardConnection;
|
|
29
|
+
export declare function probeBlackboard(env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<void>;
|
|
30
|
+
export declare function appendJournal(input: {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
agent: string;
|
|
33
|
+
version: string;
|
|
34
|
+
markdownFile: string;
|
|
35
|
+
parentSessionId?: string | null;
|
|
36
|
+
timestamp?: string;
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
dependencies?: BlackboardClientDependencies;
|
|
39
|
+
}): Promise<string>;
|
|
40
|
+
export declare function readJournal(sessionId: string, env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<unknown[]>;
|
|
41
|
+
export declare function formatJournalEntries(sessionId: string, entries: unknown[]): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { assertSessionId } from './session-id.mjs';
|
|
3
|
+
export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mjs';
|
|
4
|
+
export { assertSessionId } from './session-id.mjs';
|
|
5
|
+
export function resolveBlackboardConnection(env = process.env) {
|
|
6
|
+
const baseUrl = env.AGENT_BLACKBOARD_URL;
|
|
7
|
+
const token = env.AGENT_BLACKBOARD_TOKEN;
|
|
8
|
+
if (!baseUrl)
|
|
9
|
+
throw new Error('AGENT_BLACKBOARD_URL is not set');
|
|
10
|
+
if (!token)
|
|
11
|
+
throw new Error('AGENT_BLACKBOARD_TOKEN is not set');
|
|
12
|
+
return { baseUrl, token, readRetry: {} };
|
|
13
|
+
}
|
|
14
|
+
export async function probeBlackboard(env, dependencies) {
|
|
15
|
+
const { Sessions } = await loadClient(dependencies?.loadClient);
|
|
16
|
+
await new Sessions(resolveBlackboardConnection(env)).list({ limit: 1 });
|
|
17
|
+
}
|
|
18
|
+
export async function appendJournal(input) {
|
|
19
|
+
assertSessionId(input.sessionId);
|
|
20
|
+
if (input.parentSessionId != null)
|
|
21
|
+
assertSessionId(input.parentSessionId, 'parent session id');
|
|
22
|
+
const timestamp = input.timestamp === undefined ? new Date() : new Date(input.timestamp);
|
|
23
|
+
if (Number.isNaN(timestamp.valueOf()))
|
|
24
|
+
throw new Error('journal timestamp is not a valid date-time');
|
|
25
|
+
let markdown;
|
|
26
|
+
try {
|
|
27
|
+
markdown = new TextDecoder('utf-8', { fatal: true }).decode(await readFile(input.markdownFile));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new Error(`note file is not valid UTF-8: ${input.markdownFile}`, { cause: error });
|
|
31
|
+
}
|
|
32
|
+
if (!markdown)
|
|
33
|
+
throw new Error(`note file is empty: ${input.markdownFile}`);
|
|
34
|
+
const connection = resolveBlackboardConnection(input.env);
|
|
35
|
+
const { Sessions, Entries } = await loadClient(input.dependencies?.loadClient);
|
|
36
|
+
await new Sessions(connection).ensure({
|
|
37
|
+
id: input.sessionId,
|
|
38
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
39
|
+
agent: input.agent,
|
|
40
|
+
version: input.version,
|
|
41
|
+
});
|
|
42
|
+
const entry = await new Entries(connection).append({
|
|
43
|
+
sessionId: input.sessionId,
|
|
44
|
+
data: { type: 'journal', markdown, timestamp: timestamp.toISOString() },
|
|
45
|
+
});
|
|
46
|
+
return `Journaled to agent-blackboard session ${input.sessionId} (entry created at ${entry.createdAt}).`;
|
|
47
|
+
}
|
|
48
|
+
export async function readJournal(sessionId, env, dependencies) {
|
|
49
|
+
assertSessionId(sessionId);
|
|
50
|
+
const { Entries } = await loadClient(dependencies?.loadClient);
|
|
51
|
+
const entries = [];
|
|
52
|
+
for await (const entry of new Entries(resolveBlackboardConnection(env)).get({
|
|
53
|
+
sessionId,
|
|
54
|
+
format: 'json',
|
|
55
|
+
}))
|
|
56
|
+
entries.push(entry);
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
export function formatJournalEntries(sessionId, entries) {
|
|
60
|
+
const journals = entries.flatMap((entry) => {
|
|
61
|
+
if (typeof entry !== 'object' ||
|
|
62
|
+
entry === null ||
|
|
63
|
+
!('createdAt' in entry) ||
|
|
64
|
+
!('data' in entry) ||
|
|
65
|
+
typeof entry.createdAt !== 'string' ||
|
|
66
|
+
typeof entry.data !== 'object' ||
|
|
67
|
+
entry.data === null ||
|
|
68
|
+
!('type' in entry.data) ||
|
|
69
|
+
!('markdown' in entry.data) ||
|
|
70
|
+
entry.data.type !== 'journal' ||
|
|
71
|
+
typeof entry.data.markdown !== 'string')
|
|
72
|
+
return [];
|
|
73
|
+
return [{ createdAt: entry.createdAt, markdown: entry.data.markdown }];
|
|
74
|
+
});
|
|
75
|
+
if (!journals.length)
|
|
76
|
+
return `No journal entries found for session ${sessionId}.`;
|
|
77
|
+
return journals
|
|
78
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
79
|
+
.map(({ createdAt, markdown }) => `## ${createdAt}\n\n${markdown}`)
|
|
80
|
+
.join('\n\n');
|
|
81
|
+
}
|
|
82
|
+
async function loadClient(loader = defaultClientLoader) {
|
|
83
|
+
try {
|
|
84
|
+
return await loader();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND')
|
|
88
|
+
throw new Error('agent-blackboard is not installed; install the optional agent-blackboard peer dependency', { cause: error });
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function defaultClientLoader() {
|
|
93
|
+
return (await import('agent-blackboard'));
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function assertSessionId(sessionId: string, label?: string): void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { lstat, readFile, readdir, rm, rmdir } from 'node:fs/promises';
|
|
2
|
+
import type { SnapshotCleanupReceipt } from './snapshot-types.mts';
|
|
3
|
+
type Filesystem = {
|
|
4
|
+
lstat: typeof lstat;
|
|
5
|
+
readFile: typeof readFile;
|
|
6
|
+
readdir: typeof readdir;
|
|
7
|
+
rm: typeof rm;
|
|
8
|
+
rmdir: typeof rmdir;
|
|
9
|
+
};
|
|
10
|
+
export declare function removePartitionDirectory(filesystem: Filesystem, path: string, originalPath: string, receipt: SnapshotCleanupReceipt, directory: Awaited<ReturnType<typeof lstat>>, resume: boolean, startDeleting: () => void): Promise<void>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { RECEIPT_NAME, sameCleanupReceipt } from './snapshot-cleanup-receipt.mjs';
|
|
3
|
+
import { removeResumeReceipt, writeResumeReceipt } from './snapshot-cleanup-resume.mjs';
|
|
4
|
+
import { assertPartitionFile, validatePartition } from './snapshot-partition-validate.mjs';
|
|
5
|
+
const PARTITION = /^partition-([1-9][0-9]*)\.jsonl$/;
|
|
6
|
+
export async function removePartitionDirectory(filesystem, path, originalPath, receipt, directory, resume, startDeleting) {
|
|
7
|
+
const names = await filesystem.readdir(path);
|
|
8
|
+
const ordered = names
|
|
9
|
+
.filter((name) => PARTITION.test(name))
|
|
10
|
+
.map((name) => ({ name, number: Number(PARTITION.exec(name)?.[1]) }))
|
|
11
|
+
.sort((left, right) => left.number - right.number);
|
|
12
|
+
const expected = receipt.partitions.map(({ name }) => name);
|
|
13
|
+
const remaining = expected.slice(expected.length - ordered.length);
|
|
14
|
+
const hasReceipt = names.includes(RECEIPT_NAME);
|
|
15
|
+
if ((!hasReceipt && !resume) ||
|
|
16
|
+
names.length !== ordered.length + Number(hasReceipt) ||
|
|
17
|
+
ordered.some(({ name }, index) => name !== remaining[index]) ||
|
|
18
|
+
(!resume && ordered.length !== expected.length))
|
|
19
|
+
throw new Error('partition directory contains unexpected content');
|
|
20
|
+
const partitionNames = ordered.map(({ name }) => name);
|
|
21
|
+
if (hasReceipt)
|
|
22
|
+
await validateReceipt(filesystem, originalPath, path, receipt, directory, partitionNames);
|
|
23
|
+
else
|
|
24
|
+
validateReceiptOutput(originalPath, receipt, directory, partitionNames);
|
|
25
|
+
for (const { name } of ordered) {
|
|
26
|
+
const file = join(path, name);
|
|
27
|
+
const info = await filesystem.lstat(file);
|
|
28
|
+
assertPartitionFile(info);
|
|
29
|
+
await validatePartition(file, info, receipt.partitions.find((partition) => partition.name === name)?.checksum);
|
|
30
|
+
}
|
|
31
|
+
if (!resume)
|
|
32
|
+
await writeResumeReceipt(receipt);
|
|
33
|
+
startDeleting();
|
|
34
|
+
for (const { name } of ordered)
|
|
35
|
+
await filesystem.rm(join(path, name), { force: false });
|
|
36
|
+
if (hasReceipt)
|
|
37
|
+
await filesystem.rm(join(path, RECEIPT_NAME), { force: false });
|
|
38
|
+
await filesystem.rmdir(path);
|
|
39
|
+
await removeResumeReceipt(receipt);
|
|
40
|
+
}
|
|
41
|
+
async function validateReceipt(filesystem, originalPath, path, receipt, directory, names) {
|
|
42
|
+
const marker = join(path, RECEIPT_NAME);
|
|
43
|
+
const info = await filesystem.lstat(marker);
|
|
44
|
+
assertPartitionFile(info);
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(await filesystem.readFile(marker, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error('partition directory cleanup receipt is invalid');
|
|
51
|
+
}
|
|
52
|
+
if (!sameCleanupReceipt(parsed, receipt))
|
|
53
|
+
throw new Error('partition directory cleanup receipt does not match generated output');
|
|
54
|
+
validateReceiptOutput(originalPath, receipt, directory, names);
|
|
55
|
+
}
|
|
56
|
+
function validateReceiptOutput(originalPath, receipt, directory, names) {
|
|
57
|
+
if (receipt.schemaVersion !== 1 ||
|
|
58
|
+
receipt.directory !== originalPath ||
|
|
59
|
+
receipt.directoryDev !== directory.dev ||
|
|
60
|
+
receipt.directoryIno !== directory.ino ||
|
|
61
|
+
receipt.partitions.filter(({ name }) => names.includes(name)).length !== names.length ||
|
|
62
|
+
receipt.partitions
|
|
63
|
+
.filter(({ name }) => names.includes(name))
|
|
64
|
+
.some(({ name, checksum }, index) => name !== names[index] || checksum.algorithm !== 'sha256' || !checksum.value))
|
|
65
|
+
throw new Error('partition directory cleanup receipt does not match generated output');
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
declare const defaults: {
|
|
4
|
+
lstat: typeof lstat;
|
|
5
|
+
mkdir: typeof mkdir;
|
|
6
|
+
open: typeof open;
|
|
7
|
+
readdir: typeof readdir;
|
|
8
|
+
link: typeof link;
|
|
9
|
+
unlink: typeof unlink;
|
|
10
|
+
};
|
|
11
|
+
export declare function setCleanupKeyTempDirectoryForTest(directory?: typeof tmpdir): void;
|
|
12
|
+
export declare function setCleanupKeyFilesystemForTest(overrides?: Partial<typeof defaults>): void;
|
|
13
|
+
export declare function loadCleanupSigningKey(): Promise<Buffer>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
const KEY_NAME = 'receipt-hmac-sha256.key';
|
|
7
|
+
const KEY_BYTES = 32;
|
|
8
|
+
const defaults = { lstat, mkdir, open, readdir, link, unlink };
|
|
9
|
+
let filesystem = defaults;
|
|
10
|
+
let temporaryDirectory = tmpdir;
|
|
11
|
+
export function setCleanupKeyTempDirectoryForTest(directory = tmpdir) {
|
|
12
|
+
temporaryDirectory = directory;
|
|
13
|
+
}
|
|
14
|
+
export function setCleanupKeyFilesystemForTest(overrides) {
|
|
15
|
+
filesystem = { ...defaults, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
function euid() {
|
|
18
|
+
if (typeof process.geteuid !== 'function')
|
|
19
|
+
throw new Error('snapshot cleanup receipts require a POSIX effective user ID');
|
|
20
|
+
return process.geteuid();
|
|
21
|
+
}
|
|
22
|
+
function assertDirectory(info, uid) {
|
|
23
|
+
if (info.isSymbolicLink() ||
|
|
24
|
+
!info.isDirectory() ||
|
|
25
|
+
info.uid !== uid ||
|
|
26
|
+
(Number(info.mode) & 0o777) !== 0o700)
|
|
27
|
+
throw new Error('snapshot cleanup key directory is not owner-only');
|
|
28
|
+
}
|
|
29
|
+
function assertKey(info, uid, links = 1) {
|
|
30
|
+
if (info.isSymbolicLink() ||
|
|
31
|
+
!info.isFile() ||
|
|
32
|
+
info.nlink !== links ||
|
|
33
|
+
info.uid !== uid ||
|
|
34
|
+
(Number(info.mode) & 0o777) !== 0o600)
|
|
35
|
+
throw new Error('snapshot cleanup key is not an owner-only regular file');
|
|
36
|
+
}
|
|
37
|
+
async function keyPath() {
|
|
38
|
+
const uid = euid();
|
|
39
|
+
const directory = join(temporaryDirectory(), `agent-blackboard-cleanup-${uid}`);
|
|
40
|
+
try {
|
|
41
|
+
await filesystem.mkdir(directory, { mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'EEXIST')
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
assertDirectory(await filesystem.lstat(directory), uid);
|
|
48
|
+
return { path: join(directory, KEY_NAME), uid };
|
|
49
|
+
}
|
|
50
|
+
async function readKey(path, uid) {
|
|
51
|
+
try {
|
|
52
|
+
return await readPresentKey(path, uid);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT')
|
|
56
|
+
throw error;
|
|
57
|
+
return readPresentKey(path, uid);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function readPresentKey(path, uid) {
|
|
61
|
+
let before = await filesystem.lstat(path);
|
|
62
|
+
if (before.nlink === 2) {
|
|
63
|
+
assertKey(before, uid, 2);
|
|
64
|
+
const candidates = (await filesystem.readdir(dirname(path))).filter((name) => new RegExp(`^\\.${KEY_NAME.replaceAll('.', '\\.')}\\.[0-9a-f-]{36}$`).test(name));
|
|
65
|
+
const linked = await Promise.all(candidates.map(async (name) => ({
|
|
66
|
+
name,
|
|
67
|
+
info: await filesystem.lstat(join(dirname(path), name)),
|
|
68
|
+
})));
|
|
69
|
+
const matches = linked.filter(({ info }) => info.dev === before.dev && info.ino === before.ino);
|
|
70
|
+
if (matches.length !== 1)
|
|
71
|
+
throw new Error('snapshot cleanup key has an unsafe temporary link');
|
|
72
|
+
const temporary = join(dirname(path), matches[0].name);
|
|
73
|
+
const staged = matches[0].info;
|
|
74
|
+
assertKey(staged, uid, 2);
|
|
75
|
+
await filesystem.unlink(temporary);
|
|
76
|
+
before = await filesystem.lstat(path);
|
|
77
|
+
}
|
|
78
|
+
assertKey(before, uid);
|
|
79
|
+
const file = await filesystem.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
80
|
+
try {
|
|
81
|
+
const after = await file.stat();
|
|
82
|
+
assertKey(after, uid);
|
|
83
|
+
if (after.dev !== before.dev || after.ino !== before.ino)
|
|
84
|
+
throw new Error('snapshot cleanup key changed while it was opened');
|
|
85
|
+
const key = await file.readFile();
|
|
86
|
+
if (key.byteLength !== KEY_BYTES)
|
|
87
|
+
throw new Error('snapshot cleanup key has an invalid length');
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await file.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function publishKey(path, uid) {
|
|
95
|
+
const temporary = join(dirname(path), `.${KEY_NAME}.${randomUUID()}`);
|
|
96
|
+
const file = await filesystem.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
97
|
+
try {
|
|
98
|
+
await file.writeFile(randomBytes(KEY_BYTES));
|
|
99
|
+
await file.sync();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await file.close();
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await filesystem.link(temporary, path);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await filesystem.unlink(temporary).catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
await readKey(path, uid);
|
|
111
|
+
}
|
|
112
|
+
export async function loadCleanupSigningKey() {
|
|
113
|
+
const { path, uid } = await keyPath();
|
|
114
|
+
try {
|
|
115
|
+
return await readKey(path, uid);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code !== 'ENOENT')
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await publishKey(path, uid);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code !== 'EEXIST')
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
return readKey(path, uid);
|
|
129
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SnapshotCleanupReceipt, SnapshotPartition } from './snapshot-types.mts';
|
|
2
|
+
export declare const RECEIPT_NAME = ".agent-blackboard-cleanup-receipt.json";
|
|
3
|
+
export declare function serializeCleanupReceipt(receipt: SnapshotCleanupReceipt): string;
|
|
4
|
+
export declare function sameCleanupReceipt(left: unknown, right: SnapshotCleanupReceipt): boolean;
|
|
5
|
+
export declare function writeCleanupReceipt(directory: string, partitions: Array<Pick<SnapshotPartition, 'path' | 'checksum'>>): Promise<SnapshotCleanupReceipt>;
|
|
6
|
+
export declare function verifyCleanupReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { loadCleanupSigningKey } from './snapshot-cleanup-key.mjs';
|
|
5
|
+
export const RECEIPT_NAME = '.agent-blackboard-cleanup-receipt.json';
|
|
6
|
+
function record(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function exact(value, keys) {
|
|
10
|
+
return Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
|
11
|
+
}
|
|
12
|
+
function canonical(receipt, signature) {
|
|
13
|
+
if (!record(receipt) ||
|
|
14
|
+
!exact(receipt, signature
|
|
15
|
+
? [
|
|
16
|
+
'schemaVersion',
|
|
17
|
+
'directory',
|
|
18
|
+
'directoryDev',
|
|
19
|
+
'directoryIno',
|
|
20
|
+
'token',
|
|
21
|
+
'partitions',
|
|
22
|
+
'signature',
|
|
23
|
+
]
|
|
24
|
+
: ['schemaVersion', 'directory', 'directoryDev', 'directoryIno', 'token', 'partitions']))
|
|
25
|
+
throw new Error('partition directory cleanup receipt is invalid');
|
|
26
|
+
const { schemaVersion, directory, directoryDev, directoryIno, token, partitions } = receipt;
|
|
27
|
+
if (schemaVersion !== 1 ||
|
|
28
|
+
typeof directory !== 'string' ||
|
|
29
|
+
!directory ||
|
|
30
|
+
!Number.isSafeInteger(directoryDev) ||
|
|
31
|
+
directoryDev < 0 ||
|
|
32
|
+
!Number.isSafeInteger(directoryIno) ||
|
|
33
|
+
directoryIno < 0 ||
|
|
34
|
+
typeof token !== 'string' ||
|
|
35
|
+
!/^[0-9a-f-]{36}$/.test(token) ||
|
|
36
|
+
!Array.isArray(partitions))
|
|
37
|
+
throw new Error('partition directory cleanup receipt is invalid');
|
|
38
|
+
const normalizedDirectoryDev = directoryDev;
|
|
39
|
+
const normalizedDirectoryIno = directoryIno;
|
|
40
|
+
const normalized = partitions.map((partition) => {
|
|
41
|
+
if (!record(partition) ||
|
|
42
|
+
!exact(partition, ['name', 'checksum']) ||
|
|
43
|
+
typeof partition.name !== 'string' ||
|
|
44
|
+
!/^partition-[1-9][0-9]*\.jsonl$/.test(partition.name) ||
|
|
45
|
+
!record(partition.checksum) ||
|
|
46
|
+
!exact(partition.checksum, ['algorithm', 'value']) ||
|
|
47
|
+
partition.checksum.algorithm !== 'sha256' ||
|
|
48
|
+
typeof partition.checksum.value !== 'string' ||
|
|
49
|
+
!/^[0-9a-f]{64}$/.test(partition.checksum.value))
|
|
50
|
+
throw new Error('partition directory cleanup receipt is invalid');
|
|
51
|
+
return {
|
|
52
|
+
name: partition.name,
|
|
53
|
+
checksum: { algorithm: 'sha256', value: partition.checksum.value },
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
if (new Set(normalized.map(({ name }) => name)).size !== normalized.length)
|
|
57
|
+
throw new Error('partition directory cleanup receipt is invalid');
|
|
58
|
+
const unsigned = {
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
directory,
|
|
61
|
+
directoryDev: normalizedDirectoryDev,
|
|
62
|
+
directoryIno: normalizedDirectoryIno,
|
|
63
|
+
token,
|
|
64
|
+
partitions: normalized,
|
|
65
|
+
};
|
|
66
|
+
if (!signature)
|
|
67
|
+
return JSON.stringify(unsigned);
|
|
68
|
+
if (typeof receipt.signature !== 'string' || !/^[0-9a-f]{64}$/.test(receipt.signature))
|
|
69
|
+
throw new Error('partition directory cleanup receipt signature is invalid');
|
|
70
|
+
return JSON.stringify({ ...unsigned, signature: receipt.signature });
|
|
71
|
+
}
|
|
72
|
+
function payload(receipt) {
|
|
73
|
+
return canonical(receipt, false);
|
|
74
|
+
}
|
|
75
|
+
export function serializeCleanupReceipt(receipt) {
|
|
76
|
+
return canonical(receipt, true);
|
|
77
|
+
}
|
|
78
|
+
export function sameCleanupReceipt(left, right) {
|
|
79
|
+
try {
|
|
80
|
+
return (serializeCleanupReceipt(left) === serializeCleanupReceipt(right));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function writeCleanupReceipt(directory, partitions) {
|
|
87
|
+
const info = await lstat(directory);
|
|
88
|
+
const unsigned = {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
directory,
|
|
91
|
+
directoryDev: info.dev,
|
|
92
|
+
directoryIno: info.ino,
|
|
93
|
+
token: randomUUID(),
|
|
94
|
+
partitions: partitions.map(({ path, checksum }) => ({ name: basename(path), checksum })),
|
|
95
|
+
};
|
|
96
|
+
const signature = createHmac('sha256', await loadCleanupSigningKey())
|
|
97
|
+
.update(payload(unsigned))
|
|
98
|
+
.digest('hex');
|
|
99
|
+
const receipt = { ...unsigned, signature };
|
|
100
|
+
const marker = join(directory, RECEIPT_NAME);
|
|
101
|
+
await writeFile(marker, serializeCleanupReceipt(receipt), { flag: 'wx', mode: 0o400 });
|
|
102
|
+
await chmod(marker, 0o400);
|
|
103
|
+
return receipt;
|
|
104
|
+
}
|
|
105
|
+
export async function verifyCleanupReceipt(receipt) {
|
|
106
|
+
if (!record(receipt) ||
|
|
107
|
+
typeof receipt.signature !== 'string' ||
|
|
108
|
+
!/^[0-9a-f]{64}$/.test(receipt.signature))
|
|
109
|
+
throw new Error('partition directory cleanup receipt signature is invalid');
|
|
110
|
+
const serialized = serializeCleanupReceipt(receipt);
|
|
111
|
+
const { signature, ...unsigned } = JSON.parse(serialized);
|
|
112
|
+
const expected = createHmac('sha256', await loadCleanupSigningKey())
|
|
113
|
+
.update(payload(unsigned))
|
|
114
|
+
.digest('hex');
|
|
115
|
+
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')))
|
|
116
|
+
throw new Error('partition directory cleanup receipt signature is invalid');
|
|
117
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { link, lstat, open, readFile, readdir, rm, unlink } from 'node:fs/promises';
|
|
2
|
+
import type { SnapshotCleanupReceipt } from './snapshot-types.mts';
|
|
3
|
+
declare const defaults: {
|
|
4
|
+
link: typeof link;
|
|
5
|
+
lstat: typeof lstat;
|
|
6
|
+
open: typeof open;
|
|
7
|
+
readFile: typeof readFile;
|
|
8
|
+
readdir: typeof readdir;
|
|
9
|
+
rm: typeof rm;
|
|
10
|
+
unlink: typeof unlink;
|
|
11
|
+
};
|
|
12
|
+
export declare function setResumeFilesystemForTest(overrides?: Partial<typeof defaults>): void;
|
|
13
|
+
export declare function writeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
14
|
+
export declare function removeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
15
|
+
export declare function requireResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, open, readFile, readdir, rm, unlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { sameCleanupReceipt, verifyCleanupReceipt } from './snapshot-cleanup-receipt.mjs';
|
|
7
|
+
const defaults = { link, lstat, open, readFile, readdir, rm, unlink };
|
|
8
|
+
let filesystem = defaults;
|
|
9
|
+
export function setResumeFilesystemForTest(overrides) {
|
|
10
|
+
filesystem = { ...defaults, ...overrides };
|
|
11
|
+
}
|
|
12
|
+
function pathFor(receipt) {
|
|
13
|
+
if (!/^[0-9a-f-]{36}$/.test(receipt.token))
|
|
14
|
+
throw new Error('partition directory cleanup receipt has an invalid token');
|
|
15
|
+
return join(tmpdir(), `.agent-blackboard-cleanup-${receipt.token}.resume.json`);
|
|
16
|
+
}
|
|
17
|
+
function temporaryPath(receipt) {
|
|
18
|
+
return `${pathFor(receipt)}.${randomUUID()}.tmp`;
|
|
19
|
+
}
|
|
20
|
+
function assertResumeFile(info, links = 1) {
|
|
21
|
+
if (info.isSymbolicLink() ||
|
|
22
|
+
!info.isFile() ||
|
|
23
|
+
info.nlink !== links ||
|
|
24
|
+
(Number(info.mode) & 0o777) !== 0o400 ||
|
|
25
|
+
(typeof process.geteuid === 'function' && info.uid !== process.geteuid()))
|
|
26
|
+
throw new Error('partition directory cleanup resume metadata is unsafe');
|
|
27
|
+
}
|
|
28
|
+
async function read(receipt) {
|
|
29
|
+
const path = pathFor(receipt);
|
|
30
|
+
let before = await filesystem.lstat(path);
|
|
31
|
+
if (before.nlink === 2) {
|
|
32
|
+
assertResumeFile(before, 2);
|
|
33
|
+
const candidates = (await filesystem.readdir(tmpdir())).filter((name) => new RegExp(`^${path.split('/').at(-1).replaceAll('.', '\\.')}(?:\\.[0-9a-f-]{36}\\.tmp)$`).test(name));
|
|
34
|
+
const linked = await Promise.all(candidates.map(async (name) => ({
|
|
35
|
+
name,
|
|
36
|
+
info: await filesystem.lstat(join(tmpdir(), name)),
|
|
37
|
+
})));
|
|
38
|
+
const matches = linked.filter(({ info }) => info.dev === before.dev && info.ino === before.ino);
|
|
39
|
+
if (matches.length !== 1)
|
|
40
|
+
throw new Error('partition directory cleanup resume metadata is unsafe');
|
|
41
|
+
const temporary = join(tmpdir(), matches[0].name);
|
|
42
|
+
const staged = matches[0].info;
|
|
43
|
+
assertResumeFile(staged, 2);
|
|
44
|
+
await filesystem.unlink(temporary);
|
|
45
|
+
before = await filesystem.lstat(path);
|
|
46
|
+
}
|
|
47
|
+
assertResumeFile(before);
|
|
48
|
+
const contents = await filesystem.readFile(path, 'utf8');
|
|
49
|
+
const after = await filesystem.lstat(path);
|
|
50
|
+
assertResumeFile(after);
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(contents);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error('partition directory cleanup resume metadata does not match receipt');
|
|
57
|
+
}
|
|
58
|
+
if (before.dev !== after.dev || before.ino !== after.ino || !sameCleanupReceipt(parsed, receipt))
|
|
59
|
+
throw new Error('partition directory cleanup resume metadata does not match receipt');
|
|
60
|
+
await verifyCleanupReceipt(receipt);
|
|
61
|
+
}
|
|
62
|
+
export async function writeResumeReceipt(receipt) {
|
|
63
|
+
const path = pathFor(receipt);
|
|
64
|
+
const temporary = temporaryPath(receipt);
|
|
65
|
+
let file;
|
|
66
|
+
try {
|
|
67
|
+
file = await filesystem.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o400);
|
|
68
|
+
const { serializeCleanupReceipt } = await import('./snapshot-cleanup-receipt.mjs');
|
|
69
|
+
await file.writeFile(serializeCleanupReceipt(receipt));
|
|
70
|
+
await file.sync();
|
|
71
|
+
await file.close();
|
|
72
|
+
file = undefined;
|
|
73
|
+
await filesystem.link(temporary, path);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== 'EEXIST')
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await file?.close();
|
|
81
|
+
await filesystem.unlink(temporary).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
await read(receipt);
|
|
84
|
+
}
|
|
85
|
+
export async function removeResumeReceipt(receipt) {
|
|
86
|
+
try {
|
|
87
|
+
await read(receipt);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code === 'ENOENT')
|
|
91
|
+
return;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
await filesystem.rm(pathFor(receipt), { force: false });
|
|
95
|
+
}
|
|
96
|
+
export async function requireResumeReceipt(receipt) {
|
|
97
|
+
await read(receipt);
|
|
98
|
+
}
|