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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ Wish: `wish/tui-native-selection`.

## Unreleased

### `genie update` repaired for hosts on 5.260831.x

5.260901.1 dropped the `.agents/` and `.claude-plugin/` directories from the
release tarball. `genie update` is executed by the *previously installed*
binary, whose promoter validates the downloaded payload against an exact
top-level allowlist, so every 5.260831.x host failed with
`staged install does not match the exact installer member allowlist`.

The tarball again ships both directories (empty; nothing reads them) and the
allowlist is frozen at the eight-member set. Hosts on 5.260831.x update
normally from the next release. Hosts that installed 5.260901.1 fresh carry
the six-member allowlist and must reinstall once:
`curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash`.

Follow-up (not in this change): a shape-tolerant promoter and a release gate
that runs the previous stable binary's promoter against the candidate tarball.

### Plugin-only Codex skills

Wish: `repair-genie-codex-hooks-and-dedupe-skills`.
Expand Down
7 changes: 7 additions & 0 deletions scripts/build-binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ cp -R "${REPO_ROOT}/plugins" "${STAGE}/plugins"
cp -R "${REPO_ROOT}/skills" "${STAGE}/skills"
cp -R "${REPO_ROOT}/templates" "${STAGE}/templates"
cp "${REPO_ROOT}/LICENSE" "${STAGE}/LICENSE"
# Empty compatibility members. The promoter baked into every previously
# installed binary validates the downloaded payload against an *exact*
# top-level allowlist (src/lib/install-promotion.ts INSTALL_PAYLOAD_MEMBERS),
# so the tarball's top-level set is frozen: dropping these directories broke
# `genie update` on every 5.260831.x host (5.260901.1). They ship empty and
# nothing reads them; only remove them together with a shape-tolerant promoter.
mkdir -p "${STAGE}/.agents" "${STAGE}/.claude-plugin"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Force safe modes on compatibility directories

When a release is built under a common group-writable umask such as 002, this mkdir -p records both new directories as 0775. install.sh extracts the archive with tar -xzpf (whose -p preserves archived permissions), and assertSafeOwnedNode rejects any payload member with mode bits 0022, so the resulting tarball cannot be installed or promoted. Create these directories with a normalized 0755 mode (and verify that mode in the extracted archive) rather than inheriting the build environment's umask.

AGENTS.md reference: AGENTS.md:L55-L57

Useful? React with 👍 / 👎.


