From 09d78c5227397d1a7bb5dd1a8b79718c41ef1d66 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 28 Aug 2026 22:58:57 -0400 Subject: [PATCH 1/4] fix: improve first-run recovery for 0.3.1 --- README.md | 2 +- RELEASE_NOTES.md | 12 +++++++ docs/releases/2026-08-28.md | 7 ++++ docs/releases/OPERATOR-PACKET-v0.3.1.md | 31 +++++++++++++++++ docs/releases/README.md | 3 ++ package-lock.json | 4 +-- package.json | 3 +- src/commands/login.ts | 27 +++++++++++++-- src/main.ts | 20 +++++++++++ src/version.ts | 2 +- test/login.test.ts | 44 +++++++++++++++++++++++++ test/main_entrypoint.test.ts | 15 ++++++++- test/release_coherence.test.ts | 22 +++++++------ 13 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 docs/releases/2026-08-28.md create mode 100644 docs/releases/OPERATOR-PACKET-v0.3.1.md diff --git a/README.md b/README.md index 85fb26a7..89272aa6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ presence is not registry availability, and the two can hold different versions. | Install | Version | What you get | |---|---:|---| | npm `latest` | [![npm latest](https://img.shields.io/npm/v/aether-agents?label=&color=14b8a6)](https://www.npmjs.com/package/aether-agents) | Whatever that badge resolves to right now — it tracks the live `latest` dist-tag. | -| `main` source build | **0.3.0** | The 0.3 coding, Ollama, session, review, and ship workflows below. | +| `main` source build | **0.3.1** | The 0.3 coding, Ollama, session, review, and ship workflows below. | > **Confirm your version before following a version-labelled section.** Run > `npm view aether-agents version` for the live `latest` dist-tag, and diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cd0b1fb5..bf822e92 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,15 @@ +# Aether Agent v0.3.1 — first-run recovery + +**August 28, 2026** + +- **Clearer first-run recovery.** The CLI now stops before executing on an + unsupported Node.js runtime and prints the upgrade path. Browser-based sign-in + continues with the displayed URL and code when the system browser cannot open. +- **No feature expansion.** This is a compatibility patch over v0.3.0; it does + not include the later device-runtime work on `main`. + +--- + # Aether Agent v0.3.0 — durable work, verified delivery **Release candidate finalized August 27, 2026** diff --git a/docs/releases/2026-08-28.md b/docs/releases/2026-08-28.md new file mode 100644 index 00000000..61224c65 --- /dev/null +++ b/docs/releases/2026-08-28.md @@ -0,0 +1,7 @@ +# Aether Agent v0.3.1 — first-run recovery + +This patch makes two startup failures recoverable: an unsupported Node.js +runtime explains how to upgrade before command handling begins, and browser +sign-in keeps the displayed URL and code usable when a browser cannot be +opened. It intentionally excludes the later feature work already present on +`main`. diff --git a/docs/releases/OPERATOR-PACKET-v0.3.1.md b/docs/releases/OPERATOR-PACKET-v0.3.1.md new file mode 100644 index 00000000..f270702a --- /dev/null +++ b/docs/releases/OPERATOR-PACKET-v0.3.1.md @@ -0,0 +1,31 @@ +# Operator packet — Aether Agent v0.3.1 + +This packet records a narrowly scoped patch candidate. It is not a release +authorization: the tag, GitHub release, and npm publication remain prohibited +until the final pull-request commit has passed the required hosted checks. + +| | | +|---|---| +| Package | `aether-agents` | +| Proposed tag | `v0.3.1` | +| Release line base | `fb7ceb9c78fdacf84a864a07523185fb4387f531` (`v0.3.0`) | +| Candidate branch | `codex/patch-release-031` | +| Release scope | First-run recovery fixes only: early Node.js-version guidance and browser-login fallback messaging. | +| Deliberately excluded | Later feature work on `main`, including the device-runtime surface and other unreleased command changes. | +| Selected-change provenance | Equivalent, selectively applied portions of `44f1223f62e83c14e8d7741d8590ce61721e3fab` (first-run hardening), reviewed against v0.3.0. | +| Required qualification | Exact-head Windows, Linux, CodeQL, supply-chain, documentation, package, and release-truth checks; exact-package install canaries; then the release workflow’s trusted publication and provenance attestation. | +| Rollback | Restore npm `latest` to `0.3.0` if a post-publication regression is confirmed; do not unpublish a released version. | + +## Version decision + +`0.3.0 → 0.3.1` is appropriate because the candidate changes recovery +messaging and failure handling without adding a public feature or changing a +supported command contract. The complete `v0.3.0...main` range is not a patch +candidate and is intentionally outside this release line. + +## Qualification record + +The final merge commit, workflow run IDs, artifact digests, package integrity, +SBOM, provenance attestation, and platform canary results are added only from +the corresponding immutable hosted evidence. Until then this candidate is not +publishable. diff --git a/docs/releases/README.md b/docs/releases/README.md index e51d7d46..805c8c1f 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -12,6 +12,9 @@ manifest, the evidence gathered, and the founder-owned steps that publish it. ## Index +- [2026-08-28](2026-08-28.md) — **v0.3.1** patch candidate: actionable + Node.js-version recovery and browser-login fallback. Packet: + [OPERATOR-PACKET-v0.3.1.md](OPERATOR-PACKET-v0.3.1.md). - [v0.3.0 release body](RELEASE-BODY-v0.3.0.md) — publish-ready user-facing notes for the GitHub release. It deliberately makes no pre-publication npm claim; the dated publication record is added only after registry verification. diff --git a/package-lock.json b/package-lock.json index af3def03..2eb622fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "bin": { "aether": "dist/src/main.js", diff --git a/package.json b/package.json index 44e13944..6d582830 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.1", "description": "Open-source terminal coding agent — runs on hosted frontier models (Claude, GPT, DeepSeek, Kimi, Gemma) or fully offline via Ollama. Edits your code, runs your tests, and verifies the result.", "type": "module", "bin": { @@ -33,6 +33,7 @@ "scripts": { "build": "tsc -p tsconfig.json && node dist/scripts/copy-skill-assets.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "npm run typecheck", "dev": "npm run build && node dist/src/main.js", "start": "node dist/src/main.js", "test": "npm run build && node --test --test-isolation=none \"dist/test/**/*.test.js\"", diff --git a/src/commands/login.ts b/src/commands/login.ts index a4b5e30b..ed58b7d0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,8 @@ import type { AppContext } from "../core/context.js"; import { loginWithPassword } from "../core/auth.js"; import { LOGOUT_PATH } from "../core/transport.js"; import { requestDeviceCode, pollForToken } from "../core/device.js"; -import { openBrowser } from "../core/browser.js"; +import { openBrowserChecked } from "../core/browser.js"; +import type { OpenOutcome } from "../core/opener.js"; import { theme } from "../ui/theme.js"; import { errorHint, errorMessage } from "../core/errors.js"; import { formatErrorLine } from "../ui/error_line.js"; @@ -25,6 +26,12 @@ export interface LoginOpts { noBrowser?: boolean; } +export interface LoginDependencies { + openBrowser: (url: string) => OpenOutcome; +} + +const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserChecked }; + /** After a successful login, flag a shell-level AETHER_TOKEN: it is re-read by * every NEW process and would shadow the token just stored — the classic * "login succeeded but the next command 401s" trap (PR #47). */ @@ -43,7 +50,11 @@ function warnEnvTokenShadow(): void { } } -export async function cmdLogin(ctx: AppContext, opts: LoginOpts): Promise { +export async function cmdLogin( + ctx: AppContext, + opts: LoginOpts, + dependencies: LoginDependencies = LOGIN_DEPENDENCIES, +): Promise { // 1. Direct token. if (opts.token) { await ctx.tokens.set(opts.token); @@ -99,7 +110,17 @@ export async function cmdLogin(ctx: AppContext, opts: LoginOpts): Promise (typeof v === "string" ? v : undefined); +export const MINIMUM_NODE_MAJOR = 24; + +/** Make an unsupported runtime actionable before it reaches a later syntax or API failure. */ +export function unsupportedNodeMessage(version: string): string | null { + const majorText = version.trim().replace(/^v/i, "").split(".", 1)[0] ?? ""; + const major = Number.parseInt(majorText, 10); + if (Number.isInteger(major) && major >= MINIMUM_NODE_MAJOR) return null; + const found = version.trim() || "unknown"; + return ( + `Aether Agent requires Node.js >= ${MINIMUM_NODE_MAJOR} (found ${found}). ` + + "Upgrade Node, reopen your terminal, then run: aether --version\n" + + "https://nodejs.org/en/download" + ); +} + // Real top-level subcommand names, sourced from the union of the switch's // registry and the dispatch table (cli_registry.ts) — the same registries the // dispatch is cross-checked against — so this can never drift from the actual @@ -58,6 +73,11 @@ function suggestTopLevel(token: string): string | null { } export async function main(argv: string[]): Promise { + const nodeError = unsupportedNodeMessage(process.versions.node); + if (nodeError) { + process.stderr.write(`${errTheme.red("✗")} ${nodeError}\n`); + return 1; + } const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, diff --git a/src/version.ts b/src/version.ts index b3510d2e..8d8f1eee 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Single source of the CLI version (kept in lockstep with package.json). -export const VERSION = "0.3.0"; +export const VERSION = "0.3.1"; diff --git a/test/login.test.ts b/test/login.test.ts index e3ea4870..04f1b81b 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -192,3 +192,47 @@ test("cmdLogin (device-code flow): a denied authorization still gets the styled stderr.restore(); } }); + +test("cmdLogin reports an unavailable browser and continues with the displayed device URL", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const value = String(url); + if (value.includes("/auth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dc-browser", + user_code: "BROW-SER", + verification_uri: "https://x.example/device", + verification_uri_complete: "https://x.example/device?code=BROW-SER", + interval: 0, + expires_in: 30, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (value.includes("/auth/device/token")) { + return new Response(JSON.stringify({ access_token: "aek_browser_fallback" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch in test: ${value}`); + }) as typeof fetch; + const ctx = fakeCtxWithApi(); + const realStdoutWrite = process.stdout.write.bind(process.stdout); + let stdout = ""; + process.stdout.write = ((chunk: unknown) => ((stdout += String(chunk)), true)) as typeof process.stdout.write; + const stderr = captureStderr(); + try { + const code = await cmdLogin(ctx, {}, { + openBrowser: () => ({ status: "unavailable", detail: "synthetic headless session" }), + }); + assert.equal(code, 0); + assert.match(stdout, /https:\/\/x\.example\/device/); + assert.match(stdout, /BROW-SER/); + assert.match(stderr.text(), /Browser was not opened \(unavailable\)/); + assert.match(stderr.text(), /aether auth login --no-browser/); + assert.equal(await ctx.tokens.get(), "aek_browser_fallback"); + } finally { + globalThis.fetch = realFetch; + process.stdout.write = realStdoutWrite; + stderr.restore(); + } +}); diff --git a/test/main_entrypoint.test.ts b/test/main_entrypoint.test.ts index 8fd76b2b..eda1abe7 100644 --- a/test/main_entrypoint.test.ts +++ b/test/main_entrypoint.test.ts @@ -2,7 +2,20 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { isMainInvocation } from "../src/main.js"; +import { isMainInvocation, MINIMUM_NODE_MAJOR, unsupportedNodeMessage } from "../src/main.js"; + +test("unsupported Node versions provide one copyable recovery path", () => { + assert.equal(MINIMUM_NODE_MAJOR, 24); + assert.equal(unsupportedNodeMessage("24.0.0"), null); + assert.equal(unsupportedNodeMessage("v26.1.0"), null); + for (const version of ["23.9.0", "v20.0.0", "", "not-a-version"]) { + const message = unsupportedNodeMessage(version); + assert.ok(message); + assert.match(message, /requires Node\.js >= 24/); + assert.match(message, /reopen your terminal.*aether --version/s); + assert.match(message, /nodejs\.org\/en\/download/); + } +}); test("installed npm bin symlink is recognized as the CLI entrypoint", () => { const shim = resolve("virtual", "bin", "aether"); diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index 2b9715b2..ac597551 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -79,7 +79,7 @@ function onlyPacketRow(rows: PacketRows, label: string): string { return values[0]!; } -function assertPacketProvenance(packet: string): PacketRows { +function assertV030PacketProvenance(packet: string): PacketRows { const rows = parsePacketRows(packet); const baseLabels = [...rows.keys()].filter((label) => /base/i.test(label)).sort(); assert.deepEqual( @@ -185,12 +185,14 @@ test("the dated release log has an entry for this version, and the index links i } }); -test("the operator packet separates final-candidate, pre-merge, and historical archives", () => { +test("the current operator packet identifies the candidate and v0.3.0 retains its historical provenance", () => { const path = join(root, "docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); assert.ok(existsSync(path), `no docs/releases/OPERATOR-PACKET-v${VERSION}.md`); - const packet = readFileSync(path, "utf8"); - assert.ok(packet.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); - assertPacketProvenance(packet); + const current = readFileSync(path, "utf8"); + assert.ok(current.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); + assert.match(current, new RegExp(`\\| Proposed tag \\| \\`v${VERSION}\\` \\|`)); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); + assertV030PacketProvenance(packet); assert.match(packet, /\| Qualified final-candidate archive \| `aether-agents-0\.3\.0\.tgz` — 835,957 bytes packed \/ 3,688,966 unpacked \/ 618 entries at `1271457\.\.\.`;/); assert.match(packet, /\| Qualified final-candidate archive sha256 \| `6176172deb15eea57519408d93f23b3fac8ab5e2b2e541adddc34b4e5fb4c33d` \|/); assert.match(packet, /\| Qualified pre-merge archive \| `aether-agents-0\.3\.0\.tgz` — 835,957 bytes packed \/ 3,688,966 unpacked \/ 618 entries at `3cf44bb\.\.\.`;/); @@ -201,7 +203,7 @@ test("the operator packet separates final-candidate, pre-merge, and historical a }); test("the operator packet fails closed on contradictory release-evidence mutations", () => { - const packet = read("docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); const mutations = [ [ "duplicate reconciled base", @@ -236,16 +238,16 @@ test("the operator packet fails closed on contradictory release-evidence mutatio for (const [name, mutant] of mutations) { assert.notEqual(mutant, packet, `${name} mutation did not alter the packet fixture`); - assert.throws(() => assertPacketProvenance(mutant), name); + assert.throws(() => assertV030PacketProvenance(mutant), name); } }); test( - "the operator packet's final-candidate manifest keeps matching the package membership", + "the v0.3.0 operator packet's final-candidate manifest keeps matching the package membership", { timeout: 120_000 }, () => { - const packet = read("docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); - assertPacketProvenance(packet); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); + assertV030PacketProvenance(packet); const packed = currentPackReport(); const header = /\| Current exact-head dry run \| ([\d,]+) entries \/ ([\d,]+) workflows \|/.exec(packet); assert.ok(header, "the operator packet has no parseable current dry-run summary"); From d4128023d8ea7d6e11d821780b63aec7248c0618 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 28 Aug 2026 23:17:29 -0400 Subject: [PATCH 2/4] fix: verify browser launcher outcome --- docs/releases/OPERATOR-PACKET-v0.3.1.md | 25 ++++++++++++++ src/commands/login.ts | 8 ++--- src/core/browser.ts | 8 ++++- src/core/opener.ts | 44 ++++++++++++++++++++++++- test/opener.test.ts | 16 +++++++++ test/release_coherence.test.ts | 15 ++++++++- 6 files changed, 109 insertions(+), 7 deletions(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.1.md b/docs/releases/OPERATOR-PACKET-v0.3.1.md index f270702a..b1f9d03c 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.1.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.1.md @@ -14,6 +14,9 @@ until the final pull-request commit has passed the required hosted checks. | Deliberately excluded | Later feature work on `main`, including the device-runtime surface and other unreleased command changes. | | Selected-change provenance | Equivalent, selectively applied portions of `44f1223f62e83c14e8d7741d8590ce61721e3fab` (first-run hardening), reviewed against v0.3.0. | | Required qualification | Exact-head Windows, Linux, CodeQL, supply-chain, documentation, package, and release-truth checks; exact-package install canaries; then the release workflow’s trusted publication and provenance attestation. | +| Archive evidence | Pending exact-head hosted qualification — no v0.3.1 tarball or checksum exists yet. | +| Package manifest | Pending exact-head `npm pack --dry-run` — no v0.3.1 entry count or byte total is asserted before that evidence exists. | +| Provenance evidence | Pending the trusted-publishing workflow — no v0.3.1 provenance attestation exists yet. | | Rollback | Restore npm `latest` to `0.3.0` if a post-publication regression is confirmed; do not unpublish a released version. | ## Version decision @@ -29,3 +32,25 @@ The final merge commit, workflow run IDs, artifact digests, package integrity, SBOM, provenance attestation, and platform canary results are added only from the corresponding immutable hosted evidence. Until then this candidate is not publishable. + +## Commands retained without an explicit release-note invocation + +These existing visible commands are intentionally retained without adding a +new v0.3.1 announcement. The named exemptions are reviewed at each release so +the patch does not silently add a command surface: + +- `aether help` +- `aether chat` +- `aether run` +- `aether agents` +- `aether github` +- `aether vault` +- `aether workflow` +- `aether memory` +- `aether image` +- `aether video` +- `aether output` +- `aether audit` +- `aether receipt` +- `aether mcp` +- `aether config` diff --git a/src/commands/login.ts b/src/commands/login.ts index ed58b7d0..9e732e9d 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,7 @@ import type { AppContext } from "../core/context.js"; import { loginWithPassword } from "../core/auth.js"; import { LOGOUT_PATH } from "../core/transport.js"; import { requestDeviceCode, pollForToken } from "../core/device.js"; -import { openBrowserChecked } from "../core/browser.js"; +import { openBrowserAwaitLaunch } from "../core/browser.js"; import type { OpenOutcome } from "../core/opener.js"; import { theme } from "../ui/theme.js"; import { errorHint, errorMessage } from "../core/errors.js"; @@ -27,10 +27,10 @@ export interface LoginOpts { } export interface LoginDependencies { - openBrowser: (url: string) => OpenOutcome; + openBrowser: (url: string) => OpenOutcome | Promise; } -const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserChecked }; +const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserAwaitLaunch }; /** After a successful login, flag a shell-level AETHER_TOKEN: it is re-read by * every NEW process and would shadow the token just stored — the classic @@ -113,7 +113,7 @@ export async function cmdLogin( if (opts.noBrowser) { process.stdout.write("Browser not opened (--no-browser); use the URL and code above.\n"); } else { - const opened = dependencies.openBrowser(code.verification_uri_complete); + const opened = await dependencies.openBrowser(code.verification_uri_complete); if (opened.status !== "spawned") { process.stderr.write( `⚠ Browser was not opened (${opened.status}). Use the URL and code above, ` + diff --git a/src/core/browser.ts b/src/core/browser.ts index 02f56e8a..803c53a2 100644 --- a/src/core/browser.ts +++ b/src/core/browser.ts @@ -8,7 +8,7 @@ // `cmd /c start "" ` on Windows, which handed the URL to the command // interpreter as a token. -import { openTarget, type OpenOutcome } from "./opener.js"; +import { openTarget, openTargetChecked, type OpenOutcome } from "./opener.js"; /** Open `url` in the default browser. Never throws. */ export function openBrowser(url: string): void { @@ -19,3 +19,9 @@ export function openBrowser(url: string): void { export function openBrowserChecked(url: string): OpenOutcome { return openTarget(url); } + +/** Wait for the initial OS launcher outcome when an interactive recovery path + * needs to tell the user whether the browser was actually started. */ +export function openBrowserAwaitLaunch(url: string): Promise { + return openTargetChecked(url); +} diff --git a/src/core/opener.ts b/src/core/opener.ts index 937027e6..469344ec 100644 --- a/src/core/opener.ts +++ b/src/core/opener.ts @@ -183,7 +183,9 @@ export function openTarget(target: string, options: OpenOptions = {}): OpenOutco detached: true, shell: false, }); - // ENOENT (no xdg-open on a minimal container) arrives asynchronously. + // This synchronous API only reports whether spawning was attempted. Callers + // that need to distinguish a missing launcher from a launched browser must + // use openTargetChecked below, which waits for spawn or error. child.on("error", () => {}); child.unref(); return plan; @@ -196,3 +198,43 @@ export function openTarget(target: string, options: OpenOptions = {}): OpenOutco }; } } + +/** + * Open a target and wait until the OS launcher either starts or reports its + * initial launch failure. This still does not wait for the browser itself: + * once the launcher emits `spawn`, it is detached and can outlive the CLI. + */ +export async function openTargetChecked(target: string, options: OpenOptions = {}): Promise { + const plan = planOpen(target, options); + if (plan.status !== "spawned") return plan; + + const launcher = options.spawnFn ?? spawn; + try { + const child = launcher(plan.executable!, [...plan.args!], { + stdio: "ignore", + detached: true, + shell: false, + }); + return await new Promise((resolve) => { + child.once("error", (err) => { + resolve({ + status: "spawn-error", + executable: plan.executable, + args: plan.args, + detail: err instanceof Error ? err.message : "opener could not be launched", + }); + }); + child.once("spawn", () => { + child.unref(); + resolve(plan); + }); + }); + } catch (err) { + return { + status: "spawn-error", + executable: plan.executable, + args: plan.args, + detail: err instanceof Error ? err.message : "opener could not be launched", + }; + } +} diff --git a/test/opener.test.ts b/test/opener.test.ts index 4715f87b..1c416336 100644 --- a/test/opener.test.ts +++ b/test/opener.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +8,7 @@ import { isAllowedUrl, looksLikeUrl, openTarget, + openTargetChecked, planOpen, resolveOpenCommand, } from "../src/core/opener.js"; @@ -228,6 +230,20 @@ test("a spawn failure is reported, not thrown", () => { assert.match(outcome.detail, /ENOENT/); }); +test("openTargetChecked reports a real asynchronous ENOENT from a missing Linux launcher", async () => { + const outcome = await openTargetChecked("https://example.invalid/device", { + platform: "linux", + env: DESKTOP_ENV, + // Keep the production xdg-open selection, but replace this test process's + // executable with one guaranteed not to exist so Node emits the real async + // ENOENT path rather than a synthetic thrown error. + spawnFn: ((_: string, args: readonly string[], options: Record) => + spawn("aether-test-missing-xdg-open", [...args], options as Parameters[2])) as never, + }); + assert.equal(outcome.status, "spawn-error"); + assert.match(outcome.detail, /ENOENT|not found/i); +}); + test("planOpen validates without launching so doctor can report read-only", () => { const dir = sandbox(); const file = join(dir, "a.png"); diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index ac597551..4a8b1653 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -59,6 +59,15 @@ const EXPECTED_HOSTED_WINDOWS_UNPACKED = 3_690_927; const EXPECTED_LOCAL_WINDOWS_UNPACKED = 3_690_927; const EXPECTED_LINUX_PACKED = 835_957; const EXPECTED_WINDOWS_PACKED = 836_234; +const V031_PACKET = { + "Package": "`aether-agents`", + "Proposed tag": "`v0.3.1`", + "Release line base": "`fb7ceb9c78fdacf84a864a07523185fb4387f531` (`v0.3.0`)", + "Candidate branch": "`codex/patch-release-031`", + "Archive evidence": "Pending exact-head hosted qualification — no v0.3.1 tarball or checksum exists yet.", + "Package manifest": "Pending exact-head `npm pack --dry-run` — no v0.3.1 entry count or byte total is asserted before that evidence exists.", + "Provenance evidence": "Pending the trusted-publishing workflow — no v0.3.1 provenance attestation exists yet.", +} as const; function parsePacketRows(packet: string): PacketRows { const rows: PacketRows = new Map(); @@ -190,7 +199,11 @@ test("the current operator packet identifies the candidate and v0.3.0 retains it assert.ok(existsSync(path), `no docs/releases/OPERATOR-PACKET-v${VERSION}.md`); const current = readFileSync(path, "utf8"); assert.ok(current.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); - assert.match(current, new RegExp(`\\| Proposed tag \\| \\`v${VERSION}\\` \\|`)); + const currentRows = parsePacketRows(current); + for (const [label, expected] of Object.entries(V031_PACKET)) { + assert.equal(onlyPacketRow(currentRows, label), expected, `the current packet has the wrong ${label}`); + } + assert.doesNotMatch(current, /aether-agents-0\.3\.0\.tgz|6176172deb15eea57519408d93f23b3fac8ab5e2b2e541adddc34b4e5fb4c33d/); const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); assertV030PacketProvenance(packet); assert.match(packet, /\| Qualified final-candidate archive \| `aether-agents-0\.3\.0\.tgz` — 835,957 bytes packed \/ 3,688,966 unpacked \/ 618 entries at `1271457\.\.\.`;/); From 9482182bc0b9b760318d0c4b7ee9727de2122da2 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 28 Aug 2026 23:18:24 -0400 Subject: [PATCH 3/4] docs: record patch candidate package evidence --- docs/releases/OPERATOR-PACKET-v0.3.1.md | 4 ++-- test/release_coherence.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.1.md b/docs/releases/OPERATOR-PACKET-v0.3.1.md index b1f9d03c..b00d2062 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.1.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.1.md @@ -14,8 +14,8 @@ until the final pull-request commit has passed the required hosted checks. | Deliberately excluded | Later feature work on `main`, including the device-runtime surface and other unreleased command changes. | | Selected-change provenance | Equivalent, selectively applied portions of `44f1223f62e83c14e8d7741d8590ce61721e3fab` (first-run hardening), reviewed against v0.3.0. | | Required qualification | Exact-head Windows, Linux, CodeQL, supply-chain, documentation, package, and release-truth checks; exact-package install canaries; then the release workflow’s trusted publication and provenance attestation. | -| Archive evidence | Pending exact-head hosted qualification — no v0.3.1 tarball or checksum exists yet. | -| Package manifest | Pending exact-head `npm pack --dry-run` — no v0.3.1 entry count or byte total is asserted before that evidence exists. | +| Archive evidence | No release archive exists. The current local Windows `npm pack --dry-run --json --ignore-scripts` reported `aether-agents-0.3.1.tgz`, 837,371 packed bytes, 3,695,485 unpacked bytes, `shasum` `889864b707b5611eefa93461bde5f90757538731`, and integrity `sha512-wJjcMrxHQbdo7RfTciMD7evZJSt2wo1CNsrONNRI8w4zPeBILxNG+2/1WHaH7RVFeXz563gp1T4Bd0abpgTHqw==`; dry-run metadata is not a release checksum or provenance attestation. | +| Package manifest | The same current local dry run reported 618 entries. Hosted exact-head package evidence remains required before release. | | Provenance evidence | Pending the trusted-publishing workflow — no v0.3.1 provenance attestation exists yet. | | Rollback | Restore npm `latest` to `0.3.0` if a post-publication regression is confirmed; do not unpublish a released version. | diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index 4a8b1653..6b6a482f 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -64,8 +64,8 @@ const V031_PACKET = { "Proposed tag": "`v0.3.1`", "Release line base": "`fb7ceb9c78fdacf84a864a07523185fb4387f531` (`v0.3.0`)", "Candidate branch": "`codex/patch-release-031`", - "Archive evidence": "Pending exact-head hosted qualification — no v0.3.1 tarball or checksum exists yet.", - "Package manifest": "Pending exact-head `npm pack --dry-run` — no v0.3.1 entry count or byte total is asserted before that evidence exists.", + "Archive evidence": "No release archive exists. The current local Windows `npm pack --dry-run --json --ignore-scripts` reported `aether-agents-0.3.1.tgz`, 837,371 packed bytes, 3,695,485 unpacked bytes, `shasum` `889864b707b5611eefa93461bde5f90757538731`, and integrity `sha512-wJjcMrxHQbdo7RfTciMD7evZJSt2wo1CNsrONNRI8w4zPeBILxNG+2/1WHaH7RVFeXz563gp1T4Bd0abpgTHqw==`; dry-run metadata is not a release checksum or provenance attestation.", + "Package manifest": "The same current local dry run reported 618 entries. Hosted exact-head package evidence remains required before release.", "Provenance evidence": "Pending the trusted-publishing workflow — no v0.3.1 provenance attestation exists yet.", } as const; From c4a16242ad117a499f91e3b531baa80f1a3ff0bd Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sat, 29 Aug 2026 07:07:10 -0400 Subject: [PATCH 4/4] ci: secure the v0.3 maintenance release line --- .github/workflows/ci.yml | 1 + .github/workflows/release-truth.yml | 1 + .github/workflows/release.yml | 17 ++++++++++++++--- README.md | 2 +- scripts/verify-production.ts | 8 ++++++++ test/production_hardening.test.ts | 14 ++++++++++++++ 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be5480b..c8269122 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - release/0.3 pull_request: branches: - main diff --git a/.github/workflows/release-truth.yml b/.github/workflows/release-truth.yml index a55cf4d7..b9afd7f4 100644 --- a/.github/workflows/release-truth.yml +++ b/.github/workflows/release-truth.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - release/0.3 schedule: - cron: '17 11 * * *' workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f9e2d9e..16b5aa74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,11 +31,22 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Verify release commit belongs to main + - name: Verify release commit belongs to a trusted release line shell: bash run: | - git fetch --no-tags origin main - git merge-base --is-ancestor HEAD origin/main + git fetch --no-tags origin main release/0.3 + + if git merge-base --is-ancestor HEAD origin/main; then + exit 0 + fi + + if [[ "$RELEASE_TAG" =~ ^v0\.3\.[0-9]+$ ]] && \ + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"; then + exit 0 + fi + + echo "Release commit is not on main or the exact v0.3 maintenance head" >&2 + exit 1 - name: Set up Node.js and npm registry uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/README.md b/README.md index 89272aa6..f3addd39 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ aether chat "hello" -> **Requires 0.3.0 or newer**, from npm or from the source build below. +> **Requires 0.3.1 or newer**, from npm or from the source build below. Node.js 24 or newer is required: diff --git a/scripts/verify-production.ts b/scripts/verify-production.ts index b7c1bace..7d8daba1 100644 --- a/scripts/verify-production.ts +++ b/scripts/verify-production.ts @@ -186,6 +186,14 @@ export function validateWorkflowText(name: string, text: string): string[] { if (!/git merge-base --is-ancestor HEAD origin\/main/.test(text)) { errors.push(`${name}: release commit ancestry to main must be verified`); } + if (/origin\/release\/0\.3/.test(text)) { + if (!text.includes('[[ "$RELEASE_TAG" =~ ^v0\\.3\\.[0-9]+$ ]]')) { + errors.push(`${name}: the release/0.3 exception must be restricted to numeric v0.3.x tags`); + } + if (!text.includes('test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"')) { + errors.push(`${name}: v0.3.x publication must equal the exact release/0.3 head`); + } + } if (!/npm install --global[^\n]*--ignore-scripts/.test(text)) { errors.push(`${name}: the exact tarball must pass an install smoke test`); } diff --git a/test/production_hardening.test.ts b/test/production_hardening.test.ts index cd0d8f9b..2a0c5a30 100644 --- a/test/production_hardening.test.ts +++ b/test/production_hardening.test.ts @@ -138,6 +138,20 @@ test("publishing workflow must be event-gated, main-derived, and install-smoked" assert.match(errors, /install smoke/); }); +test("a v0.3 maintenance release must use a numeric tag at the exact protected branch head", () => { + const release = readFileSync(join(process.cwd(), ".github", "workflows", "release.yml"), "utf8"); + assert.deepEqual(validateWorkflowText("release.yml", release), []); + + const looseTag = release.replace('[[ "$RELEASE_TAG" =~ ^v0\\.3\\.[0-9]+$ ]]', '[[ "$RELEASE_TAG" == v0.3.* ]]'); + assert.match(validateWorkflowText("release.yml", looseTag).join("\n"), /numeric v0\.3\.x tags/); + + const looseHead = release.replace( + 'test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"', + 'git merge-base --is-ancestor HEAD origin/release/0.3', + ); + assert.match(validateWorkflowText("release.yml", looseHead).join("\n"), /exact release\/0\.3 head/); +}); + test("installer policy rejects pipe-to-shell and lifecycle-enabled global installs", () => { assert.deepEqual(validateInstallerText("safe.sh", "npm install -g aether-agents@latest --ignore-scripts"), []); assert.match(validateInstallerText("bad.sh", "curl https://example.test/install.sh | sh\nnpm install -g aether-agents").join("\n"), /pipe-to-shell/);