diff --git a/.github/workflows/hosted-canary.yml b/.github/workflows/hosted-canary.yml index 8df7359..93c403e 100644 --- a/.github/workflows/hosted-canary.yml +++ b/.github/workflows/hosted-canary.yml @@ -75,6 +75,7 @@ jobs: SOURCE_SHA: ${{ github.event.pull_request.head.sha }} CANARY_QUALIFIED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} CANARY_TRUSTED_WORKFLOW_SHA: ${{ github.event.pull_request.base.sha }} + CANARY_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} run: | set -euo pipefail test -n "$GH_TOKEN" diff --git a/scripts/ship-canary.test.ts b/scripts/ship-canary.test.ts index 44cace4..d7c56da 100644 --- a/scripts/ship-canary.test.ts +++ b/scripts/ship-canary.test.ts @@ -471,6 +471,118 @@ test("candidate config cannot redirect trusted canary targets", () => { }) }) +test("trusted template base admits exact first-consumer canary targets", () => { + const driver = canaryFixture() + const candidate = canaryFixture() + writeFileSync( + join(driver.temporaryRoot, "plugin.config.json"), + readFileSync(join(root, "plugin.config.json"), "utf8"), + ) + + const result = runTrustedCanary( + driver.temporaryRoot, + driver, + candidate.temporaryRoot, + "--dry-run", + { + GITHUB_REPOSITORY: "myagentdojo/dojo-hello", + CANARY_HEAD_REPOSITORY: "myagentdojo/dojo-hello", + GITHUB_WORKFLOW_REF: + "myagentdojo/dojo-hello/.github/workflows/hosted-canary.yml@refs/heads/main", + }, + ) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + identity: "myagentdojo", + targets: [ + { repository: "myagentdojo/dojo-hello-public-canary", visibility: "PUBLIC" }, + { repository: "myagentdojo/dojo-hello-private-canary", visibility: "PRIVATE" }, + ], + }) +}) + +interface MutableBootstrapCandidate { + template: boolean + repository: string + canary: { + owner: string + actor: string + publicRepository: string + privateRepository: string + } +} + +test.each([ + [ + "redirected target", + (config: MutableBootstrapCandidate) => { + config.canary.publicRepository = "attacker-public-canary" + }, + {}, + ], + [ + "wrong actor", + (config: MutableBootstrapCandidate) => { + config.canary.actor = "another-actor" + }, + {}, + ], + [ + "wrong repository URL", + (config: MutableBootstrapCandidate) => { + config.repository = "https://github.com/myagentdojo/another-repository" + }, + {}, + ], + [ + "partially initialized candidate", + (config: MutableBootstrapCandidate) => { + config.template = true + }, + {}, + ], + [ + "fork head", + (_config: MutableBootstrapCandidate) => {}, + { CANARY_HEAD_REPOSITORY: "fork-owner/dojo-hello" }, + ], +])("trusted template bootstrap rejects %s", (_name, mutate, environment) => { + const driver = canaryFixture() + const candidate = canaryFixture() + writeFileSync( + join(driver.temporaryRoot, "plugin.config.json"), + readFileSync(join(root, "plugin.config.json"), "utf8"), + ) + const candidateConfigPath = join(candidate.temporaryRoot, "plugin.config.json") + const candidateConfig = JSON.parse( + readFileSync(candidateConfigPath, "utf8"), + ) as MutableBootstrapCandidate + mutate(candidateConfig) + writeFileSync(candidateConfigPath, `${JSON.stringify(candidateConfig, null, 2)}\n`) + + const result = runTrustedCanary( + driver.temporaryRoot, + driver, + candidate.temporaryRoot, + "--dry-run", + { + GITHUB_REPOSITORY: "myagentdojo/dojo-hello", + CANARY_HEAD_REPOSITORY: "myagentdojo/dojo-hello", + GITHUB_WORKFLOW_REF: + "myagentdojo/dojo-hello/.github/workflows/hosted-canary.yml@refs/heads/main", + ...environment, + }, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "canary_target_mismatch", + retrySafe: false, + }) + expect(existsSync(driver.log)).toBe(false) +}) + test("public publication uses sanitized bytes while private publication uses the source checkout", async () => { const driver = canaryFixture() const candidate = canaryFixture() @@ -1060,6 +1172,9 @@ test("privileged canary workflow executes trusted code and treats the PR checkou expect(workflow).toContain("environment: hosted-canary-qualification") expect(workflow).toContain("CANARY_QUALIFIED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }}") expect(workflow).toContain("CANARY_TRUSTED_WORKFLOW_SHA: ${{ github.event.pull_request.base.sha }}") + expect(workflow).toContain( + "CANARY_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}", + ) expect(workflow).toContain("GH_TOKEN: ${{ secrets.CANARY_GH_TOKEN }}") expect(workflow).toContain("CANARY_SSH_KNOWN_HOSTS: ${{ secrets.CANARY_SSH_KNOWN_HOSTS }}") expect(workflow).toContain("CANARY_SSH_PRIVATE_KEY: ${{ secrets.CANARY_SSH_PRIVATE_KEY }}") diff --git a/scripts/ship-canary.ts b/scripts/ship-canary.ts index 746e208..234de72 100644 --- a/scripts/ship-canary.ts +++ b/scripts/ship-canary.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { loadPluginConfig } from "./plugin-config" +import { type PluginConfig, loadPluginConfig } from "./plugin-config" import { deterministicPluginArchive, payloadInventorySha256 } from "./plugin-files" import { copyMarketplaceDistribution, proveHostedHarnessInstall } from "./prove-harness-install" @@ -1097,14 +1097,22 @@ function installCandidate(target: Target, sourceSha: string): CandidateInstallEv } } -function candidateCanaryTargets(sourceRoot: string): { - owner?: unknown - actor?: unknown - publicRepository?: unknown - privateRepository?: unknown -} { +interface CandidateCanaryConfig { + template?: unknown + repository?: unknown + canary: { + owner?: unknown + actor?: unknown + publicRepository?: unknown + privateRepository?: unknown + } +} + +function candidateCanaryConfig(sourceRoot: string): CandidateCanaryConfig { try { const candidate = JSON.parse(readFileSync(join(sourceRoot, "plugin.config.json"), "utf8")) as { + template?: unknown + repository?: unknown canary?: { owner?: unknown actor?: unknown @@ -1112,7 +1120,11 @@ function candidateCanaryTargets(sourceRoot: string): { privateRepository?: unknown } } - return candidate.canary ?? {} + return { + template: candidate.template, + repository: candidate.repository, + canary: candidate.canary ?? {}, + } } catch (error) { throw new CanaryError( "candidate_config_invalid", @@ -1123,6 +1135,52 @@ function candidateCanaryTargets(sourceRoot: string): { } } +function trustedCanaryTargets( + trustedConfig: PluginConfig, + candidate: CandidateCanaryConfig, + identity: string, + environment: Record = process.env, +): PluginConfig["canary"] { + const exactMatch = + candidate.canary.owner === trustedConfig.canary.owner && + candidate.canary.actor === trustedConfig.canary.actor && + candidate.canary.publicRepository === trustedConfig.canary.publicRepository && + candidate.canary.privateRepository === trustedConfig.canary.privateRepository + if (exactMatch) return trustedConfig.canary + + const repository = environment.GITHUB_REPOSITORY ?? "" + const [owner, name, extra] = repository.split("/") + const expected = { + owner, + actor: identity, + publicRepository: `${name}-public-canary`, + privateRepository: `${name}-private-canary`, + } + const protectedBootstrap = + trustedConfig.template === true && + environment.GITHUB_ACTIONS === "true" && + Boolean(owner && name && !extra && identity) && + environment.CANARY_HEAD_REPOSITORY === repository && + environment.GITHUB_WORKFLOW_REF?.startsWith( + `${repository}/.github/workflows/hosted-canary.yml@`, + ) === true && + /^[0-9a-f]{40}$/.test(environment.CANARY_TRUSTED_WORKFLOW_SHA ?? "") && + candidate.template === false && + candidate.repository === `https://github.com/${repository}` && + candidate.canary.owner === expected.owner && + candidate.canary.actor === expected.actor && + candidate.canary.publicRepository === expected.publicRepository && + candidate.canary.privateRepository === expected.privateRepository + if (protectedBootstrap) return expected + + throw new CanaryError( + "canary_target_mismatch", + "candidate canary targets differ from the trusted driver checkout", + "restore the trusted targets, or initialize the exact same-repository template through its protected hosted-canary workflow", + false, + ) +} + function assertCandidateInstall( target: Target, evidence: CandidateInstallEvidence, @@ -1223,21 +1281,9 @@ export async function qualifyTargets( function preflight(options: PublishOptions): Preflight { const trustedConfig = loadPluginConfig(root) - const candidateCanary = candidateCanaryTargets(options.sourceRoot) - if ( - candidateCanary.owner !== trustedConfig.canary.owner || - candidateCanary.actor !== trustedConfig.canary.actor || - candidateCanary.publicRepository !== trustedConfig.canary.publicRepository || - candidateCanary.privateRepository !== trustedConfig.canary.privateRepository - ) { - throw new CanaryError( - "canary_target_mismatch", - "candidate canary targets differ from the trusted driver checkout", - "restore plugin.config.json canary targets to the trusted base values", - false, - ) - } + const candidateConfig = candidateCanaryConfig(options.sourceRoot) const identity = processResult(["gh", "api", "user", "--jq", ".login"]) || "" + const canary = trustedCanaryTargets(trustedConfig, candidateConfig, identity) const dirty = processResult( ["git", "status", "--porcelain", "--untracked-files=no"], false, @@ -1327,7 +1373,7 @@ function preflight(options: PublishOptions): Preflight { const boundTransport = bindTransportIdentity( identity, resolvedTransport.identity, - trustedConfig.canary.actor, + canary.actor, resolvedTransport.kind, ) const transportIdentity = { ...boundTransport, host: resolvedTransport.host } @@ -1335,9 +1381,9 @@ function preflight(options: PublishOptions): Preflight { try { const targets = buildTargets( origin, - trustedConfig.canary.owner, - trustedConfig.canary.publicRepository, - trustedConfig.canary.privateRepository, + canary.owner, + canary.publicRepository, + canary.privateRepository, sourceSha, options.sourceRoot, publicCandidate,