# Tests validate the source checkout; no language's test sources or discovery
# directories belong in the runtime archive. Keep deletion and assertion
Expand Down
3 changes: 2 additions & 1 deletion scripts/install-swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function buildTarball(root: string, opts: { version: string; withBinary?: boolea
}
writeFileSync(join(tree, 'VERSION'), `${opts.version}\n`, { mode: 0o644 });
writeFileSync(join(tree, 'LICENSE'), 'fixture license\n', { mode: 0o644 });
for (const name of ['.agents', '.claude-plugin']) mkdirSync(join(tree, name), { recursive: true, mode: 0o755 });
for (const name of ['plugins', 'skills', 'templates']) {
mkdirSync(join(tree, name), { recursive: true, mode: 0o755 });
writeFileSync(join(tree, name, opts.sidecar ?? 'marker.txt'), `sidecar:${name}\n`, { mode: 0o644 });
Expand Down Expand Up @@ -212,7 +213,7 @@ describe('install.sh transactional binary promotion (F31a)', () => {
expect(readFileSync(join(layout.bin, 'VERSION'), 'utf8')).toBe('5.260714.1\n');
const previous = join(layout.bin, '.previous');
expect(existsSync(previous) ? readdirSync(previous) : []).toEqual([]);
for (const name of ['plugins', 'skills', 'templates']) {
for (const name of ['.agents', '.claude-plugin', 'plugins', 'skills', 'templates']) {
expect(lstatSync(join(layout.bin, name)).isDirectory()).toBe(true);
expect(lstatSync(join(layout.bin, name)).isSymbolicLink()).toBe(false);
}
Expand Down
4 changes: 4 additions & 0 deletions scripts/release-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,10 @@ describe('Group E release and documentation contracts', () => {
expect(build).toContain('assert_release_tree_equal "${STAGE}" "${VERIFY_ROOT}"');
expect(build).toContain('cmp -- "${expected_entry}" "${actual_entry}"');
expect(build).toContain('cp "${REPO_ROOT}/LICENSE"');
// Frozen compat members: the previously installed binary validates the
// downloaded payload against its own exact INSTALL_PAYLOAD_MEMBERS, so
// dropping these broke `genie update` on every 5.260831.x host (5.260901.1).
expect(build).toContain('mkdir -p "${STAGE}/.agents" "${STAGE}/.claude-plugin"');
expect(build).toContain("-iname '*.test.*'");
expect(build).toContain("-iname 'test_*.*'");
expect(build).toContain("-iname '*_test.*'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function buildReleasePayload(
payloadSha256: string;
} {
const payload = join(root, 'payload');
for (const directory of ['plugins/genie', 'skills/review', 'templates']) {
for (const directory of ['.agents', '.claude-plugin', 'plugins/genie', 'skills/review', 'templates']) {
mkdirSync(join(payload, directory), { recursive: true });
}
writeFileSync(join(payload, 'LICENSE'), 'test fixture\n');
Expand Down
1 change: 1 addition & 0 deletions src/genie-commands/install-promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ afterEach(() => {
});

function writePayload(root: string, version: string): void {
for (const name of ['.agents', '.claude-plugin']) mkdirSync(join(root, name), { recursive: true, mode: 0o755 });
for (const name of ['plugins', 'skills', 'templates']) {
mkdirSync(join(root, name), { recursive: true, mode: 0o755 });
writeFileSync(join(root, name, 'generation.txt'), `${version}:${name}\n`, { mode: 0o644 });
Expand Down
37 changes: 34 additions & 3 deletions src/lib/install-promotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
INSTALL_PAYLOAD_COMPAT_MEMBERS,
INSTALL_PAYLOAD_MEMBERS,
type InstallPayloadMember,
type InstallPromotionDependencies,
Expand Down Expand Up @@ -51,6 +52,8 @@ function makeRoot(): string {
}

function writePayload(root: string, generation: string): void {
// Real tarballs ship `.agents` and `.claude-plugin` as empty compat dirs.
for (const name of INSTALL_PAYLOAD_COMPAT_MEMBERS) mkdirSync(join(root, name), { recursive: true, mode: 0o755 });
for (const name of ['plugins', 'skills', 'templates']) {
// Explicit 0o755 so the payload member dirs never inherit a group/other
// write bit under a loose caller umask (umask 002 → 0o775), which the
Expand Down Expand Up @@ -136,6 +139,33 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

describe('installer payload member contract', () => {
test('the top-level member set is frozen for cross-release update compatibility', () => {
// `genie update` runs the PREVIOUS release's promoter against the NEW
// tarball. Changing this set breaks update on every already-installed host
// (5.260901.1 vs 5.260831.x). Do not edit without a shape-tolerant promoter.
expect([...INSTALL_PAYLOAD_MEMBERS]).toEqual([
'.agents',
'.claude-plugin',
'LICENSE',
'VERSION',
'genie',
'plugins',
'skills',
'templates',
]);
expect([...INSTALL_PAYLOAD_COMPAT_MEMBERS]).toEqual(['.agents', '.claude-plugin']);
});

test('empty compat directories are accepted and published as payload members', () => {
const fixture = makeFixture();
for (const name of INSTALL_PAYLOAD_COMPAT_MEMBERS) expect(readdirSync(join(fixture.staging, name))).toEqual([]);
expect(promote(fixture).outcome).toBe('committed');
for (const name of INSTALL_PAYLOAD_COMPAT_MEMBERS) expect(readdirSync(join(fixture.bin, name))).toEqual([]);
assertGeneration(fixture.bin, '2.0.0');
});
});

describe('installer promotion transaction', () => {
test('reports native capability and the exact release payload allowlist', () => {
const result = installPromotionCapability();
Expand Down Expand Up @@ -645,8 +675,9 @@ describe('installer promotion transaction', () => {
});

test('an authorized-looking rolledback history still infers and rolls back a published member', () => {
// `.agents` is the first published member, so exactly one receipt precedes the forged pair.
const fixture = makeFixture(false);
expect(() => promote(fixture, { dependencies: interruption('publish-incoming', 'plugins') })).toThrow(
expect(() => promote(fixture, { dependencies: interruption('publish-incoming', '.agents') })).toThrow(
InstallPromotionInterruptedError,
);
const transactionRoot = pendingRoots(fixture)[0] as string;
Expand All @@ -655,8 +686,8 @@ describe('installer promotion transaction', () => {

const [report] = recoverPendingInstallPromotions({ genieHome: fixture.home, dependencies: dependencies() });
expect(report?.outcome).toBe('rolledback');
expect(existsSync(join(fixture.bin, 'plugins'))).toBe(false);
expect(lstatSync(join(fixture.staging, 'plugins')).isDirectory()).toBe(true);
expect(existsSync(join(fixture.bin, '.agents'))).toBe(false);
expect(lstatSync(join(fixture.staging, '.agents')).isDirectory()).toBe(true);
});

test('rejects non-0600 receipts and unknown transaction-root objects without mutating payload paths', () => {
Expand Down
38 changes: 37 additions & 1 deletion src/lib/install-promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,41 @@ import {
renamePathNoClobber,
} from './install-transaction.js';

export const INSTALL_PAYLOAD_MEMBERS = ['LICENSE', 'VERSION', 'genie', 'plugins', 'skills', 'templates'] as const;
/**
* Exact top-level member set of every release tarball.
*
* FROZEN — this is a cross-release compatibility contract, not a description of
* what the current build needs. `genie update` is executed by the *previously
* installed* binary, whose copy of this list validates the freshly downloaded
* payload (`verifyPayloadLayout`: same count, same sorted names). Any release
* whose tarball drops or adds a top-level member is therefore rejected by every
* host that has not yet installed a binary agreeing with the new set — that is
* exactly how 5.260901.1 broke `genie update` on every 5.260831.x host
* ("staged install does not match the exact installer member allowlist").
*
* `.agents` and `.claude-plugin` no longer carry product content; they ship as
* empty compatibility directories (`scripts/build-binary.sh`) purely so the
* promoter baked into older releases accepts the payload. Removing them again
* requires an installer that tolerates payload-shape changes across releases
* (a member-tolerant promoter and a release gate that runs the previous stable
* binary's `genie update` against the candidate) — never a plain delisting.
*/
export const INSTALL_PAYLOAD_MEMBERS = [
'.agents',
'.claude-plugin',
'LICENSE',
'VERSION',
'genie',
'plugins',
'skills',
'templates',
] as const;

/** Members shipped only for older promoters; they carry no product content. */
export const INSTALL_PAYLOAD_COMPAT_MEMBERS = [
'.agents',
'.claude-plugin',
] as const satisfies readonly InstallPayloadMember[];

export type InstallPayloadMember = (typeof INSTALL_PAYLOAD_MEMBERS)[number];
export type InstallPromotionOutcome = 'committed' | 'rolledback';
Expand Down Expand Up @@ -189,6 +223,8 @@ const activeInstallStagingGuards = new WeakSet<InstallStagingDirectoryGuard>();
const installStagingContentDigests = new WeakMap<InstallStagingDirectoryGuard, string | null>();

const EXPECTED_MEMBER_KINDS: Record<InstallPayloadMember, PhysicalPathIdentity['kind']> = {
'.agents': 'directory',
'.claude-plugin': 'directory',
LICENSE: 'file',
VERSION: 'file',
genie: 'file',
Expand Down
2 changes: 1 addition & 1 deletion tests/support/update-current-boundary-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ if (genieHome === undefined || scenario !== 'already-current') {
}

const bin = join(genieHome, 'bin');
for (const directory of ['plugins/genie', 'skills/review', 'templates']) {
for (const directory of ['.agents', '.claude-plugin', 'plugins/genie', 'skills/review', 'templates']) {
mkdirSync(join(bin, directory), { recursive: true });
}
writeFileSync(join(bin, 'LICENSE'), 'fixture\n');
Expand Down
Loading