Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- New setting, off by default: open a browser split beside the terminal whenever a new agent workspace is created (⌘⇧C, `programa` helper agents, and `race`). Also `automation.openBrowserWithAgentSplits` in settings.json.

### Fixed
- Release publishing accepts the previous ten-asset candidate manifests again, so the first ship after the remote daemon removal no longer fails.
- Remote and local CLI clients now share the v2 JSON-RPC and `programa-relay-auth` contracts, password-protected sockets work through MCP, and remote bootstrap files, tmux wait signals, relay diagnostics, and downloaded daemon artifacts have bounded ownership and lifetime.
- Session recovery now rejects oversized or structurally invalid snapshots before reconstruction, quarantines corrupt primaries, caps history scanning, elects escrow holders without unlinking live or indeterminate sockets, and terminates timed-out subprocess trees without leaving descendants or pipe readers behind.
- The iOS client now uses device-only Keychain credentials, bounded newline framing, cancellation-safe request and pairing deadlines, settled path selection, server-reconciled CloudKit subscriptions, and a generation-owned reconnect loop. The completed duplicate macOS transport spike has been removed.
Expand Down
29 changes: 27 additions & 2 deletions scripts/rolling_release_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ function requiredImmutableNames(build) {
];
}

// The remote daemon (programad-remote) was removed in PR #329, which narrowed
// requiredImmutableNames to just the two current build artifacts. The already-published
// prerelease that the live appcast points at was sealed before that change and still
// carries these six legacy daemon immutables. It cannot be deleted, so the validator
// must keep accepting its sealed manifest as-is instead of rejecting it as corrupt.
function legacyImmutableNames(build) {
return [
`programad-remote-checksums-${build}.txt`,
`programad-remote-darwin-amd64-${build}`,
`programad-remote-darwin-arm64-${build}`,
`programad-remote-linux-amd64-${build}`,
`programad-remote-linux-arm64-${build}`,
`programad-remote-manifest-${build}.json`,
];
}

function validateAsset(asset, index) {
const label = `manifest asset ${index}`;
assertPlainObject(asset, label);
Expand Down Expand Up @@ -109,6 +125,7 @@ function validateCandidateManifest(manifest) {
const stableAliases = assets.filter((asset) => asset.role === "stable-alias");
const immutable = assets.filter((asset) => asset.role === "immutable");
const requiredNames = new Set(requiredImmutableNames(manifest.build));
const legacyNames = new Set(legacyImmutableNames(manifest.build));

for (const asset of assets) {
if (asset.name === "appcast.xml" && asset.role !== "appcast") {
Expand All @@ -123,13 +140,21 @@ function validateCandidateManifest(manifest) {
if (asset.role === "stable-alias" && asset.name !== "programa-macos.dmg") {
throw new TypeError("the stable-alias role is reserved for programa-macos.dmg");
}
if (asset.role === "immutable" && !requiredNames.has(asset.name)) {
if (asset.role === "immutable" && !requiredNames.has(asset.name) && !legacyNames.has(asset.name)) {
throw new TypeError(`manifest has an unexpected immutable asset or build suffix: ${asset.name}`);
}
}

if (manifest.sealed) {
if (assets.length !== 4) throw new TypeError("sealed manifest must contain exactly 4 assets");
const presentLegacyNames = immutable.filter((asset) => legacyNames.has(asset.name));
const isLegacyManifest = presentLegacyNames.length > 0;
if (isLegacyManifest && presentLegacyNames.length !== legacyNames.size) {
throw new TypeError("sealed manifest has a partial legacy daemon asset set");
}
const expectedAssetCount = isLegacyManifest ? 10 : 4;
if (assets.length !== expectedAssetCount) {
throw new TypeError(`sealed manifest must contain exactly ${expectedAssetCount} assets`);
}
if (appcast.length !== 1 || stableAliases.length !== 1) {
throw new TypeError("sealed manifest must contain exactly one appcast and one stable alias");
}
Expand Down
50 changes: 50 additions & 0 deletions scripts/rolling_release_state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,56 @@ test("immutable payload suffixes must match the candidate build", () => {
assert.throws(() => validateCandidateManifest(value), /build|suffix|asset|required/i);
});

// PR #329 removed the remote daemon (programad-remote) and narrowed the required
// immutable set from ten assets to two. The already-published rolling candidate that
// the live appcast points at was sealed before that change and still carries the six
// legacy daemon immutables, so the validator must keep accepting it.
function legacyDaemonAssets(build) {
return [
`programad-remote-checksums-${build}.txt`,
`programad-remote-darwin-amd64-${build}`,
`programad-remote-darwin-arm64-${build}`,
`programad-remote-linux-amd64-${build}`,
`programad-remote-linux-arm64-${build}`,
`programad-remote-manifest-${build}.json`,
].map((name, index) => ({
name,
role: "immutable",
size: index + 100,
sha256: String(index + 100).padStart(64, "0"),
}));
}

test("a sealed manifest with the full legacy ten-asset daemon set still validates", () => {
const value = manifestFor("41");
value.assets.push(...legacyDaemonAssets("41"));

const validated = validateCandidateManifest(value);
assert.equal(validated.assets.length, 10);
});

test("a sealed manifest with a partial legacy daemon asset set fails closed", () => {
const value = manifestFor("41");
value.assets.push(...legacyDaemonAssets("41").slice(0, 3));

assert.throws(() => validateCandidateManifest(value), /partial|legacy|daemon|asset/i);
});

test("an immutable asset with an unknown, non-legacy name still fails validation", () => {
const value = manifestFor("41");
value.assets.push({
name: "programad-remote-windows-amd64-41",
role: "immutable",
size: 900,
sha256: "e".repeat(64),
});

assert.throws(
() => validateCandidateManifest(value),
/unexpected immutable asset or build suffix/i,
);
});

test("the stable DMG is byte-identical to the immutable build DMG", () => {
const valid = manifestFor("41");
assert.doesNotThrow(() => validateCandidateManifest(valid));
Expand Down
Loading