@tryaura/aura-testkit 0.1.1 → 0.2.1
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 +30 -3
- package/dist/index.d.ts +14 -7
- package/dist/index.js +180 -34
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -13,14 +13,20 @@ run spawns.
|
|
|
13
13
|
```ts
|
|
14
14
|
import { createSeedBuilder, runCheck } from "@tryaura/aura-testkit";
|
|
15
15
|
|
|
16
|
-
it("
|
|
16
|
+
it("runs a distribution against a fake machine", async () => {
|
|
17
17
|
await using seed = await createSeedBuilder()
|
|
18
18
|
.homeFile(".fixture/config.txt", "legacy=true\n")
|
|
19
19
|
.workspaceFile("AGENTS.md", "workspace instructions\n")
|
|
20
20
|
.shim("fixture-agent", [{ args: ["--version"], stdout: "fixture-agent 1.2.3\n" }])
|
|
21
21
|
.build();
|
|
22
22
|
|
|
23
|
-
const result = await runCheck({
|
|
23
|
+
const result = await runCheck({
|
|
24
|
+
distro: {
|
|
25
|
+
branding: { command: "fixture", displayName: "Fixture Doctor" },
|
|
26
|
+
plugins: [],
|
|
27
|
+
},
|
|
28
|
+
seed,
|
|
29
|
+
});
|
|
24
30
|
|
|
25
31
|
expect(result.findings).toMatchInlineSnapshot();
|
|
26
32
|
});
|
|
@@ -59,6 +65,26 @@ Every invocation is recorded, matched or not:
|
|
|
59
65
|
await expect(seed.invocations("fixture-agent")).resolves.toEqual([["--version"]]);
|
|
60
66
|
```
|
|
61
67
|
|
|
68
|
+
Asking for a command that was not seeded rejects and lists the known shims. A seeded command that
|
|
69
|
+
has not run resolves to an empty list.
|
|
70
|
+
|
|
71
|
+
### Shim record contract
|
|
72
|
+
|
|
73
|
+
The invocation log is a public, versioned format. Each invocation is one ASCII line:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
aura-testkit-v1<TAB><argument-count><TAB><base64-UTF-8-argument>...<LF>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Empty and multiline arguments round-trip because every argument is encoded separately. A shim
|
|
80
|
+
builds the complete record in memory and appends it with one `printf`, so parallel invocations do
|
|
81
|
+
not interleave fields. Records are capped at 2,048 bytes; an invocation over that limit writes a
|
|
82
|
+
versioned truncation marker, and `seed.invocations(command)` rejects with the record size and limit
|
|
83
|
+
instead of returning incomplete arguments.
|
|
84
|
+
|
|
85
|
+
Generated shims require a POSIX shell and the standard `base64` and `tr` utilities available at
|
|
86
|
+
`/usr/bin`. Seed building fails immediately on Windows with an explicit platform error.
|
|
87
|
+
|
|
62
88
|
Versioned official-app fixtures are also exported for adapter and binary integration tests:
|
|
63
89
|
|
|
64
90
|
```ts
|
|
@@ -88,7 +114,8 @@ await using cursorSeed = await createCursorSeed({
|
|
|
88
114
|
needs to prove a check ran: a check that threw reports no findings and is explained only by
|
|
89
115
|
`report.diagnostics`.
|
|
90
116
|
- `findings` — shorthand for `report.findings`.
|
|
91
|
-
- `exitCode` — `0
|
|
117
|
+
- `exitCode` — `0` for a completed check, `2` for usage/state conflicts, or `3` for operational
|
|
118
|
+
failures. Inspect report status or severity counts to assert finding health.
|
|
92
119
|
- `diffs` — every change under the fake HOME and workspace, as unified patches whose first line is
|
|
93
120
|
the entry's permission bits. Permission-only changes, empty directories, and binary files are all
|
|
94
121
|
visible. The seeded `PATH` directory is not diffed; use `seed.invocations` for what a shim did.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CheckReport, CheckReport as CheckReport$1, CliDistro, CliExitCode, ReportFinding } from "@tryaura/aura-cli";
|
|
1
|
+
import { CheckReport, CheckReport as CheckReport$1, CliDistro, CliDistro as CliDistro$1, CliExitCode, ReportFinding } from "@tryaura/aura-cli";
|
|
2
2
|
import { Environment } from "@tryaura/aura-sdk";
|
|
3
3
|
//#region src/types.d.ts
|
|
4
4
|
/**
|
|
@@ -36,8 +36,8 @@ interface TestSeed {
|
|
|
36
36
|
* Every invocation of one seeded shim, in the order it happened.
|
|
37
37
|
*
|
|
38
38
|
* Records invocations no response matched too, which is what turns "the shim answered `exit 2`"
|
|
39
|
-
* into "the adapter asked for arguments no response declares". Empty
|
|
40
|
-
* run
|
|
39
|
+
* into "the adapter asked for arguments no response declares". Empty when the seeded command was
|
|
40
|
+
* never run. Asking for a command that was not seeded throws and names the known shims.
|
|
41
41
|
*/
|
|
42
42
|
readonly invocations: (command: string) => Promise<readonly (readonly string[])[]>;
|
|
43
43
|
}
|
|
@@ -59,13 +59,20 @@ type SeedContent = string | ((roots: SeedRoots) => string);
|
|
|
59
59
|
interface TestSeedBuilder {
|
|
60
60
|
homeFile(path: string, content: SeedContent): TestSeedBuilder;
|
|
61
61
|
shim(command: string, responses: readonly ShimResponse[]): TestSeedBuilder;
|
|
62
|
+
/**
|
|
63
|
+
* Records the seeded workspace preset as trusted in the seed's `agents/aura.json`.
|
|
64
|
+
*
|
|
65
|
+
* A repository preset applies only after a person accepted it, and `--yes` runs never accept
|
|
66
|
+
* one; a seed that wants the preset in effect declares the acceptance that already happened.
|
|
67
|
+
*/
|
|
68
|
+
trustWorkspacePreset(path?: string): TestSeedBuilder;
|
|
62
69
|
workspaceFile(path: string, content: SeedContent): TestSeedBuilder;
|
|
63
70
|
build(): Promise<TestSeed>;
|
|
64
71
|
}
|
|
65
72
|
interface RunCheckOptions {
|
|
66
73
|
/** Check-command flags other than `--json`, which the runner always supplies. */
|
|
67
74
|
readonly args?: readonly string[] | undefined;
|
|
68
|
-
readonly distro: CliDistro;
|
|
75
|
+
readonly distro: CliDistro$1;
|
|
69
76
|
readonly seed: TestSeed;
|
|
70
77
|
}
|
|
71
78
|
/** Options for running a compiled Aura distribution against a deterministic seed. */
|
|
@@ -131,7 +138,7 @@ declare function runCheck(options: RunCheckOptions): Promise<TestRunResult>;
|
|
|
131
138
|
interface RunSetupOptions {
|
|
132
139
|
/** Setup-command flags other than `--yes`, which the runner always supplies. */
|
|
133
140
|
readonly args?: readonly string[] | undefined;
|
|
134
|
-
readonly distro: CliDistro;
|
|
141
|
+
readonly distro: CliDistro$1;
|
|
135
142
|
/** Extra variables visible to the run, such as a skill-directory token. `PATH` stays seeded. */
|
|
136
143
|
readonly environmentVariables?: Readonly<Record<string, string>> | undefined;
|
|
137
144
|
readonly seed: TestSeed;
|
|
@@ -196,7 +203,7 @@ type TreeEntry = BinaryEntry | DirectoryEntry | FileEntry | SymlinkEntry;
|
|
|
196
203
|
type FilesystemSnapshot = ReadonlyMap<string, TreeEntry>;
|
|
197
204
|
declare function captureFilesystem(seed: TestSeed): Promise<FilesystemSnapshot>;
|
|
198
205
|
//#endregion
|
|
199
|
-
//#region src/seed.d.ts
|
|
206
|
+
//#region src/seed.boundary.d.ts
|
|
200
207
|
/** Starts a fluent description of one isolated fake machine. */
|
|
201
208
|
declare function createSeedBuilder(): TestSeedBuilder;
|
|
202
209
|
//#endregion
|
|
@@ -303,4 +310,4 @@ declare function cursorShimResponses(options: Pick<CursorSeedOptions, "version">
|
|
|
303
310
|
/** Builds documented Cursor rule and MCP configuration against an exact editor version. */
|
|
304
311
|
declare function createCursorSeed(options: CursorSeedOptions): Promise<TestSeed>;
|
|
305
312
|
//#endregion
|
|
306
|
-
export { ANY_ARGUMENT, CODEX_NESTED_PACKAGE, type CheckReport, type ClaudeCodeFixtureVersion, type ClaudeCodeSeedOptions, type CodexFixtureVersion, type CodexProjectInstructions, type CodexSeedOptions, type ConvergedTwiceResult, type ConvergenceRunResult, type CursorFixtureVersion, type CursorRulesFixture, type CursorSeedOptions, type FilesystemSnapshot, type MockDirectory, type MockDirectoryBuilder, type MockDirectoryFile, type MockDirectoryListing, type MockDirectoryRequest, type RunBinaryCheckOptions, type RunCheckOptions, type RunSetupOptions, type SetupRunResult, type ShimArgument, type ShimResponse, type TestFileDiff, type TestFileDiffStatus, type TestRunResult, type TestSeed, type TestSeedBuilder, captureFilesystem, claudeCodeShimResponses, codexShimResponses, createClaudeCodeSeed, createCodexSeed, createCursorSeed, createMockDirectoryBuilder, createSeedBuilder, cursorShimResponses, expectConvergedTwice, loopbackOnlyHttpGet, runBinaryCheck, runCheck, runSetup };
|
|
313
|
+
export { ANY_ARGUMENT, CODEX_NESTED_PACKAGE, type CheckReport, type ClaudeCodeFixtureVersion, type ClaudeCodeSeedOptions, type CliDistro, type CodexFixtureVersion, type CodexProjectInstructions, type CodexSeedOptions, type ConvergedTwiceResult, type ConvergenceRunResult, type CursorFixtureVersion, type CursorRulesFixture, type CursorSeedOptions, type FilesystemSnapshot, type MockDirectory, type MockDirectoryBuilder, type MockDirectoryFile, type MockDirectoryListing, type MockDirectoryRequest, type RunBinaryCheckOptions, type RunCheckOptions, type RunSetupOptions, type SetupRunResult, type ShimArgument, type ShimResponse, type TestFileDiff, type TestFileDiffStatus, type TestRunResult, type TestSeed, type TestSeedBuilder, captureFilesystem, claudeCodeShimResponses, codexShimResponses, createClaudeCodeSeed, createCodexSeed, createCursorSeed, createMockDirectoryBuilder, createSeedBuilder, cursorShimResponses, expectConvergedTwice, loopbackOnlyHttpGet, runBinaryCheck, runCheck, runSetup };
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { PassThrough, Readable } from "node:stream";
|
|
6
6
|
import { runCli } from "@tryaura/aura-cli";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
7
|
+
import { platform, tmpdir } from "node:os";
|
|
8
8
|
import { DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_RESPONSE_BYTES, MAX_HTTP_TIMEOUT_MS } from "@tryaura/aura-sdk";
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
10
|
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/base.js
|
|
@@ -850,12 +850,12 @@ function toPortablePath(path) {
|
|
|
850
850
|
//#endregion
|
|
851
851
|
//#region src/guards.ts
|
|
852
852
|
/** Narrows to a plain JSON-shaped object, excluding arrays and null. */
|
|
853
|
-
function isRecord(value) {
|
|
853
|
+
function isRecord$1(value) {
|
|
854
854
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
855
855
|
}
|
|
856
856
|
/** Freezes a JSON-shaped value all the way down. */
|
|
857
857
|
function deepFreeze(value) {
|
|
858
|
-
if (!isRecord(value) && !Array.isArray(value)) return value;
|
|
858
|
+
if (!isRecord$1(value) && !Array.isArray(value)) return value;
|
|
859
859
|
for (const property of Object.values(value)) deepFreeze(property);
|
|
860
860
|
return Object.freeze(value);
|
|
861
861
|
}
|
|
@@ -886,7 +886,7 @@ function reject(path, expectation, problems) {
|
|
|
886
886
|
/** Validates the declared keys and ignores every other one, which is what lets the report grow. */
|
|
887
887
|
function shape(fields) {
|
|
888
888
|
return (value, path, problems) => {
|
|
889
|
-
if (!isRecord(value)) return reject(path, "an object", problems);
|
|
889
|
+
if (!isRecord$1(value)) return reject(path, "an object", problems);
|
|
890
890
|
let valid = true;
|
|
891
891
|
for (const [key, check] of Object.entries(fields)) valid = check(value[key], `${path}.${key}`, problems) && valid;
|
|
892
892
|
return valid;
|
|
@@ -902,7 +902,7 @@ function arrayOf(check) {
|
|
|
902
902
|
}
|
|
903
903
|
function recordOf(check) {
|
|
904
904
|
return (value, path, problems) => {
|
|
905
|
-
if (!isRecord(value)) return reject(path, "an object", problems);
|
|
905
|
+
if (!isRecord$1(value)) return reject(path, "an object", problems);
|
|
906
906
|
let valid = true;
|
|
907
907
|
for (const [key, entry] of Object.entries(value)) valid = check(entry, `${path}.${key}`, problems) && valid;
|
|
908
908
|
return valid;
|
|
@@ -959,15 +959,15 @@ const findingFields = shape({
|
|
|
959
959
|
*/
|
|
960
960
|
const finding = (value, path, problems) => {
|
|
961
961
|
const valid = findingFields(value, path, problems);
|
|
962
|
-
return isRecord(value) ? tableRowsAreReadable(value, path, problems) && valid : valid;
|
|
962
|
+
return isRecord$1(value) ? tableRowsAreReadable(value, path, problems) && valid : valid;
|
|
963
963
|
};
|
|
964
964
|
function tableRowsAreReadable(value, path, problems) {
|
|
965
965
|
const presentation = value["presentation"];
|
|
966
|
-
if (!isRecord(presentation) || presentation["kind"] !== "metadata-table") return true;
|
|
966
|
+
if (!isRecord$1(presentation) || presentation["kind"] !== "metadata-table") return true;
|
|
967
967
|
const rowsKey = presentation["rowsKey"];
|
|
968
968
|
const metadata = value["metadata"];
|
|
969
|
-
const rows = typeof rowsKey === "string" && isRecord(metadata) ? metadata[rowsKey] : void 0;
|
|
970
|
-
if (!Array.isArray(rows) || !rows.every(isRecord)) return reject(`${path}.metadata.${typeof rowsKey === "string" ? rowsKey : "?"}`, "an array of objects, because presentation.rowsKey names it", problems);
|
|
969
|
+
const rows = typeof rowsKey === "string" && isRecord$1(metadata) ? metadata[rowsKey] : void 0;
|
|
970
|
+
if (!Array.isArray(rows) || !rows.every(isRecord$1)) return reject(`${path}.metadata.${typeof rowsKey === "string" ? rowsKey : "?"}`, "an array of objects, because presentation.rowsKey names it", problems);
|
|
971
971
|
const keys = columnKeys(presentation["columns"]);
|
|
972
972
|
if (keys.length === 0) return true;
|
|
973
973
|
return rows.every((row, index) => keys.some((key) => row[key] !== void 0) ? true : reject(`${path}.metadata.${String(rowsKey)}[${String(index)}]`, `a row carrying at least one column key (${keys.join(", ")})`, problems));
|
|
@@ -975,7 +975,7 @@ function tableRowsAreReadable(value, path, problems) {
|
|
|
975
975
|
function columnKeys(columns) {
|
|
976
976
|
if (!Array.isArray(columns)) return [];
|
|
977
977
|
return columns.flatMap((column) => {
|
|
978
|
-
const key = isRecord(column) ? column["key"] : void 0;
|
|
978
|
+
const key = isRecord$1(column) ? column["key"] : void 0;
|
|
979
979
|
return typeof key === "string" ? [key] : [];
|
|
980
980
|
});
|
|
981
981
|
}
|
|
@@ -1039,7 +1039,7 @@ const REPORT = {
|
|
|
1039
1039
|
})
|
|
1040
1040
|
};
|
|
1041
1041
|
function isJsonObject(value) {
|
|
1042
|
-
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
1042
|
+
return isRecord$1(value) && Object.values(value).every(isJsonValue);
|
|
1043
1043
|
}
|
|
1044
1044
|
function isJsonValue(value) {
|
|
1045
1045
|
return value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string" || Array.isArray(value) && value.every(isJsonValue) || isJsonObject(value);
|
|
@@ -1218,7 +1218,7 @@ function executeBinary(options) {
|
|
|
1218
1218
|
});
|
|
1219
1219
|
}
|
|
1220
1220
|
//#endregion
|
|
1221
|
-
//#region ../core/src/http.ts
|
|
1221
|
+
//#region ../core/src/http.boundary.ts
|
|
1222
1222
|
/**
|
|
1223
1223
|
* Hosts allowed to serve plain `http:`.
|
|
1224
1224
|
*
|
|
@@ -1345,6 +1345,39 @@ function failureReason(error) {
|
|
|
1345
1345
|
return name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
|
|
1346
1346
|
}
|
|
1347
1347
|
//#endregion
|
|
1348
|
+
//#region ../core/src/managed-block/protocol.ts
|
|
1349
|
+
/**
|
|
1350
|
+
* Normalizes snippet text to the bytes covered by the managed-block hash protocol.
|
|
1351
|
+
*
|
|
1352
|
+
* Trailing newlines are trimmed by scanning rather than with `/\n+$/`, whose backtracking is
|
|
1353
|
+
* quadratic when a long newline run does not reach the end of the string.
|
|
1354
|
+
*/
|
|
1355
|
+
function canonicalizeManagedSnippet(content) {
|
|
1356
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
1357
|
+
let end = normalized.length;
|
|
1358
|
+
while (end > 0 && normalized[end - 1] === "\n") end -= 1;
|
|
1359
|
+
return `${normalized.slice(0, end)}\n`;
|
|
1360
|
+
}
|
|
1361
|
+
/** Computes the protocol hash for text already in {@link canonicalizeManagedSnippet} form. */
|
|
1362
|
+
function hashCanonicalManagedSnippet(canonical) {
|
|
1363
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
1364
|
+
}
|
|
1365
|
+
/** Computes the protocol hash for a snippet's canonical UTF-8 contents. */
|
|
1366
|
+
function hashManagedSnippet(content) {
|
|
1367
|
+
return hashCanonicalManagedSnippet(canonicalizeManagedSnippet(content));
|
|
1368
|
+
}
|
|
1369
|
+
//#endregion
|
|
1370
|
+
//#region ../core/src/preset/repo-trust.ts
|
|
1371
|
+
/**
|
|
1372
|
+
* Hashes repository preset contents for the trust record.
|
|
1373
|
+
*
|
|
1374
|
+
* Uses the managed-snippet canonicalization so a checkout that rewrites line endings does not
|
|
1375
|
+
* invalidate a trust the user already granted to the same bytes-as-authored.
|
|
1376
|
+
*/
|
|
1377
|
+
function hashRepoPreset(content) {
|
|
1378
|
+
return hashManagedSnippet(content);
|
|
1379
|
+
}
|
|
1380
|
+
//#endregion
|
|
1348
1381
|
//#region src/http.ts
|
|
1349
1382
|
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "[::1]"]);
|
|
1350
1383
|
const realHttpGet = createHttpGet();
|
|
@@ -1540,6 +1573,8 @@ const ANY_ARGUMENT = Symbol("aura-testkit.anyArgument");
|
|
|
1540
1573
|
//#region src/shims.ts
|
|
1541
1574
|
const COMMAND_NAME = /^[A-Za-z0-9._+-]+$/u;
|
|
1542
1575
|
const RESERVED_NAMES = /* @__PURE__ */ new Set([".", ".."]);
|
|
1576
|
+
const RECORD_FORMAT = "aura-testkit-v1";
|
|
1577
|
+
const RECORD_LIMIT_BYTES = 2048;
|
|
1543
1578
|
async function writeShim(options) {
|
|
1544
1579
|
const { command, logDir, pathDir, responses } = options;
|
|
1545
1580
|
validateShim(command, responses);
|
|
@@ -1550,12 +1585,15 @@ async function writeShim(options) {
|
|
|
1550
1585
|
/**
|
|
1551
1586
|
* Reads back every invocation one shim recorded.
|
|
1552
1587
|
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1588
|
+
* Each invocation is one LF-terminated ASCII record containing the format version, argument count,
|
|
1589
|
+
* and one base64-encoded UTF-8 field per argument, separated by tabs. The shim appends the complete
|
|
1590
|
+
* record with one `printf`, so concurrent invocations cannot interleave individual fields.
|
|
1591
|
+
*
|
|
1592
|
+
* A missing log means the seeded shim has not run. Invalid command names and malformed or truncated
|
|
1593
|
+
* records throw instead of being conflated with that valid empty state.
|
|
1556
1594
|
*/
|
|
1557
1595
|
async function readInvocations(logDir, command) {
|
|
1558
|
-
if (!isPortableCommandName(command))
|
|
1596
|
+
if (!isPortableCommandName(command)) throw new Error(`Shim command must be a portable executable name. Received: ${command}`);
|
|
1559
1597
|
let log;
|
|
1560
1598
|
try {
|
|
1561
1599
|
log = await readFile(join(logDir, command), "utf8");
|
|
@@ -1563,16 +1601,10 @@ async function readInvocations(logDir, command) {
|
|
|
1563
1601
|
if (isNotFound(error)) return [];
|
|
1564
1602
|
throw error;
|
|
1565
1603
|
}
|
|
1566
|
-
const fields = log.split("\0");
|
|
1567
|
-
fields.pop();
|
|
1568
1604
|
const invocations = [];
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
if (!Number.isInteger(count) || count < 0 || cursor + 1 + count > fields.length) throw new Error(`Shim ${command} wrote an invocation log this runner cannot read.`);
|
|
1573
|
-
invocations.push(Object.freeze(fields.slice(cursor + 1, cursor + 1 + count)));
|
|
1574
|
-
cursor += 1 + count;
|
|
1575
|
-
}
|
|
1605
|
+
const records = log.split("\n");
|
|
1606
|
+
if (records.pop() !== "") throw unreadableInvocationLog(command);
|
|
1607
|
+
for (const record of records) invocations.push(parseInvocationRecord(command, record));
|
|
1576
1608
|
return Object.freeze(invocations);
|
|
1577
1609
|
}
|
|
1578
1610
|
function validateShim(command, responses) {
|
|
@@ -1610,12 +1642,17 @@ function validateResponse(command, response) {
|
|
|
1610
1642
|
function renderShim(command, logPath, responses) {
|
|
1611
1643
|
return [
|
|
1612
1644
|
"#!/bin/sh",
|
|
1613
|
-
"{"
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
"
|
|
1618
|
-
|
|
1645
|
+
`aura_record="${RECORD_FORMAT}\t$#"`,
|
|
1646
|
+
"for aura_recorded in \"$@\"; do",
|
|
1647
|
+
` aura_encoded=$(printf '%s' "$aura_recorded" | /usr/bin/base64 | /usr/bin/tr -d '\\r\\n')`,
|
|
1648
|
+
" aura_record=\"${aura_record} ${aura_encoded}\"",
|
|
1649
|
+
"done",
|
|
1650
|
+
"aura_record=\"${aura_record}\n\"",
|
|
1651
|
+
"aura_record_length=${#aura_record}",
|
|
1652
|
+
`if [ "$aura_record_length" -gt ${String(RECORD_LIMIT_BYTES)} ]; then`,
|
|
1653
|
+
` aura_record="${RECORD_FORMAT}\ttruncated\t\${aura_record_length}\n"`,
|
|
1654
|
+
"fi",
|
|
1655
|
+
`printf '%s' "$aura_record" >> ${shellQuote(logPath)}`,
|
|
1619
1656
|
...responses.map(renderResponse),
|
|
1620
1657
|
`printf '%s' ${shellQuote(`aura-testkit: unmatched invocation: ${command}`)} >&2`,
|
|
1621
1658
|
"for aura_reported in \"$@\"; do",
|
|
@@ -1639,13 +1676,47 @@ function shellQuote(value) {
|
|
|
1639
1676
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1640
1677
|
}
|
|
1641
1678
|
function isNotFound(error) {
|
|
1642
|
-
return isRecord(error) && error["code"] === "ENOENT";
|
|
1679
|
+
return isRecord$1(error) && error["code"] === "ENOENT";
|
|
1680
|
+
}
|
|
1681
|
+
function isUnsignedInteger(value) {
|
|
1682
|
+
return /^(?:0|[1-9][0-9]*)$/u.test(value);
|
|
1683
|
+
}
|
|
1684
|
+
function parseInvocationRecord(command, record) {
|
|
1685
|
+
const fields = record.split(" ");
|
|
1686
|
+
if (fields[0] !== RECORD_FORMAT) throw unreadableInvocationLog(command);
|
|
1687
|
+
if (fields[1] === "truncated") throwTruncatedInvocation(command, fields);
|
|
1688
|
+
return parseInvocationFields(command, fields);
|
|
1689
|
+
}
|
|
1690
|
+
function throwTruncatedInvocation(command, fields) {
|
|
1691
|
+
const recordLength = fields[2];
|
|
1692
|
+
if (fields.length !== 3 || recordLength === void 0 || !isUnsignedInteger(recordLength)) throw unreadableInvocationLog(command);
|
|
1693
|
+
throw new Error(`Shim ${command} invocation produced a ${recordLength}-byte log record, exceeding the ${String(RECORD_LIMIT_BYTES)}-byte atomic limit. Reduce the invocation's arguments.`);
|
|
1694
|
+
}
|
|
1695
|
+
function parseInvocationFields(command, fields) {
|
|
1696
|
+
const countField = fields[1];
|
|
1697
|
+
if (countField === void 0 || !isUnsignedInteger(countField)) throw unreadableInvocationLog(command);
|
|
1698
|
+
const count = Number(countField);
|
|
1699
|
+
if (!Number.isSafeInteger(count) || fields.length !== count + 2) throw unreadableInvocationLog(command);
|
|
1700
|
+
return Object.freeze(fields.slice(2).map((field) => decodeArgument(command, field)));
|
|
1701
|
+
}
|
|
1702
|
+
function decodeArgument(command, value) {
|
|
1703
|
+
const bytes = Buffer.from(value, "base64");
|
|
1704
|
+
if (bytes.toString("base64") !== value) throw unreadableInvocationLog(command);
|
|
1705
|
+
try {
|
|
1706
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1707
|
+
} catch {
|
|
1708
|
+
throw unreadableInvocationLog(command);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function unreadableInvocationLog(command) {
|
|
1712
|
+
return /* @__PURE__ */ new Error(`Shim ${command} wrote an invocation log this runner cannot read.`);
|
|
1643
1713
|
}
|
|
1644
1714
|
//#endregion
|
|
1645
|
-
//#region src/seed.ts
|
|
1715
|
+
//#region src/seed.boundary.ts
|
|
1646
1716
|
var SeedBuilder = class {
|
|
1647
1717
|
#homeFiles = /* @__PURE__ */ new Map();
|
|
1648
1718
|
#shims = /* @__PURE__ */ new Map();
|
|
1719
|
+
#trustedPresets = /* @__PURE__ */ new Set();
|
|
1649
1720
|
#workspaceFiles = /* @__PURE__ */ new Map();
|
|
1650
1721
|
homeFile(path, content) {
|
|
1651
1722
|
addFile(this.#homeFiles, "HOME", path, content);
|
|
@@ -1663,11 +1734,16 @@ var SeedBuilder = class {
|
|
|
1663
1734
|
});
|
|
1664
1735
|
return this;
|
|
1665
1736
|
}
|
|
1737
|
+
trustWorkspacePreset(path = ".aura/preset.json") {
|
|
1738
|
+
this.#trustedPresets.add(normalizeSeedPath(path));
|
|
1739
|
+
return this;
|
|
1740
|
+
}
|
|
1666
1741
|
workspaceFile(path, content) {
|
|
1667
1742
|
addFile(this.#workspaceFiles, "workspace", path, content);
|
|
1668
1743
|
return this;
|
|
1669
1744
|
}
|
|
1670
1745
|
async build() {
|
|
1746
|
+
assertSupportedPlatform(platform());
|
|
1671
1747
|
const root = await realpath(await mkdtemp(join(tmpdir(), "aura-testkit-")));
|
|
1672
1748
|
const homeDir = join(root, "home");
|
|
1673
1749
|
const logDir = join(root, "invocations");
|
|
@@ -1692,6 +1768,7 @@ var SeedBuilder = class {
|
|
|
1692
1768
|
pathDir,
|
|
1693
1769
|
responses: shim.responses
|
|
1694
1770
|
})));
|
|
1771
|
+
if (this.#trustedPresets.size > 0) await recordTrustedPresets(homeDir, workspaceDir, this.#trustedPresets);
|
|
1695
1772
|
} catch (error) {
|
|
1696
1773
|
await rm(root, {
|
|
1697
1774
|
force: true,
|
|
@@ -1710,7 +1787,13 @@ var SeedBuilder = class {
|
|
|
1710
1787
|
return Object.freeze({
|
|
1711
1788
|
cleanup,
|
|
1712
1789
|
homeDir,
|
|
1713
|
-
invocations: (command) =>
|
|
1790
|
+
invocations: async (command) => {
|
|
1791
|
+
if (!commands.has(command)) {
|
|
1792
|
+
const known = [...commands].sort().join(", ") || "(none)";
|
|
1793
|
+
throw new Error(`Unknown shim command: ${command}. Known shims: ${known}.`);
|
|
1794
|
+
}
|
|
1795
|
+
return readInvocations(logDir, command);
|
|
1796
|
+
},
|
|
1714
1797
|
pathDir,
|
|
1715
1798
|
workspaceDir,
|
|
1716
1799
|
[Symbol.asyncDispose]: cleanup
|
|
@@ -1721,6 +1804,69 @@ var SeedBuilder = class {
|
|
|
1721
1804
|
function createSeedBuilder() {
|
|
1722
1805
|
return new SeedBuilder();
|
|
1723
1806
|
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Merges trust records for the seeded presets into the seed's manifest.
|
|
1809
|
+
*
|
|
1810
|
+
* A trust record binds an absolute preset path to a hash of its contents, and both exist only
|
|
1811
|
+
* once the seed is materialized, so this runs after the files are written and reads them back.
|
|
1812
|
+
*/
|
|
1813
|
+
async function recordTrustedPresets(homeDir, workspaceDir, presets) {
|
|
1814
|
+
const manifestPath = join(homeDir, "agents", "aura.json");
|
|
1815
|
+
const existing = await readFile(manifestPath, "utf8").catch(() => void 0);
|
|
1816
|
+
const manifest = existing === void 0 ? {
|
|
1817
|
+
apps: {},
|
|
1818
|
+
mcpServers: [],
|
|
1819
|
+
ownership: {},
|
|
1820
|
+
schemaVersion: 1,
|
|
1821
|
+
skills: [],
|
|
1822
|
+
snippets: []
|
|
1823
|
+
} : parseSeedManifest(existing, manifestPath);
|
|
1824
|
+
const added = [];
|
|
1825
|
+
for (const relativePath of presets) {
|
|
1826
|
+
const path = join(workspaceDir, relativePath);
|
|
1827
|
+
const content = await readFile(path, "utf8").catch(() => {
|
|
1828
|
+
throw new Error(`trustWorkspacePreset needs the seeded workspace file: ${relativePath}`);
|
|
1829
|
+
});
|
|
1830
|
+
added.push({
|
|
1831
|
+
hash: hashRepoPreset(content),
|
|
1832
|
+
path
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
const addedPaths = new Set(added.map((entry) => entry.path));
|
|
1836
|
+
const trustedRepoPresets = [...parseTrustedRepoPresets(manifest["trustedRepoPresets"], manifestPath).filter((entry) => !addedPaths.has(entry.path)), ...added];
|
|
1837
|
+
if (trustedRepoPresets.length > 64) throw new Error(`Seed manifest ${manifestPath} exceeds the ${String(64)} trusted repository preset limit`);
|
|
1838
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
1839
|
+
await writeFile(manifestPath, `${JSON.stringify({
|
|
1840
|
+
...manifest,
|
|
1841
|
+
trustedRepoPresets
|
|
1842
|
+
}, void 0, 2)}\n`, {
|
|
1843
|
+
encoding: "utf8",
|
|
1844
|
+
mode: 384
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
function parseSeedManifest(source, path) {
|
|
1848
|
+
const value = JSON.parse(source);
|
|
1849
|
+
if (!isRecord(value)) throw new Error(`Seed manifest ${path} must contain a JSON object`);
|
|
1850
|
+
return value;
|
|
1851
|
+
}
|
|
1852
|
+
function parseTrustedRepoPresets(value, manifestPath) {
|
|
1853
|
+
if (value === void 0) return [];
|
|
1854
|
+
if (!Array.isArray(value)) throw new Error(`Seed manifest ${manifestPath} trustedRepoPresets must be an array`);
|
|
1855
|
+
return value.map((candidate, index) => {
|
|
1856
|
+
if (!isRecord(candidate) || typeof candidate["hash"] !== "string" || typeof candidate["path"] !== "string") throw new Error(`Seed manifest ${manifestPath} trustedRepoPresets[${String(index)}] must contain string hash and path fields`);
|
|
1857
|
+
return {
|
|
1858
|
+
...candidate,
|
|
1859
|
+
hash: candidate["hash"],
|
|
1860
|
+
path: candidate["path"]
|
|
1861
|
+
};
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
function isRecord(value) {
|
|
1865
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1866
|
+
}
|
|
1867
|
+
function assertSupportedPlatform(platform) {
|
|
1868
|
+
if (platform === "win32") throw new Error("Aura testkit requires a POSIX shell and cannot build seeds on Windows.");
|
|
1869
|
+
}
|
|
1724
1870
|
function addFile(files, scope, path, content) {
|
|
1725
1871
|
const normalized = normalizeSeedPath(path);
|
|
1726
1872
|
if (files.has(normalized)) throw new Error(`${scope} file is already seeded: ${normalized}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryaura/aura-testkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Deterministic filesystem seeds and CLI harnesses for Aura plugins and distributions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aura",
|
|
@@ -30,17 +30,17 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@tryaura/aura-
|
|
34
|
-
"@tryaura/aura-
|
|
33
|
+
"@tryaura/aura-cli": "0.2.1",
|
|
34
|
+
"@tryaura/aura-sdk": "0.2.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "24.13.3",
|
|
38
38
|
"diff": "9.0.0",
|
|
39
39
|
"vitest": "4.1.10",
|
|
40
|
-
"@tryaura/
|
|
41
|
-
"@tryaura/adapter-codex": "0.0.0",
|
|
40
|
+
"@tryaura/adapter-claude-code": "0.0.0",
|
|
42
41
|
"@tryaura/adapter-cursor": "0.0.0",
|
|
43
|
-
"@tryaura/
|
|
42
|
+
"@tryaura/core": "0.0.0",
|
|
43
|
+
"@tryaura/adapter-codex": "0.0.0"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=24"
|