mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108be12a71 | ||
|
|
85ef10dcff | ||
|
|
f37a1718de | ||
|
|
ef33754f2e |
No files matched your search
+25
-117
@@ -1,88 +1,12 @@
|
||||
name: Tests
|
||||
|
||||
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
|
||||
# list each published package would contain. Each suite is its own job so one failure does not
|
||||
# hide the others. No provider credentials or operator configuration are supplied.
|
||||
# Package suites deny external networking; scenario runtime has no external route.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
bundle-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Record the published file list of each package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p pack-lists
|
||||
for p in cli daemon tui; do
|
||||
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
|
||||
| sort > pack-lists/$p.txt
|
||||
echo "$p: $(wc -l < pack-lists/$p.txt) files"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pack-lists
|
||||
path: pack-lists/
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
repo-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
package-tests:
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
# file-inode behaviour and release tags.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [daemon, cli, tui, ui]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -92,48 +16,32 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: brew install tmux
|
||||
- run: npm ci
|
||||
- name: Build the packages the tests import
|
||||
run: npm run build
|
||||
- name: Run the suite without ambient credentials or external network
|
||||
- run: npm run build
|
||||
- name: Validate the corrected inspect and messages, and capture ordinary v1 head bytes
|
||||
run: |
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
|
||||
suite_root=$(cd "$suite_root" && pwd -P)
|
||||
mkdir -p "$suite_root/home" "$suite_root/bin"
|
||||
# Same system bytes, nonprivileged mode; no system file is changed.
|
||||
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
|
||||
# Only this job's temporary sockets (including tmux) are reachable.
|
||||
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
|
||||
CI=true TERM=xterm-256color \
|
||||
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
|
||||
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
|
||||
set -euo pipefail
|
||||
node scripts/ci-macos-process-probe.mjs
|
||||
# The production readers use bare ps. Select real system bytes only after
|
||||
# self/owned-child observation and network/IPC confinement are proved.
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
test "$(command -v ps)" = "$TMPDIR/bin/ps"
|
||||
exec npm run test -w "packages/$1"
|
||||
SUITE
|
||||
|
||||
installed-scenario:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Installed package, daemon restart, and seeded durability failure
|
||||
run: bash scripts/run-pr-scenarios.sh
|
||||
mkdir evidence
|
||||
npm run test -w packages/daemon -- test/bundle-login-files.test.ts test/bundle-routes.test.ts test/bundle-binary-bytes.test.ts --reporter=json --outputFile="$GITHUB_WORKSPACE/evidence/daemon-green.json" > evidence/daemon-green.log 2>&1
|
||||
npm run test -w packages/cli -- test/bundle-login-files.test.ts test/bundle-source.test.ts --reporter=json --outputFile="$GITHUB_WORKSPACE/evidence/cli-green.json" > evidence/cli-green.log 2>&1
|
||||
npm run test -w packages/ui -- test/bundle-inspector.test.tsx --reporter=json --outputFile="$GITHUB_WORKSPACE/evidence/ui-green.json" > evidence/ui-green.log 2>&1
|
||||
npm run lint > evidence/lint.log 2>&1
|
||||
node scripts/check-docs-guard.mjs > evidence/docs.log 2>&1
|
||||
BUNDLE_ARCHIVE_CAPTURE="$GITHUB_WORKSPACE/evidence/head-v1.rigbundle" npm run test -w packages/daemon -- test/bundle-v1-byte-probe.test.ts > evidence/head-bytes.log 2>&1
|
||||
- name: Capture exact base v1 bytes with the unchanged fixture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
baseline="$RUNNER_TEMP/bundle-baseline"
|
||||
git worktree add --detach "$baseline" ce7f89cc3c09b6b7dfcd842eddbdfb595a961259
|
||||
cp packages/daemon/test/bundle-v1-byte-probe.test.ts "$baseline/packages/daemon/test/bundle-v1-byte-probe.test.ts"
|
||||
cd "$baseline"
|
||||
npm ci
|
||||
npm run build -w packages/daemon
|
||||
BUNDLE_ARCHIVE_CAPTURE="$GITHUB_WORKSPACE/evidence/base-v1.rigbundle" npm run test -w packages/daemon -- test/bundle-v1-byte-probe.test.ts > "$GITHUB_WORKSPACE/evidence/base-bytes.log" 2>&1
|
||||
cmp "$GITHUB_WORKSPACE/evidence/base-v1.rigbundle" "$GITHUB_WORKSPACE/evidence/head-v1.rigbundle"
|
||||
sha256sum "$GITHUB_WORKSPACE/evidence/"*.rigbundle
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: installed-scenario-evidence
|
||||
path: dist/pr-scenarios/
|
||||
if-no-files-found: error
|
||||
name: bundle-round2-evidence
|
||||
path: evidence/
|
||||
@@ -21,7 +21,12 @@ Two rules matter most for strangers:
|
||||
- **Keep everything inside the folder.** Every `agent_ref` should be `local:` and point inside it. A path that exists on
|
||||
your machine won't exist on anyone else's.
|
||||
- **Never put credentials in it.** No tokens, keys or `.env` files. `rig bundle create` refuses well-known sensitive
|
||||
file names (such as `.env`, `.pem` and `.key`) anyway.
|
||||
file names (such as `.env`, `.pem` and `.key`) anyway. It also refuses contained `.claude/.credentials.json`,
|
||||
`.codex/auth.json` and `.pi/agent/auth.json`, including nested copies in agents or a carried project. Remove them
|
||||
and rebuild. Only if recipients should have these logins (for example, your own internal test bundle) and their
|
||||
owner agrees, `rig bundle create --include-login-files` records the exact included login-file paths in the
|
||||
manifest and consumers show a notice; anyone with the bundle could read those files.
|
||||
Unrecorded login files and other sensitive paths still fail. See [harness login files](rig-bundle.md#harness-login-files).
|
||||
|
||||
Two optional files sit beside `rig.yaml`:
|
||||
- **`bundle.yaml`** declares what people need before installing: setup steps (`preconditions`, shown before install
|
||||
|
||||
@@ -191,6 +191,7 @@ integrity:
|
||||
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
|
||||
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
|
||||
| `culture_file` | string | no | Relative path to the culture file if present. |
|
||||
| `included_login_files` | string[] | no | Exact archive-relative harness login files deliberately included by the producer. Only these paths are exempt from the login-file refusal; this unsigned declaration is not authentication or receiver approval. |
|
||||
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. Validation doesn't require it, but install refuses an archive without it and inspect reports integrity FAIL. |
|
||||
| `provenance` | object | no | Stated, not verified: `created_at`, `daemon_version`, `cli_version`, `source_host`, `author_session`, `source_rig_id`, `source_rig_name`, `notes`, and `source` for a GitHub link build. |
|
||||
| `assembler` | object | no | `openrigVersion` of the OpenRig that built the archive. |
|
||||
@@ -221,6 +222,28 @@ integrity:
|
||||
| `original_ref` | string | yes | The original import ref before rewriting. |
|
||||
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
|
||||
|
||||
### Harness login files
|
||||
|
||||
By default, create refuses `.claude/.credentials.json`, `.codex/auth.json` and `.pi/agent/auth.json`
|
||||
at the root or nested anywhere in selected bundle content, including agents and an explicitly carried project.
|
||||
The author check reports them too. Messages name paths, never contents, and explain both remedies:
|
||||
remove the file from the agent folder or carried project and build again; or, only if recipients should have this
|
||||
login (for example, your own internal test bundle) and its owner agrees, recreate with
|
||||
`rig bundle create <spec> -o <archive.rigbundle> --include-login-files`.
|
||||
Anyone with that archive could read the included login files.
|
||||
|
||||
The flag applies to this create only. The generated `included_login_files` manifest list names only the actual,
|
||||
exact archive-relative login files included. Inspect, unpack, install and bootstrap honour those paths without
|
||||
another flag and show a credential-bearing notice. An additional unrecorded login file still fails, as do malformed
|
||||
records, directory or wildcard exemptions, and unrelated sensitive files. There is no machine setting or `--force`
|
||||
bypass. The unsigned record states the producer's choice; it does not authenticate the producer, establish trust,
|
||||
or constitute a fresh recipient approval. Review the source and files before using a bundle.
|
||||
|
||||
Compatibility change: previously accepted bundles containing these files now fail default creation and downstream
|
||||
verification/install unless deliberately rebuilt with the record. Inspection remains diagnostic: it reports failed
|
||||
integrity. Ordinary `auth.json` elsewhere and non-login harness files are unchanged. Filename checks do not scan
|
||||
arbitrary contents for secrets.
|
||||
|
||||
### Integrity Section
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
@@ -272,7 +295,7 @@ where the daemon runs.
|
||||
### Create a bundle
|
||||
|
||||
```bash
|
||||
rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <dir>] [--preset <name>] [--seat <pod.member=runtime>]... [--context-pack <dir>]... [--project-dir <dir>] [--name <name>] [--bundle-version <ver>] [--min-cli-version <ver>] [--min-daemon-version <ver>] [--notes <text>] [--include-packages <refs...>] [--allow-drift] [--json]
|
||||
rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <dir>] [--preset <name>] [--seat <pod.member=runtime>]... [--context-pack <dir>]... [--project-dir <dir>] [--name <name>] [--bundle-version <ver>] [--min-cli-version <ver>] [--min-daemon-version <ver>] [--notes <text>] [--include-packages <refs...>] [--allow-drift] [--include-login-files] [--json]
|
||||
```
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
@@ -289,6 +312,7 @@ rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <
|
||||
| `--min-cli-version`, `--min-daemon-version` | no | — | The oldest OpenRig CLI or daemon that may install the bundle, checked on install. For a link they override the author's `bundle.yaml` minimums. |
|
||||
| `--notes <text>` | no | — | Notes stored in the manifest's provenance. |
|
||||
| `--include-packages <refs...>` | no | all from the spec | Legacy (schema 1) specs only: the package refs to include. |
|
||||
| `--include-login-files` | no | off | Only if recipients should have these logins and their owner agrees (for example, an internal test bundle), include them and record their exact archive-relative paths. Create and consumers show a credential-bearing notice. Does not exempt other sensitive files. |
|
||||
| `--allow-drift` | no | off | Build a spec that differs from the running rig of the same name. Without it, create refuses; with it, the difference is written into the manifest's provenance notes. |
|
||||
| `--json` | no | off | Machine-readable output. |
|
||||
|
||||
@@ -537,7 +561,7 @@ When an agent spec has `imports`, each import is resolved, vendored into the bun
|
||||
7. Each agent must have `name`, `path` (safe relative), and `hash`
|
||||
8. Optional blocks, when present, must be well formed: `provenance`, `compatibility`, `skills`, `plugins`,
|
||||
`workflow_specs`, `context_packs`, `agent_images`, and `project` (an `id` made of letters, digits, `.`, `_` and `-`,
|
||||
and a safe relative `path`)
|
||||
and a safe relative `path`), plus `included_login_files` (unique safe relative paths)
|
||||
|
||||
The manifest validator doesn't check `integrity`; unpack does (below).
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ export async function showBundleBehaviourBeforeAction(
|
||||
): Promise<BundleBehaviour | undefined> {
|
||||
try {
|
||||
const response = await inspect();
|
||||
const integrity = response.data.integrityResult as { warnings?: string[] } | undefined;
|
||||
for (const warning of integrity?.warnings ?? []) output(plain(warning));
|
||||
const view = response.data.behaviour as BundleBehaviour | undefined;
|
||||
if (response.status < 400 && view?.schema === "openrig.bundle-behaviour/v1") {
|
||||
for (const line of formatBundleBehaviour(view)) output(line);
|
||||
|
||||
@@ -78,6 +78,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.requiredOption("-o, --output <path>", "Output path for .rigbundle")
|
||||
.option("--name <name>", "Bundle name (default: the rig's name in rig.yaml)")
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-login-files", "Include harness login files only if recipients should have them and their owner agrees (for example, an internal test bundle); record exact paths in the manifest")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--context-pack <dir>", "Carry the context pack in <dir> (its manifest.yaml and declared files), which may sit outside the rig folder; repeatable", (dir: string, dirs: string[]) => [...dirs, dir], [] as string[])
|
||||
@@ -89,7 +90,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--allow-drift", "Bundle a spec that disagrees with the running rig of the same name; the divergence is stamped into bundle provenance")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name?: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; contextPack?: string[]; projectDir?: string; preset?: string; seat?: string[]; notes?: string; minDaemonVersion?: string; minCliVersion?: string; allowDrift?: boolean; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name?: string; bundleVersion: string; includePackages?: string[]; includeLoginFiles?: boolean; rigRoot?: string; contextPack?: string[]; projectDir?: string; preset?: string; seat?: string[]; notes?: string; minDaemonVersion?: string; minCliVersion?: string; allowDrift?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
if (isGitHubBundleLink(spec)) {
|
||||
try {
|
||||
@@ -152,6 +153,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath, bundleName, bundleVersion: opts.bundleVersion, outputPath: nodePath.resolve(opts.output),
|
||||
includePackages: opts.includePackages,
|
||||
...(opts.includeLoginFiles ? { includeLoginFiles: true } : {}),
|
||||
rigRoot,
|
||||
...(opts.contextPack?.length ? { contextPackDirs: opts.contextPack.map((dir) => nodePath.resolve(dir)) } : {}),
|
||||
...(opts.projectDir ? { projectDir: nodePath.resolve(opts.projectDir) } : {}),
|
||||
@@ -257,6 +259,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(`Digest valid: ${res.data["digestValid"]}`);
|
||||
const ir = res.data["integrityResult"] as Record<string, unknown>;
|
||||
console.log(`Integrity: ${ir["passed"] ? "PASS" : "FAIL"}`);
|
||||
for (const error of (ir["errors"] as string[] | undefined) ?? []) console.error(error);
|
||||
// What install will add before any seat launches
|
||||
const packs = m["contextPacks"] as string[] | undefined;
|
||||
if (packs?.length) console.log(`Context packs: ${packs.join(", ")}`);
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Check {
|
||||
|
||||
/** Advisory filesystem reads only. No daemon, bundler, preflight, provider or launch. */
|
||||
export async function checkBundleFolder(folder: string) {
|
||||
const { validateRigSpecImport, validateAgentSpecFromYaml, isSensitivePath } = await import("@openrig/daemon/spec-validation");
|
||||
const { validateRigSpecImport, validateAgentSpecFromYaml, isSensitivePath, isHarnessLoginPath, harnessLoginMessage } = await import("@openrig/daemon/spec-validation");
|
||||
const checks: Check[] = [];
|
||||
const add = (ruleId: string, status: Check["status"], reason: string, file?: string) => checks.push({ ruleId, status, reason, ...(file ? { path: file } : {}) });
|
||||
const result = { standardVersion: "openrig.bundle-standard/v1", checks };
|
||||
@@ -126,7 +126,7 @@ export async function checkBundleFolder(folder: string) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) { add("credential_paths", "not_checked", "Symlink content was not scanned.", path.relative(root, file)); continue; }
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (isSensitivePath(path.relative(root, file))) { sensitive++; add("credential_paths", "finding", "Known sensitive filename; exclude credentials from the published folder.", path.relative(root, file)); }
|
||||
else if (isSensitivePath(path.relative(root, file))) { sensitive++; add("credential_paths", "finding", isHarnessLoginPath(path.relative(root, file)) ? harnessLoginMessage(path.relative(root, file)) : "Known sensitive filename; exclude credentials from the published folder.", path.relative(root, file)); }
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
@@ -261,6 +261,7 @@ export interface BundleLinkOptions {
|
||||
minDaemonVersion?: string;
|
||||
minCliVersion?: string;
|
||||
includePackages?: string[];
|
||||
includeLoginFiles?: boolean;
|
||||
contextPack?: string[];
|
||||
projectDir?: string;
|
||||
allowDrift?: boolean;
|
||||
@@ -310,6 +311,7 @@ export async function importGitHubBundle(input: string, deps: StatusDeps, opts:
|
||||
specPath, rigRoot: folder, outputPath: bundlePath,
|
||||
bundleName, bundleVersion: opts.bundleVersion ?? "0.1.0",
|
||||
includePackages: opts.includePackages,
|
||||
...(opts.includeLoginFiles ? { includeLoginFiles: true } : {}),
|
||||
...(opts.projectDir ? { projectDir: path.resolve(opts.projectDir) } : {}),
|
||||
...(opts.contextPack?.length ? { contextPackDirs: opts.contextPack.map(dir => path.resolve(dir)) } : {}),
|
||||
provenance: { ...opts.provenance, source: prepared.source },
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { LOGIN_FILES, author, existingArchive, expectLoginMessage, put } from "../../daemon/test/helpers/bundle-login-fixture.js";
|
||||
import { bundleCommand } from "../src/commands/bundle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async original => ({
|
||||
...await original<typeof import("../src/daemon-lifecycle.js")>(),
|
||||
getDaemonStatus: async () => ({ state: "running", healthy: true }),
|
||||
getDaemonUrl: () => "http://fixture.invalid", daemonStatusGuard: () => true,
|
||||
}));
|
||||
|
||||
// This policy test crosses the real archive/bootstrap boundary, with provider checks explicitly inert.
|
||||
vi.mock("../../daemon/src/domain/rigspec-preflight.js", async original => ({
|
||||
...await original<object>(), rigPreflight: async () => ({ ready: true, errors: [], warnings: [] }),
|
||||
}));
|
||||
|
||||
describe("actionable harness-login bundle diagnostics", () => {
|
||||
let root: string, db: Database.Database, app: ReturnType<typeof createTestApp>["app"];
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-login-cli-"));
|
||||
db = createDb(); migrate(db, ALL_MIGRATIONS); app = createTestApp(db).app;
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
async function command(args: string[]) {
|
||||
const output: string[] = [];
|
||||
const spies = ["log", "error", "warn"].map(method => vi.spyOn(console, method as "log").mockImplementation((...parts) => { output.push(parts.join(" ")); }));
|
||||
const priorExit = process.exitCode; process.exitCode = undefined;
|
||||
const client = { post: async (route: string, body: unknown) => {
|
||||
const response = await app.request(route, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
return { status: response.status, data: await response.json() };
|
||||
} };
|
||||
try {
|
||||
const deps = { lifecycleDeps: {}, clientFactory: () => client } as unknown as StatusDeps;
|
||||
await new Command().exitOverride().addCommand(bundleCommand(deps)).parseAsync(["bundle", ...args], { from: "user" });
|
||||
return { output: output.join("\n"), exit: process.exitCode };
|
||||
} finally { process.exitCode = priorExit; for (const spy of spies) spy.mockRestore(); }
|
||||
}
|
||||
|
||||
for (const json of [false, true]) {
|
||||
it(`author check explains each login finding (${json ? "JSON" : "human"})`, async () => {
|
||||
const folder = path.join(root, "author"); author(folder);
|
||||
for (const file of LOGIN_FILES) put(folder, `agents/worker/${file}`);
|
||||
const result = await command(["check", folder, ...(json ? ["--json"] : [])]);
|
||||
expect(result.exit).toBe(1);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(result.output, file);
|
||||
});
|
||||
|
||||
it(`create explains the refusal (${json ? "JSON" : "human"})`, async () => {
|
||||
const folder = path.join(root, "author"); const spec = author(folder);
|
||||
for (const file of LOGIN_FILES) put(folder, `agents/worker/${file}`);
|
||||
const result = await command(["create", spec, "-o", path.join(root, "out.rigbundle"), ...(json ? ["--json"] : [])]);
|
||||
expect(result.exit).toBe(2);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(result.output, file);
|
||||
});
|
||||
|
||||
for (const action of ["inspect", "install"] as const) {
|
||||
it(`${action} explains why the existing archive fails (${json ? "JSON" : "human"})`, async () => {
|
||||
const f = await existingArchive(root);
|
||||
const result = await command([action, f.archive, ...(action === "install" ? ["--plan"] : []), ...(json ? ["--json"] : [])]);
|
||||
expect(result.exit).toBe(2);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(result.output, file);
|
||||
});
|
||||
}
|
||||
|
||||
it(`explicit create and its downstream notices remain visible (${json ? "JSON" : "human"})`, async () => {
|
||||
const folder = path.join(root, "author"); const spec = author(folder);
|
||||
for (const file of LOGIN_FILES) put(folder, `agents/worker/${file}`);
|
||||
const archive = path.join(root, "intentional.rigbundle");
|
||||
const created = await command(["create", spec, "-o", archive, "--include-login-files", ...(json ? ["--json"] : [])]);
|
||||
expect(created.exit).not.toBe(2); for (const file of LOGIN_FILES) expectLoginMessage(created.output, file);
|
||||
for (const action of ["inspect", "install"]) {
|
||||
const consumed = await command([action, archive, ...(action === "install" ? ["--plan"] : []), ...(json ? ["--json"] : [])]);
|
||||
expect(consumed.exit).toBeUndefined(); for (const file of LOGIN_FILES) expectLoginMessage(consumed.output, file);
|
||||
if (action === "install") expect(consumed.output).toMatch(/planned/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -262,6 +262,14 @@ describe("GitHub bundle source", () => {
|
||||
expect(f.prepare).not.toHaveBeenCalled(); expect(f.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("forwards only the explicit per-create login opt-in for a GitHub source (%s)", async includeLoginFiles => {
|
||||
const f = fixture();
|
||||
await importGitHubBundle(URL, f.deps, { includeLoginFiles }, f.prepare);
|
||||
const body = f.post.mock.calls[0]![1];
|
||||
if (includeLoginFiles) expect(body.includeLoginFiles).toBe(true);
|
||||
else expect(body).not.toHaveProperty("includeLoginFiles");
|
||||
});
|
||||
|
||||
it("uses one create, passes authored minima and source, then cleans only settled input", async () => {
|
||||
const f = fixture();
|
||||
const result = await importGitHubBundle(URL, f.deps, { minCliVersion: "0.6.6" }, f.prepare);
|
||||
|
||||
@@ -163,7 +163,8 @@ export class BootstrapOrchestrator {
|
||||
let bundleSchemaVersion = 1;
|
||||
const peekDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-peek-"));
|
||||
try {
|
||||
await unpack(sourceRef, peekDir);
|
||||
const unpacked = await unpack(sourceRef, peekDir);
|
||||
warnings.push(...(unpacked?.warnings ?? []));
|
||||
const manifestPath = nodePath.join(peekDir, "bundle.yaml");
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
|
||||
|
||||
@@ -13,7 +13,7 @@ import nodePath from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { verifyIntegrity, type IntegrityFsOps } from "./bundle-integrity.js";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest } from "./bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, normalizeIncludedLoginFiles } from "./bundle-types.js";
|
||||
|
||||
/**
|
||||
* Pack a staging directory into a .rigbundle archive (deterministic tar.gz).
|
||||
@@ -109,7 +109,7 @@ export async function collectUnsafeArchiveEntries(archivePath: string): Promise<
|
||||
* Rejects symlinks, hardlinks, path traversal, and absolute paths.
|
||||
* Runs content integrity verification after extraction.
|
||||
*/
|
||||
export async function unpack(archivePath: string, outputDir: string): Promise<void> {
|
||||
export async function unpack(archivePath: string, outputDir: string): Promise<{ warnings: string[] } | undefined> {
|
||||
// Step 1: Verify archive-level digest
|
||||
const digestResult = verifyArchiveDigest(archivePath);
|
||||
if (!digestResult.valid) {
|
||||
@@ -173,8 +173,8 @@ export async function unpack(archivePath: string, outputDir: string): Promise<vo
|
||||
walkFiles: (dir) => walkFilesSync(dir),
|
||||
};
|
||||
|
||||
// verifyIntegrity only reads manifest.integrity — safe to cast
|
||||
const result = verifyIntegrity(outputDir, { integrity } as unknown as Parameters<typeof verifyIntegrity>[1], fsOps);
|
||||
const includedLoginFiles = normalizeIncludedLoginFiles(raw["included_login_files"]);
|
||||
const result = verifyIntegrity(outputDir, { integrity, includedLoginFiles } as unknown as Parameters<typeof verifyIntegrity>[1], fsOps);
|
||||
if (!result.passed) {
|
||||
const details = [
|
||||
...result.mismatches.map((f) => `tampered: ${f}`),
|
||||
@@ -184,6 +184,7 @@ export async function unpack(archivePath: string, outputDir: string): Promise<vo
|
||||
];
|
||||
throw new Error(`Content integrity verification failed: ${details.join("; ")}`);
|
||||
}
|
||||
if (result.warnings) return { warnings: result.warnings };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import nodePath from "node:path";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import type { LegacyBundleManifest as BundleManifest, BundleIntegrity } from "./bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializeLegacyBundleManifest as serializeBundleManifest } from "./bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializeLegacyBundleManifest as serializeBundleManifest, normalizeIncludedLoginFiles } from "./bundle-types.js";
|
||||
|
||||
export interface IntegrityFsOps {
|
||||
readFile: (path: string) => string;
|
||||
@@ -32,9 +32,24 @@ const SENSITIVE_PATTERNS = [
|
||||
/^node_modules\//,
|
||||
];
|
||||
|
||||
export function isSensitivePath(relativePath: string): boolean {
|
||||
export function isHarnessLoginPath(relativePath: string): boolean {
|
||||
return /(?:^|\/)(?:\.claude\/\.credentials\.json|\.codex\/auth\.json|\.pi\/agent\/auth\.json)$/.test(relativePath.split(nodePath.sep).join("/"));
|
||||
}
|
||||
|
||||
export function harnessLoginMessage(relativePath: string, included = false): string {
|
||||
return `${included ? "Credential-bearing bundle includes" : "Bundle refuses"} harness login file '${relativePath}': anyone with the bundle could read this login file. Remove it from the agent folder or carried project and build the bundle again, or, only if recipients should have this login (for example, your own internal test bundle) and its owner agrees, recreate with --include-login-files. ${included ? "The unsigned manifest records the producer's declaration, not trust or receiver approval." : "No file contents are shown."}`;
|
||||
}
|
||||
|
||||
function includedLoginPaths(raw: unknown): Set<string> {
|
||||
const paths = normalizeIncludedLoginFiles(raw) ?? [];
|
||||
if (paths.some(p => !isHarnessLoginPath(p) || /[*?]/.test(p))) throw new Error("included_login_files may name only exact harness login-file paths, never directories or wildcards");
|
||||
return new Set(paths);
|
||||
}
|
||||
|
||||
export function isSensitivePath(relativePath: string, includedLoginFiles?: ReadonlySet<string>): boolean {
|
||||
const name = nodePath.basename(relativePath);
|
||||
return SENSITIVE_PATTERNS.some((p) => p.test(name) || p.test(relativePath));
|
||||
return SENSITIVE_PATTERNS.some((p) => p.test(name) || p.test(relativePath))
|
||||
|| (isHarnessLoginPath(relativePath) && !includedLoginFiles?.has(relativePath));
|
||||
}
|
||||
|
||||
function hashContent(content: Buffer): string {
|
||||
@@ -45,7 +60,8 @@ function hashContent(content: Buffer): string {
|
||||
* Compute integrity hashes for all content files in a bundle directory.
|
||||
* Throws on sensitive paths. Excludes control files and OS junk.
|
||||
*/
|
||||
export function computeIntegrity(dir: string, fsOps: IntegrityFsOps): BundleIntegrity {
|
||||
export function computeIntegrity(dir: string, fsOps: IntegrityFsOps, includedLoginFiles?: string[]): BundleIntegrity {
|
||||
const included = includedLoginPaths(includedLoginFiles);
|
||||
const allFiles = fsOps.walkFiles(dir);
|
||||
const files: Record<string, string> = {};
|
||||
const sensitive: string[] = [];
|
||||
@@ -55,8 +71,8 @@ export function computeIntegrity(dir: string, fsOps: IntegrityFsOps): BundleInte
|
||||
if (IGNORE_FILES.has(name)) continue;
|
||||
if (CONTROL_FILES.has(relPath)) continue;
|
||||
|
||||
if (isSensitivePath(relPath)) {
|
||||
sensitive.push(relPath);
|
||||
if (isSensitivePath(relPath, included)) {
|
||||
sensitive.push(isHarnessLoginPath(relPath) ? harnessLoginMessage(relPath) : relPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -92,6 +108,7 @@ export interface VerifyResult {
|
||||
missing: string[];
|
||||
extra: string[];
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +130,16 @@ export function verifyIntegrity(dir: string, manifest: BundleManifest, fsOps: In
|
||||
return result;
|
||||
}
|
||||
|
||||
let included: Set<string>;
|
||||
try { included = includedLoginPaths(manifest.includedLoginFiles); }
|
||||
catch (err) { result.passed = false; result.errors.push((err as Error).message); return result; }
|
||||
const expectedFiles = manifest.integrity.files;
|
||||
for (const file of included) {
|
||||
if (!Object.hasOwn(expectedFiles, file)) {
|
||||
result.passed = false;
|
||||
result.errors.push(`included_login_files path is not covered by integrity: '${file}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected files
|
||||
for (const [relPath, expectedHash] of Object.entries(expectedFiles)) {
|
||||
@@ -142,11 +168,12 @@ export function verifyIntegrity(dir: string, manifest: BundleManifest, fsOps: In
|
||||
result.passed = false;
|
||||
}
|
||||
// Check for sensitive paths on install/verify path (not just create)
|
||||
if (isSensitivePath(relPath)) {
|
||||
result.errors.push(`Sensitive file detected: ${relPath}`);
|
||||
if (isSensitivePath(relPath, included)) {
|
||||
result.errors.push(isHarnessLoginPath(relPath) ? harnessLoginMessage(relPath) : `Sensitive file detected: ${relPath}`);
|
||||
result.passed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (included.size > 0) result.warnings = [...included].map(file => harnessLoginMessage(file, true));
|
||||
return result;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export class LegacyBundleSourceResolver {
|
||||
|
||||
// -- Pod-aware bundle source resolver (AgentSpec reboot) --
|
||||
|
||||
import { parsePodBundleManifest, validatePodBundleManifest, type PodBundleManifest } from "./bundle-types.js";
|
||||
import { parsePodBundleManifest, validatePodBundleManifest, normalizeIncludedLoginFiles, type PodBundleManifest } from "./bundle-types.js";
|
||||
|
||||
/** Result of resolving a pod-aware bundle */
|
||||
export interface PodBundleResolvedSource {
|
||||
@@ -174,6 +174,7 @@ export class PodBundleSourceResolver {
|
||||
: [],
|
||||
})),
|
||||
cultureFile: m["culture_file"] as string | undefined,
|
||||
includedLoginFiles: normalizeIncludedLoginFiles(m["included_login_files"]),
|
||||
};
|
||||
|
||||
const specPath = nodePath.join(tempDir, manifest.rigSpec);
|
||||
|
||||
@@ -433,6 +433,8 @@ export interface PodBundleManifest {
|
||||
agents: PodBundleAgentEntry[];
|
||||
cultureFile?: string;
|
||||
integrity?: BundleIntegrity;
|
||||
/** Exact archive-relative harness login paths deliberately included by the producer. Not authentication. */
|
||||
includedLoginFiles?: string[];
|
||||
provenance?: BundleProvenance;
|
||||
assembler?: { openrigVersion: string; commit?: string };
|
||||
compatibility?: BundleCompatibility;
|
||||
@@ -458,6 +460,19 @@ export interface BundleProjectReference {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** A producer declaration, never a wildcard or a receiver approval. */
|
||||
export function normalizeIncludedLoginFiles(raw: unknown): string[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!Array.isArray(raw) || raw.some(p => typeof p !== "string" || !isRelativeSafePath(p)) || new Set(raw).size !== raw.length) {
|
||||
throw new Error("included_login_files must be a list of unique, exact safe archive-relative login-file paths");
|
||||
}
|
||||
return raw as string[];
|
||||
}
|
||||
|
||||
function validateIncludedLoginFiles(raw: unknown, errors: string[]): void {
|
||||
try { normalizeIncludedLoginFiles(raw); } catch (err) { errors.push((err as Error).message); }
|
||||
}
|
||||
|
||||
function validateProjectBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
@@ -501,6 +516,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
|
||||
validateContextPacksBlock(m["context_packs"], errors);
|
||||
validateAgentImagesBlock(m["agent_images"], errors);
|
||||
validateProjectBlock(m["project"], errors);
|
||||
validateIncludedLoginFiles(m["included_login_files"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -529,6 +545,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
|
||||
};
|
||||
if (manifest.cultureFile) doc["culture_file"] = manifest.cultureFile;
|
||||
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
|
||||
if (manifest.includedLoginFiles?.length) doc["included_login_files"] = manifest.includedLoginFiles;
|
||||
if (manifest.assembler) doc["assembler"] = manifest.assembler;
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
@@ -575,6 +592,8 @@ export interface LegacyBundleManifest {
|
||||
rigSpec: string;
|
||||
packages: LegacyBundlePackageEntry[];
|
||||
integrity?: BundleIntegrity;
|
||||
/** Exact archive-relative harness login paths deliberately included by the producer. Not authentication. */
|
||||
includedLoginFiles?: string[];
|
||||
provenance?: BundleProvenance;
|
||||
assembler?: { openrigVersion: string; commit?: string };
|
||||
compatibility?: BundleCompatibility;
|
||||
@@ -691,6 +710,7 @@ export function validateLegacyBundleManifest(
|
||||
// (v2) validator already enforces containment here; the legacy (v1) validator must too, or a v1
|
||||
// bundle can escape the projects root (write) or the bundle root (read).
|
||||
validateProjectBlock(m["project"], errors);
|
||||
validateIncludedLoginFiles(m["included_login_files"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -733,6 +753,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
|
||||
};
|
||||
}
|
||||
|
||||
const includedLoginFiles = normalizeIncludedLoginFiles(m["included_login_files"]);
|
||||
if (includedLoginFiles) result.includedLoginFiles = includedLoginFiles;
|
||||
|
||||
const assembler = m["assembler"] as { openrigVersion?: unknown; commit?: unknown } | undefined;
|
||||
if (assembler && typeof assembler.openrigVersion === "string") result.assembler = { openrigVersion: assembler.openrigVersion, ...(typeof assembler.commit === "string" ? { commit: assembler.commit } : {}) };
|
||||
const provenance = normalizeProvenanceBlock(m["provenance"]);
|
||||
@@ -785,6 +808,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
|
||||
};
|
||||
}
|
||||
|
||||
if (manifest.includedLoginFiles?.length) doc["included_login_files"] = manifest.includedLoginFiles;
|
||||
if (manifest.assembler) doc["assembler"] = manifest.assembler;
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { BootstrapOrchestrator } from "../domain/bootstrap-orchestrator.js"
|
||||
import type { BootstrapRepository } from "../domain/bootstrap-repository.js";
|
||||
import { LegacyBundleAssembler as BundleAssembler, type AssemblerFsOps } from "../domain/bundle-assembler.js";
|
||||
import { PodBundleAssembler, type PodAssemblerFsOps } from "../domain/pod-bundle-assembler.js";
|
||||
import { computeIntegrity, writeIntegrity, verifyIntegrity, type IntegrityFsOps } from "../domain/bundle-integrity.js";
|
||||
import { computeIntegrity, verifyIntegrity, isHarnessLoginPath, harnessLoginMessage, type IntegrityFsOps } from "../domain/bundle-integrity.js";
|
||||
import { pack, unpack, verifyArchiveDigest, collectUnsafeArchiveEntries, unsafeArchiveEntryReason } from "../domain/bundle-archive.js";
|
||||
import { resolvePackage } from "../domain/package-resolve-helper.js";
|
||||
import {
|
||||
@@ -21,7 +21,7 @@ import { LegacyRigSpecCodec } from "../domain/rigspec-codec.js";
|
||||
import { LegacyRigSpecSchema } from "../domain/rigspec-schema.js";
|
||||
import { RigSpecCodec } from "../domain/rigspec-codec.js";
|
||||
import { RigSpecSchema } from "../domain/rigspec-schema.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, validateLegacyBundleManifest, normalizeProvenanceBlock, normalizeBundleSource, normalizeCompatibilityBlock, isRelativeSafePath } from "../domain/bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, validateLegacyBundleManifest, normalizeProvenanceBlock, normalizeBundleSource, normalizeCompatibilityBlock, isRelativeSafePath, normalizeIncludedLoginFiles } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest, BundleProvenance, BundleCompatibility, BundlePluginReference } from "../domain/bundle-types.js";
|
||||
import { detectBundleConflicts, type BundleConflict } from "../domain/bundle-conflict-detector.js";
|
||||
import { bundleInstallContext, bundleInstallContextLines } from "../domain/bundle-install-context.js";
|
||||
@@ -122,10 +122,11 @@ async function extractManifestForCompatCheck(bundlePath: string): Promise<Record
|
||||
async function extractInstallTimeMetadata(bundlePath: string): Promise<{
|
||||
bundleManifest: Record<string, unknown>;
|
||||
rigName: string | undefined;
|
||||
warnings?: string[];
|
||||
}> {
|
||||
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-meta-"));
|
||||
try {
|
||||
await unpack(bundlePath, tmpDir);
|
||||
const unpacked = await unpack(bundlePath, tmpDir);
|
||||
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
|
||||
if (!fs.existsSync(manifestPath)) throw new Error("Bundle missing bundle.yaml");
|
||||
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
|
||||
@@ -177,7 +178,7 @@ async function extractInstallTimeMetadata(bundlePath: string): Promise<{
|
||||
// rig.yaml malformed — leave rigName undefined; conflict check skips
|
||||
}
|
||||
}
|
||||
return { bundleManifest, rigName };
|
||||
return { bundleManifest, rigName, ...(unpacked?.warnings ? { warnings: unpacked.warnings } : {}) };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -840,14 +841,16 @@ bundleRoutes.post("/create", async (c) => {
|
||||
if (carriedPacks.length > 0) result.manifest.contextPacks = [...(result.manifest.contextPacks ?? []), ...carriedPacks];
|
||||
if (projectDir) result.manifest.project = vendorProjectDir(nodePath.resolve(projectDir), tmpStaging);
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
const included = body["includeLoginFiles"] === true ? integrityFsOps().walkFiles(tmpStaging).filter(isHarnessLoginPath).sort() : [];
|
||||
if (included.length) result.manifest.includedLoginFiles = included;
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps(), included);
|
||||
result.manifest.integrity = integrity;
|
||||
fs.writeFileSync(nodePath.join(tmpStaging, "bundle.yaml"), serializePodBundleManifest(result.manifest), "utf-8");
|
||||
|
||||
assertShippableStagingTree(tmpStaging);
|
||||
const archiveHash = await pack(tmpStaging, nodePath.resolve(outputPath));
|
||||
eventBus.emit({ type: "bundle.created", bundleName, bundleVersion, archiveHash });
|
||||
const warning = [driftWarning, ...(result.warnings ?? []), ...(authorPrimitives.warnings ?? [])].filter(Boolean).join("; ");
|
||||
const warning = [driftWarning, ...(result.warnings ?? []), ...(authorPrimitives.warnings ?? []), ...included.map(file => harnessLoginMessage(file, true))].filter(Boolean).join("; ");
|
||||
return c.json({ ...bundleBuildIdentity(result.manifest, archiveHash), bundleName, bundleVersion, archiveHash, schemaVersion: 2, agents: result.manifest.agents.length, ...(warning ? { warning } : {}) }, 201);
|
||||
} finally {
|
||||
fs.rmSync(tmpStaging, { recursive: true, force: true });
|
||||
@@ -924,14 +927,17 @@ bundleRoutes.post("/create", async (c) => {
|
||||
fs.writeFileSync(nodePath.join(tmpStaging, "bundle.yaml"), serializeLegacyBundleManifest(manifest), "utf-8");
|
||||
}
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
writeIntegrity(tmpStaging, integrity, integrityFsOps());
|
||||
const included = body["includeLoginFiles"] === true ? integrityFsOps().walkFiles(tmpStaging).filter(isHarnessLoginPath).sort() : [];
|
||||
if (included.length) manifest.includedLoginFiles = included;
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps(), included);
|
||||
manifest.integrity = integrity;
|
||||
const { serializeLegacyBundleManifest } = await import("../domain/bundle-types.js");
|
||||
fs.writeFileSync(nodePath.join(tmpStaging, "bundle.yaml"), serializeLegacyBundleManifest(manifest), "utf-8");
|
||||
|
||||
assertShippableStagingTree(tmpStaging);
|
||||
const archiveHash = await pack(tmpStaging, nodePath.resolve(outputPath));
|
||||
eventBus.emit({ type: "bundle.created", bundleName, bundleVersion, archiveHash });
|
||||
const warning = [driftWarning, ...(legacyAuthorPrimitives.warnings ?? [])].filter(Boolean).join("; ");
|
||||
const warning = [driftWarning, ...(legacyAuthorPrimitives.warnings ?? []), ...included.map(file => harnessLoginMessage(file, true))].filter(Boolean).join("; ");
|
||||
return c.json({ ...bundleBuildIdentity(manifest, archiveHash), bundleName, bundleVersion, archiveHash, packages: manifest.packages.length, ...(warning ? { warning } : {}) }, 201);
|
||||
} finally {
|
||||
fs.rmSync(tmpStaging, { recursive: true, force: true });
|
||||
@@ -1017,6 +1023,7 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
const integritySection = rawParsed["integrity"] as { algorithm?: string; files?: Record<string, string> } | undefined;
|
||||
const podManifest = {
|
||||
schemaVersion: 2 as const,
|
||||
includedLoginFiles: normalizeIncludedLoginFiles(rawParsed["included_login_files"]),
|
||||
name: rawParsed["name"] as string,
|
||||
version: rawParsed["version"] as string,
|
||||
createdAt: rawParsed["created_at"] as string,
|
||||
@@ -1071,6 +1078,7 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
createdAt: podManifest.createdAt,
|
||||
rigSpec: podManifest.rigSpec,
|
||||
packages: [],
|
||||
includedLoginFiles: podManifest.includedLoginFiles,
|
||||
integrity: { algorithm: "sha256" as const, files: integritySection.files ?? {} },
|
||||
} : undefined;
|
||||
const integrityResult = integrityCompat
|
||||
@@ -1081,7 +1089,13 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
return c.json({ ...identity, manifest: podManifest, digestValid, integrityResult, behaviour }, 200);
|
||||
}
|
||||
|
||||
const manifest = normalizeBundleManifest(parseBundleManifest(manifestYaml));
|
||||
let manifest: ReturnType<typeof normalizeBundleManifest>;
|
||||
try {
|
||||
manifest = normalizeBundleManifest(parseBundleManifest(manifestYaml));
|
||||
} catch (err) {
|
||||
// Like v2, malformed v1 manifests are inspect diagnostics, not server failures.
|
||||
return c.json({ error: `Invalid v1 manifest: ${(err as Error).message}`, digestValid }, 200);
|
||||
}
|
||||
const integrityResult = manifest.integrity
|
||||
? verifyIntegrity(tmpDir, manifest, integrityFsOps())
|
||||
: { passed: false, mismatches: [], missing: [], extra: [], errors: ["no integrity section"] };
|
||||
@@ -1150,7 +1164,7 @@ bundleRoutes.post("/install", async (c) => {
|
||||
// source resolver validates them again later — so it runs unconditionally:
|
||||
// --skip-version-check and --force override the Item-2 compat check and
|
||||
// the Item-3 conflict check, not the safety validation.
|
||||
let installMeta: { bundleManifest: Record<string, unknown>; rigName: string | undefined };
|
||||
let installMeta: { bundleManifest: Record<string, unknown>; rigName: string | undefined; warnings?: string[] };
|
||||
try {
|
||||
installMeta = await extractInstallTimeMetadata(bundlePath);
|
||||
} catch (err) {
|
||||
@@ -1217,7 +1231,7 @@ bundleRoutes.post("/install", async (c) => {
|
||||
});
|
||||
if (result.status === "planned") {
|
||||
eventBus.emit({ type: "bootstrap.planned", runId: result.runId, sourceRef: bundlePath, stages: result.stages.length });
|
||||
return c.json(result, 200);
|
||||
return c.json({ ...result, ...(installMeta.warnings ? { warnings: [...installMeta.warnings.filter(w => !result.warnings?.includes(w)), ...(result.warnings ?? [])] } : {}) }, 200);
|
||||
}
|
||||
// Plan failed — structured mapping (same as bootstrap plan route)
|
||||
eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef: bundlePath, error: result.errors[0] ?? "plan failed" });
|
||||
@@ -1250,7 +1264,8 @@ bundleRoutes.post("/install", async (c) => {
|
||||
const routing: BundleContentRouting | undefined = bundleRouting
|
||||
?? (result.status === "completed" ? await bootstrapOrchestrator.routeBundleContents(bundlePath) : undefined);
|
||||
// The hook already put its failures in warnings; the legacy fallback adds its own here.
|
||||
const warnings = bundleRouting ? (result.warnings ?? []) : [...(result.warnings ?? []), ...routingFailureWarnings(routing)];
|
||||
const warnings = [...(result.warnings ?? []), ...(bundleRouting ? [] : routingFailureWarnings(routing))];
|
||||
warnings.unshift(...(installMeta.warnings ?? []).filter(w => !warnings.includes(w)));
|
||||
const responseBody = routing ? { ...bootstrapResult, ...routing, warnings } : { ...bootstrapResult, warnings };
|
||||
if (result.status === "completed") {
|
||||
eventBus.emit({ type: "bootstrap.completed", runId: result.runId, rigId: result.rigId!, sourceRef: bundlePath });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { RigSpecParseError, validateRigSpecImport } from "./domain/spec-validation-service.js";
|
||||
|
||||
export { validateAgentSpecFromYaml } from "./domain/spec-validation-service.js";
|
||||
export { isSensitivePath } from "./domain/bundle-integrity.js";
|
||||
export { isSensitivePath, harnessLoginMessage, isHarnessLoginPath } from "./domain/bundle-integrity.js";
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createTestApp } from "./helpers/test-app.js";
|
||||
import { computeIntegrity, isSensitivePath, verifyIntegrity } from "../src/domain/bundle-integrity.js";
|
||||
import { pack, unpack } from "../src/domain/bundle-archive.js";
|
||||
import { normalizeLegacyBundleManifest, serializeLegacyBundleManifest, validateLegacyBundleManifest, validatePodBundleManifest } from "../src/domain/bundle-types.js";
|
||||
import { PodBundleSourceResolver } from "../src/domain/bundle-source-resolver.js";
|
||||
import { stringify } from "yaml";
|
||||
import { LOGIN_FILES, author, existingArchive, expectLoginMessage, integrityFs, put } from "./helpers/bundle-login-fixture.js";
|
||||
|
||||
// This policy test crosses the real archive/bootstrap boundary, with provider checks explicitly inert.
|
||||
vi.mock("../src/domain/rigspec-preflight.js", async original => ({
|
||||
...await original<object>(), rigPreflight: async () => ({ ready: true, errors: [], warnings: [] }),
|
||||
}));
|
||||
|
||||
describe("known harness login paths in selected bundle content", () => {
|
||||
let root: string, db: Database.Database, harness: ReturnType<typeof createTestApp>;
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-login-"));
|
||||
db = createDb(); migrate(db, ALL_MIGRATIONS); harness = createTestApp(db);
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const post = (url: string, body: unknown) => harness.app.request(`/api/bundles/${url}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it.each(LOGIN_FILES)("matches %s only as a complete root or nested path", file => {
|
||||
for (const prefix of ["", "agents/worker/", "project/nested/"]) expect(isSensitivePath(prefix + file)).toBe(true);
|
||||
for (const lookalike of ["auth.json", ".codex/config.toml", file + ".example", file.replaceAll("/", "-"), "prefix" + file]) {
|
||||
expect(isSensitivePath(lookalike), lookalike).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
for (const source of ["agent", "project"] as const) {
|
||||
it.each(LOGIN_FILES)(`create refuses a %s carried through the ${source} tree, without emitting an archive`, async file => {
|
||||
const folder = path.join(root, "source"); const specPath = author(folder);
|
||||
const carried = path.join(root, "carried"); put(carried, "project.yaml", "id: fixture-project\n");
|
||||
put(source === "agent" ? path.join(folder, "agents/worker") : carried, `nested/${file}`);
|
||||
const outputPath = path.join(root, "out.rigbundle");
|
||||
const res = await post("create", { specPath, bundleName: "fixture", bundleVersion: "1.0", outputPath,
|
||||
...(source === "project" ? { projectDir: carried } : {}) });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json(); expectLoginMessage(body.error, file);
|
||||
expect(fs.existsSync(outputPath)).toBe(false); expect(fs.existsSync(outputPath + ".sha256")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("the existing shared verifier names every login file and its remedy", async () => {
|
||||
const f = await existingArchive(root);
|
||||
expect(() => computeIntegrity(f.staging, integrityFs)).toThrow(/harness login file/i);
|
||||
const result = verifyIntegrity(f.staging, { integrity: f.integrity } as never, integrityFs);
|
||||
expect(result.passed).toBe(false);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(result.errors.join("\n"), file);
|
||||
});
|
||||
|
||||
it("unpack and install refuse a previously valid archive before bootstrap, even with unrelated override flags", async () => {
|
||||
const f = await existingArchive(root);
|
||||
const error = await unpack(f.archive, path.join(root, "unpacked")).then(() => "", error => error.message);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(error, file);
|
||||
const bootstrap = vi.spyOn(harness.bootstrapOrchestrator, "bootstrap");
|
||||
const res = await post("install", { bundlePath: f.archive, plan: true, force: true, skipVersionCheck: true });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json(); for (const file of LOGIN_FILES) expectLoginMessage(body.detail, file);
|
||||
expect(bootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inspection remains diagnostic but reports failed verification with every actionable reason", async () => {
|
||||
const f = await existingArchive(root);
|
||||
const res = await post("inspect", { bundlePath: f.archive }); expect(res.status).toBe(200);
|
||||
const body = await res.json(); expect(body.digestValid).toBe(true); expect(body.integrityResult.passed).toBe(false);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(body.integrityResult.errors.join("\n"), file);
|
||||
expect(body.behaviour.identity.integrity.filesVerified).toBe(false);
|
||||
});
|
||||
|
||||
it("ordinary auth.json and non-login harness files still create, verify and unpack unchanged", async () => {
|
||||
const files = ["auth.json", "other/auth.json", ".codex/config.toml", ".claude/settings.json", ".pi/agent/settings.json", ".codex-auth.json"];
|
||||
const f = await existingArchive(root, files);
|
||||
const integrity = computeIntegrity(f.staging, integrityFs); expect(integrity).toEqual(f.integrity);
|
||||
const result = verifyIntegrity(f.staging, { integrity: f.integrity } as never, integrityFs); expect(result.passed).toBe(true);
|
||||
const extracted = path.join(root, "clean"); await unpack(f.archive, extracted);
|
||||
for (const file of files) expect(fs.readFileSync(path.join(extracted, file))).toEqual(fs.readFileSync(path.join(f.staging, file)));
|
||||
});
|
||||
|
||||
it.each(["agent", "project"])("explicit create records only actual exact login files from the %s tree; consumers need no repeated flag", async source => {
|
||||
const folder = path.join(root, "source"); const specPath = author(folder);
|
||||
const carried = path.join(root, "carried"); put(carried, "project.yaml", "id: fixture-project\n");
|
||||
for (const file of LOGIN_FILES) put(source === "agent" ? path.join(folder, "agents/worker") : carried, `nested/${file}`);
|
||||
const outputPath = path.join(root, "intentional.rigbundle");
|
||||
const created = await post("create", { specPath, outputPath, bundleName: "fixture", bundleVersion: "1.0", includeLoginFiles: true,
|
||||
...(source === "project" ? { projectDir: carried } : {}) });
|
||||
expect(created.status).toBe(201); const creation = await created.json();
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(creation.warning ?? "", file);
|
||||
const extracted = path.join(root, "intentional");
|
||||
const unpacked = await unpack(outputPath, extracted);
|
||||
const manifest = parse(fs.readFileSync(path.join(extracted, "bundle.yaml"), "utf8"));
|
||||
const expected = LOGIN_FILES.map(file => `${source === "agent" ? "agents/worker/nested" : "project/nested"}/${file}`).sort();
|
||||
expect(manifest.included_login_files).toEqual(expected);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(JSON.stringify(unpacked), file);
|
||||
const inspected = await (await post("inspect", { bundlePath: outputPath })).json();
|
||||
expect(inspected.integrityResult.passed).toBe(true); expect(inspected.manifest.includedLoginFiles).toEqual(expected);
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(inspected.integrityResult.warnings.join("\n"), file);
|
||||
const bootstrap = vi.spyOn(harness.bootstrapOrchestrator, "bootstrap").mockResolvedValue({ status: "planned", runId: "fixture", stages: [] } as never);
|
||||
const installed = await post("install", { bundlePath: outputPath, plan: true });
|
||||
expect(installed.status).toBe(200); expect(bootstrap).toHaveBeenCalledOnce();
|
||||
const plan = await installed.json(); for (const file of LOGIN_FILES) expectLoginMessage(plan.warnings.join("\n"), file);
|
||||
});
|
||||
|
||||
it.each([null, true, "*", ["*"], ["agents/*/.codex/auth.json"], ["agents/worker/.codex"], ["../.codex/auth.json"], ["/.codex/auth.json"], [".codex/auth.json", ".codex/auth.json"]])(
|
||||
"rejects a malformed or non-exact login record: %j", async record => {
|
||||
const f = await existingArchive(root, undefined, record);
|
||||
const result = verifyIntegrity(f.staging, { integrity: f.integrity, includedLoginFiles: record } as never, integrityFs);
|
||||
expect(result.passed).toBe(false); expect(result.errors.join("\n")).toMatch(/included_login_files/);
|
||||
await expect(unpack(f.archive, path.join(root, "bad-record"))).rejects.toThrow(/included_login_files/);
|
||||
const inspected = await (await post("inspect", { bundlePath: f.archive })).json();
|
||||
expect(inspected.error || inspected.integrityResult?.errors.join("\n")).toMatch(/included_login_files/);
|
||||
});
|
||||
|
||||
it.each([1, 2] as const)("v%s inspect keeps a malformed login record diagnostic", async schemaVersion => {
|
||||
const f = await existingArchive(root, undefined, true, schemaVersion);
|
||||
const response = await post("inspect", { bundlePath: f.archive });
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.digestValid).toBe(true);
|
||||
expect(body.error).toMatch(/included_login_files/);
|
||||
});
|
||||
|
||||
it("an additional unrecorded login file still fails every consumer", async () => {
|
||||
const paths = LOGIN_FILES.map(file => `agents/worker/${file}`);
|
||||
const f = await existingArchive(root, paths, paths.slice(0, 2));
|
||||
const result = verifyIntegrity(f.staging, { integrity: f.integrity, includedLoginFiles: paths.slice(0, 2) } as never, integrityFs);
|
||||
expect(result.passed).toBe(false); expectLoginMessage(result.errors.join("\n"), LOGIN_FILES[2]!);
|
||||
await expect(unpack(f.archive, path.join(root, "unlisted"))).rejects.toThrow(LOGIN_FILES[2]);
|
||||
const installed = await post("install", { bundlePath: f.archive, plan: true, force: true, skipVersionCheck: true });
|
||||
expect(installed.status).toBe(400);
|
||||
const inspected = await (await post("inspect", { bundlePath: f.archive })).json(); expect(inspected.integrityResult.passed).toBe(false);
|
||||
});
|
||||
|
||||
it("the explicit login option does not exempt unrelated sensitive files", async () => {
|
||||
const folder = path.join(root, "source"); const specPath = author(folder);
|
||||
put(folder, "agents/worker/.codex/auth.json"); put(folder, "agents/worker/.env", "SYNTHETIC_ENV_ONLY");
|
||||
const outputPath = path.join(root, "still-refused.rigbundle");
|
||||
const res = await post("create", { specPath, outputPath, bundleName: "fixture", bundleVersion: "1.0", includeLoginFiles: true });
|
||||
expect(res.status).toBe(500); expect(JSON.stringify(await res.json())).toContain(".env"); expect(fs.existsSync(outputPath)).toBe(false);
|
||||
});
|
||||
it("the login record cannot exempt an existing sensitive-path rule", () => {
|
||||
const file = "node_modules/.codex/auth.json";
|
||||
const folder = path.join(root, "other-sensitive"); put(folder, file);
|
||||
expect(() => computeIntegrity(folder, integrityFs, [file])).toThrow(/Sensitive/);
|
||||
});
|
||||
|
||||
it("the actual bootstrap plan carries a credential-bearing notice without another flag", async () => {
|
||||
const paths = LOGIN_FILES.map(file => `agents/worker/${file}`);
|
||||
const f = await existingArchive(root, paths, paths);
|
||||
const result = await harness.bootstrapOrchestrator.bootstrap({ mode: "plan", sourceRef: f.archive, sourceKind: "rig_bundle" });
|
||||
expect(result.status).toBe("planned");
|
||||
// The test app's execution adapters are inert; this asserts the shared pre-materialisation consumer.
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(result.warnings.join("\n"), file);
|
||||
});
|
||||
|
||||
it("legacy manifests retain exact records through normalisation, serialization and consumers", async () => {
|
||||
const paths = LOGIN_FILES.map(file => `agents/worker/${file}`);
|
||||
const f = await existingArchive(root, paths, paths, 1);
|
||||
const raw = parse(fs.readFileSync(path.join(f.staging, "bundle.yaml"), "utf8"));
|
||||
expect(validateLegacyBundleManifest(raw).valid).toBe(true);
|
||||
expect(parse(serializeLegacyBundleManifest(normalizeLegacyBundleManifest(raw))).included_login_files).toEqual(paths);
|
||||
const unpacked = await unpack(f.archive, path.join(root, "legacy-unpacked"));
|
||||
for (const file of LOGIN_FILES) expectLoginMessage(unpacked!.warnings.join("\n"), file);
|
||||
const inspected = await (await post("inspect", { bundlePath: f.archive })).json();
|
||||
expect(inspected.integrityResult.passed).toBe(true); expect(inspected.manifest.includedLoginFiles).toEqual(paths);
|
||||
delete raw.included_login_files;
|
||||
put(f.staging, "bundle.yaml", stringify(raw)); await pack(f.staging, f.archive);
|
||||
await expect(unpack(f.archive, path.join(root, "legacy-default"))).rejects.toThrow(/harness login file/);
|
||||
});
|
||||
|
||||
it("the pod resolver retains the declaration and file bytes", async () => {
|
||||
const paths = LOGIN_FILES.map(file => `agents/worker/${file}`);
|
||||
const f = await existingArchive(root, paths, paths);
|
||||
const resolver = new PodBundleSourceResolver(); const resolved = await resolver.resolve(f.archive);
|
||||
try {
|
||||
expect(resolved.manifest.includedLoginFiles).toEqual(paths);
|
||||
for (const file of paths) expect(fs.readFileSync(path.join(resolved.tempDir, file))).toEqual(fs.readFileSync(path.join(f.staging, file)));
|
||||
} finally { resolver.cleanup(resolved.tempDir); }
|
||||
});
|
||||
|
||||
it("a declaration does not waive missing content or hash checks", async () => {
|
||||
const paths = LOGIN_FILES.map(file => `agents/worker/${file}`);
|
||||
const f = await existingArchive(root, paths, paths);
|
||||
put(f.staging, paths[0]!, "SYNTHETIC_CHANGED_ONLY");
|
||||
fs.unlinkSync(path.join(f.staging, paths[1]!));
|
||||
const checked = verifyIntegrity(f.staging, { integrity: f.integrity, includedLoginFiles: paths } as never, integrityFs);
|
||||
expect(checked.passed).toBe(false); expect(checked.mismatches).toContain(paths[0]); expect(checked.missing).toContain(paths[1]);
|
||||
const invalid = verifyIntegrity(f.staging, { integrity: f.integrity, includedLoginFiles: ["missing/.codex/auth.json"] } as never, integrityFs);
|
||||
expect(invalid.passed).toBe(false); expect(invalid.errors.join("\n")).toContain("not covered by integrity");
|
||||
for (const validator of [validateLegacyBundleManifest, validatePodBundleManifest]) {
|
||||
expect(validator({ included_login_files: ["../.codex/auth.json"] }).errors.join("\n")).toContain("included_login_files");
|
||||
}
|
||||
});
|
||||
|
||||
it("legacy create defaults to refusal and the explicit flag records its actual paths", async () => {
|
||||
const folder = path.join(root, "legacy-source");
|
||||
put(folder, "rig.yaml", 'schema_version: 1\nname: legacy\nversion: "1.0"\nnodes:\n - id: dev\n runtime: claude-code\n package_refs: ["./pkg"]\nedges: []\n');
|
||||
put(folder, "pkg/package.yaml", 'schema_version: 1\nname: pkg\nversion: "1.0.0"\nsummary: Fixture\ncompatibility:\n runtimes: [claude-code]\nexports:\n skills:\n - source: skills/demo\n name: demo\n supported_scopes: [project_shared]\n default_scope: project_shared\n');
|
||||
put(folder, "pkg/skills/demo/SKILL.md", "# Synthetic fixture");
|
||||
for (const file of LOGIN_FILES) put(folder, `pkg/nested/${file}`);
|
||||
const outputPath = path.join(root, "legacy.rigbundle");
|
||||
const body = { specPath: path.join(folder, "rig.yaml"), outputPath, bundleName: "fixture", bundleVersion: "1.0" };
|
||||
expect((await post("create", { ...body, includeLoginFiles: "true" })).status).toBe(500);
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
const created = await post("create", { ...body, includeLoginFiles: true }); expect(created.status).toBe(201);
|
||||
const extracted = path.join(root, "legacy-created"); await unpack(outputPath, extracted);
|
||||
const manifest = parse(fs.readFileSync(path.join(extracted, "bundle.yaml"), "utf8"));
|
||||
expect(manifest.included_login_files).toEqual(LOGIN_FILES.map(file => `packages/pkg/nested/${file}`).sort());
|
||||
});
|
||||
|
||||
it("ordinary install warnings retain their existing order and duplicates", async () => {
|
||||
const f = await existingArchive(root, ["auth.json"]);
|
||||
vi.spyOn(harness.bootstrapOrchestrator, "bootstrap").mockResolvedValue({ status: "completed", runId: "fixture", rigId: "fixture-rig", stages: [], warnings: ["ordinary warning", "ordinary warning"] } as never);
|
||||
vi.spyOn(harness.bootstrapOrchestrator, "routeBundleContents").mockResolvedValue({} as never);
|
||||
const response = await post("install", { bundlePath: f.archive, targetRoot: path.join(root, "inert-target") });
|
||||
expect(response.status).toBe(201);
|
||||
expect((await response.json()).warnings).toEqual(["ordinary warning", "ordinary warning"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { it, expect, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createTestApp } from "./helpers/test-app.js";
|
||||
|
||||
it("captures a fixed-time ordinary v1 create archive", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-01-02T03:04:05.000Z"));
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ordinary-v1-"));
|
||||
const db = createDb();
|
||||
try {
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
fs.mkdirSync(path.join(root, "pkg/skills/demo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "rig.yaml"), 'schema_version: 1\nname: ordinary-v1\nversion: "1.0"\nnodes:\n - id: dev\n runtime: claude-code\n package_refs: ["./pkg"]\nedges: []\n', { mode: 0o644 });
|
||||
fs.writeFileSync(path.join(root, "pkg/package.yaml"), 'schema_version: 1\nname: pkg\nversion: "1.0.0"\nsummary: Fixture\ncompatibility:\n runtimes: [claude-code]\nexports:\n skills:\n - source: skills/demo\n name: demo\n supported_scopes: [project_shared]\n default_scope: project_shared\n', { mode: 0o644 });
|
||||
fs.writeFileSync(path.join(root, "pkg/skills/demo/SKILL.md"), "# Ordinary fixture\n", { mode: 0o644 });
|
||||
const archive = path.join(root, "ordinary.rigbundle");
|
||||
const res = await createTestApp(db).app.request("/api/bundles/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ specPath: path.join(root, "rig.yaml"), outputPath: archive, bundleName: "ordinary-v1", bundleVersion: "1.0" }) });
|
||||
const response = await res.json();
|
||||
expect(res.status, JSON.stringify(response)).toBe(201);
|
||||
const bytes = fs.readFileSync(archive);
|
||||
fs.copyFileSync(archive, process.env.BUNDLE_ARCHIVE_CAPTURE!);
|
||||
fs.writeFileSync(process.env.BUNDLE_ARCHIVE_CAPTURE! + ".json", JSON.stringify({ bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), response }, null, 2));
|
||||
} finally { db.close(); vi.useRealTimers(); fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { stringify } from "yaml";
|
||||
import { expect } from "vitest";
|
||||
import { pack } from "../../src/domain/bundle-archive.js";
|
||||
import type { IntegrityFsOps } from "../../src/domain/bundle-integrity.js";
|
||||
|
||||
export const LOGIN_FILES = [".claude/.credentials.json", ".codex/auth.json", ".pi/agent/auth.json"];
|
||||
export const MARKER = "SYNTHETIC_LOGIN_FIXTURE_ONLY";
|
||||
export const RIG = `version: "0.2"
|
||||
name: login-fixture
|
||||
pods:
|
||||
- id: dev
|
||||
label: Dev
|
||||
members:
|
||||
- { id: worker, runtime: codex, agent_ref: "local:agents/worker", profile: default, cwd: . }
|
||||
edges: []
|
||||
edges: []
|
||||
`;
|
||||
export const AGENT = 'name: worker\nversion: "1.0"\nprofiles:\n default: {}\n';
|
||||
|
||||
export function put(root: string, relative: string, content = MARKER): void {
|
||||
const file = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content, { mode: 0o644 });
|
||||
}
|
||||
|
||||
export function author(root: string): string {
|
||||
put(root, "rig.yaml", RIG);
|
||||
put(root, "agents/worker/agent.yaml", AGENT);
|
||||
return path.join(root, "rig.yaml");
|
||||
}
|
||||
|
||||
export function walk(root: string): string[] {
|
||||
return fs.readdirSync(root, { withFileTypes: true }).flatMap(entry => entry.isDirectory()
|
||||
? walk(path.join(root, entry.name)).map(file => `${entry.name}/${file}`) : [entry.name]).sort();
|
||||
}
|
||||
|
||||
export const integrityFs: IntegrityFsOps = {
|
||||
readFile: file => fs.readFileSync(file, "utf8"), readFileBuffer: file => fs.readFileSync(file),
|
||||
writeFile: (file, content) => fs.writeFileSync(file, content), exists: fs.existsSync, walkFiles: walk,
|
||||
};
|
||||
|
||||
/** A correctly hashed archive made before the login-path policy existed; no new create check is bypassed. */
|
||||
export async function existingArchive(root: string, paths = LOGIN_FILES.map(file => `agents/worker/${file}`), recorded?: unknown, schemaVersion: 1 | 2 = 2) {
|
||||
const staging = path.join(root, "staging");
|
||||
author(staging);
|
||||
for (const file of paths) put(staging, file);
|
||||
const integrity = { algorithm: "sha256" as const, files: Object.fromEntries(walk(staging)
|
||||
.map(file => [file, createHash("sha256").update(fs.readFileSync(path.join(staging, file))).digest("hex")])) };
|
||||
const manifest = { schema_version: schemaVersion, name: "login-fixture", version: "1.0", created_at: "2026-01-01T00:00:00Z",
|
||||
rig_spec: "rig.yaml", ...(recorded !== undefined ? { included_login_files: recorded } : {}), ...(schemaVersion === 1 ? { packages: [{ name: "worker", version: "1.0", path: "agents/worker", original_source: "local:agents/worker" }] } : {}), agents: [{ name: "worker", version: "1.0", path: "agents/worker",
|
||||
original_ref: "local:agents/worker", hash: "0".repeat(64), import_entries: [] }], integrity };
|
||||
put(staging, "bundle.yaml", stringify(manifest));
|
||||
const archive = path.join(root, "existing.rigbundle");
|
||||
await pack(staging, archive);
|
||||
return { archive, staging, integrity };
|
||||
}
|
||||
|
||||
export function expectLoginMessage(message: string, file: string): void {
|
||||
expect(message).toContain(file);
|
||||
expect(message).toMatch(/harness login file/i);
|
||||
expect(message).toMatch(/anyone with the bundle could read/i);
|
||||
expect(message).toMatch(/remove it from the agent folder/i);
|
||||
expect(message).toMatch(/carried project/i);
|
||||
expect(message).toMatch(/build.*again/i);
|
||||
expect(message).toContain("--include-login-files");
|
||||
expect(message).toContain("only if recipients should have this login");
|
||||
expect(message).toContain("internal test bundle");
|
||||
expect(message).toContain("its owner agrees");
|
||||
expect(message).not.toContain(MARKER);
|
||||
}
|
||||
@@ -157,6 +157,13 @@ export function BundleInspector() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.integrityResult.errors.map(message => (
|
||||
<p key={message} className="text-destructive text-body-md mb-spacing-2">{message}</p>
|
||||
))}
|
||||
{(result.integrityResult.warnings ?? []).map(message => (
|
||||
<p key={message} className="text-warning text-body-md mb-spacing-2">{message}</p>
|
||||
))}
|
||||
|
||||
{/* Per-file integrity */}
|
||||
{result.integrityResult && (
|
||||
<>
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface InspectResult {
|
||||
compatibility?: InspectCompatibility;
|
||||
};
|
||||
digestValid: boolean;
|
||||
integrityResult: { passed: boolean; mismatches: string[]; missing: string[]; extra: string[]; errors: string[] };
|
||||
integrityResult: { passed: boolean; mismatches: string[]; missing: string[]; extra: string[]; errors: string[]; warnings?: string[] };
|
||||
}
|
||||
|
||||
export interface BundleInstallResult {
|
||||
|
||||
@@ -245,3 +245,16 @@ describe("Bundle activity feed events", () => {
|
||||
expect(eventSummary(event)).toContain("bundled");
|
||||
});
|
||||
});
|
||||
|
||||
// Server diagnostics must stay visible; a FAIL/PASS badge alone cannot explain the login-file policy.
|
||||
it.each(["errors", "warnings"] as const)("shows login-file %s with the path and both remedies", async field => {
|
||||
const message = "Harness login file 'agents/worker/.codex/auth.json': anyone with the bundle could read it. Remove it from the agent folder or carried project and build again, or, only if recipients should have this login (for example, your own internal test bundle) and its owner agrees, recreate with --include-login-files.";
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ ...INSPECT_RESPONSE,
|
||||
integrityResult: { ...INSPECT_RESPONSE.integrityResult, passed: field !== "errors", [field]: [message] },
|
||||
}) });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><BundleInspector /></QueryClientProvider>);
|
||||
fireEvent.change(screen.getByTestId("bundle-path-input"), { target: { value: "/tmp/synthetic.rigbundle" } });
|
||||
fireEvent.click(screen.getByTestId("inspect-btn"));
|
||||
await waitFor(() => expect(screen.getByText(message)).toBeTruthy());
|
||||
});
|
||||
Reference in new issue
Block a user