From 624e05a0add496aca47ffc1030cd75930cfe6435 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 10:52:04 +0200 Subject: [PATCH 1/5] fix(github): refuse local-gh GitHub mutations under identity "app" Factory had two GitHub write identities. The lifecycle writeback honours `github.identity` and, on exact `app`, is performed server-side by the connected workspace GitHub App. Two mutations were never gated and still shelled out to `gh`, which authenticates as whichever human is logged in locally: - the guarded squash merge (`src/github/merge-gate.ts`), and - Notion intake issue create/edit (`src/intake/notion.ts`). Neither can be re-routed today: the connected `GithubConnectionWrite` surface exposes no `mergePullRequest` and no `createIssue`, and Factory must never receive or invoke a GitHub credential of its own. So under an explicit `app` identity both now refuse, naming the missing server-side capability and the operator's recovery path, instead of silently writing as the operator. `auto` and `user` are unchanged. Also: - `defaultMergeGate(config)` makes the FactoryLoop selection testable, the same shape as the existing `defaultGithubWriteback`. - `GhCliIssuePublisher` takes an explicit identity, so Notion intake's operator attribution is a decision on the record rather than an unnoticed default, and an injectable `gh` runner, because the class previously had no test seam at all and could only be exercised against live GitHub. - `StandalonePullRequest.source` keeps its `'gh'` member with the rationale written down: it is READ provenance, and this module performs no writes. - The stale `TODO(issue-52)` marker is rewritten; issue 52 is closed as completed and no longer owns the work. Reads are deliberately untouched: `gh pr view` carries no authorship. Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22 --- README.md | 31 ++++- src/cli/fleet.ts | 7 +- src/github/gh-identity.test.ts | 173 ++++++++++++++++++++++++++++ src/github/gh-identity.ts | 57 +++++++++ src/github/index.ts | 6 + src/github/merge-gate.ts | 38 +++++- src/github/standalone-babysitter.ts | 17 +++ src/intake/notion.ts | 43 ++++++- src/orchestrator/factory.ts | 14 ++- 9 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 src/github/gh-identity.test.ts create mode 100644 src/github/gh-identity.ts diff --git a/README.md b/README.md index 05ce6c9c..47baa380 100644 --- a/README.md +++ b/README.md @@ -792,8 +792,35 @@ mount acknowledges the provider mutation. Provider-authoritative issue reads remain optional on the writeback interface; when unavailable, their existing call sites keep their conservative fallback behavior. -This identity setting does not change Notion intake's separate GitHub issue -publisher, which still requires local `gh` authentication when enabled. +#### Writes that still shell out to `gh` + +Two Factory GitHub mutations are not represented on the connected App surface +and therefore cannot be performed as the app today. Under `"app"` they refuse +rather than writing as the operator, so an explicit app identity never produces +a human-attributed write: + +| Write | Refuses under `"app"` | Missing connected capability | +|---|---|---| +| Guarded squash merge (`mergePolicy: "on-green-with-review"`) | the merge is declined and logged; nothing is merged | `mergePullRequest` | +| Notion intake issue create/edit | the intake run fails with the reason | `createIssue` | + +Both refusals name the missing capability and the recovery path: set +`github.identity` to `"user"` or `"auto"` to deliberately accept local-user +attribution for that operation. Under `"auto"` and `"user"` both paths behave +exactly as they always have. Neither refusal is reachable in the default cloud +deployment, which runs `mergePolicy: "never"` and does not run Notion intake. + +Notion intake is a separate surface from the Factory lifecycle writeback and +still requires local `gh` authentication when enabled; its CLI entry point +states `"user"` explicitly so the attribution is a decision on the record +rather than an unnoticed default. + +Read paths are deliberately unaffected. `gh pr view` carries no authorship, so +merge-gate reads, Notion intake label/visibility lookups, and the standalone +babysitter's PR metadata read (whose `source: 'gh'` provenance marker is +retained for exactly this reason) continue to work under every identity. +Review replies and pushes on a babysat PR are performed by the dispatched agent +under the agent's own credential, not by the Factory process. Authenticated Factory progress reporting is enabled by default for real CLI sessions. Factory sends privacy-bounded lifecycle events, worker ownership, diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 1e5fd70e..1c105b33 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -364,7 +364,12 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom manifest, dispatch: !globals.dryRun, ...(!globals.dryRun ? { - github: new GhCliIssuePublisher(), + // Notion intake is a separate surface from the Factory lifecycle + // writeback and has no app-authored issue-create route, so its + // issues are deliberately authored by the operator's gh account. + // Stated explicitly here so the attribution is a decision on the + // record rather than an unnoticed default. + github: new GhCliIssuePublisher('user'), workspace, ...(notionClaims ? { claims: notionClaims } : {}), ...(notionContracts ? { contracts: notionContracts } : {}), diff --git a/src/github/gh-identity.test.ts b/src/github/gh-identity.test.ts new file mode 100644 index 00000000..f3dd0d2e --- /dev/null +++ b/src/github/gh-identity.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' + +import { GhCliGithubMergeGate, type GhRunner } from './merge-gate' +import { localGhMutationAllowed } from './gh-identity' +import { GhCliIssuePublisher } from '../intake/notion' +import { defaultMergeGate } from '../orchestrator/factory' +import { FactoryConfigSchema } from '../config/schema' + +/** + * The defect (#221): Factory writes to GitHub through two identities. The + * lifecycle writeback honours `github.identity`, but the guarded merge and + * Notion intake shell out to `gh` unconditionally, so under an explicit + * `identity: "app"` they still attribute the write to whichever human is + * logged in locally. + * + * Each case below is a must-fire / must-not-fire pair: `app` must refuse + * WITHOUT spawning `gh`, and `auto`/`user` must behave exactly as they do + * today. A test that only asserted the refusal would pass against a change + * that broke every local run. + */ + +const mergeInput = { repo: 'AgentWorkforce/example', number: 7, expectedHeadSha: 'a'.repeat(40) } + +/** + * Every `gh` invocation in this file goes through a fake. Nothing here may + * reach the network: an earlier draft of this test used the real runner and + * created a junk issue and overwrote a merged PR's body on the live repo. + */ +const fakeGh = (): { gh: (args: string[], input?: string) => Promise; calls: string[][] } => { + const calls: string[][] = [] + return { + calls, + gh: async (args) => { + calls.push(args) + return 'https://github.com/AgentWorkforce/example/issues/1' + }, + } +} + +const recordingRunner = (): { runner: GhRunner; calls: string[][] } => { + const calls: string[][] = [] + return { + calls, + runner: async (args) => { + calls.push(args) + return { stdout: '', stderr: '' } + }, + } +} + +const configWith = (identity?: 'app' | 'user' | 'auto') => + FactoryConfigSchema.parse({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + ...(identity ? { github: { identity } } : {}), + }) + +describe('local gh mutations under github.identity', () => { + it('MUST NOT FIRE: auto and user still squash-merge through the local gh CLI', async () => { + for (const identity of ['auto', 'user'] as const) { + const { runner, calls } = recordingRunner() + const result = await new GhCliGithubMergeGate(runner, identity).merge(mergeInput) + + expect(result.merged, `${identity} must keep merging`).toBe(true) + expect(calls, `${identity} must still invoke gh`).toHaveLength(1) + expect(calls[0]?.slice(0, 2)).toEqual(['pr', 'merge']) + } + }) + + it('MUST NOT FIRE: a gate constructed without an identity keeps the historical behavior', async () => { + const { runner, calls } = recordingRunner() + const result = await new GhCliGithubMergeGate(runner).merge(mergeInput) + + expect(result.merged).toBe(true) + expect(calls).toHaveLength(1) + }) + + it('MUST FIRE: identity "app" refuses the guarded merge and never spawns gh', async () => { + const { runner, calls } = recordingRunner() + const result = await new GhCliGithubMergeGate(runner, 'app').merge(mergeInput) + + expect(result.merged).toBe(false) + // Refused before the process boundary, not after a merge already landed. + expect(calls).toEqual([]) + expect(result.reason).toContain('GitHub identity "app"') + // The refusal must name the missing server-side capability and the + // operator's recovery path, or it is an outage with no exit. + expect(result.reason).toContain('mergePullRequest') + expect(result.reason).toContain('"user" or "auto"') + }) + + it('MUST NOT FIRE: identity "app" leaves the merge-gate READ working', async () => { + // `check` reads `gh pr view`; a read carries no authorship, so gating it + // would break the gate without removing any attribution. + const gate = new GhCliGithubMergeGate(async () => ({ + stdout: JSON.stringify({ + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + headRefOid: mergeInput.expectedHeadSha, + reviewDecision: 'APPROVED', + statusCheckRollup: [{ conclusion: 'SUCCESS' }], + }), + }), 'app') + + await expect(gate.check(mergeInput)).resolves.toMatchObject({ verdict: 'READY', ready: true }) + }) + + it('MUST FIRE: the FactoryLoop selector propagates identity "app" to the merge gate', async () => { + // Without this the guard is a gate nobody invokes: the class would refuse + // correctly while FactoryLoop kept constructing it with the default. + const result = await defaultMergeGate(configWith('app')).merge(mergeInput) + expect(result.merged).toBe(false) + expect(result.reason).toContain('GitHub identity "app"') + }) + + it('MUST NOT FIRE: the selector leaves auto and an absent github key merging', async () => { + // `github.identity` is synthesised to `auto` when the key is absent, so + // "no github block" and "identity: auto" must both stay on the old path. + for (const config of [configWith('auto'), configWith()]) { + expect(config.github.identity).toBe('auto') + expect(localGhMutationAllowed(config.github.identity)).toBe(true) + } + }) + + it('MUST FIRE: identity "app" refuses Notion intake issue create and edit, without invoking gh', async () => { + const { gh, calls } = fakeGh() + const publisher = new GhCliIssuePublisher('app', gh) + + await expect(publisher.createIssue({ + repo: 'AgentWorkforce/example', + title: 'title', + body: 'body', + labels: [], + })).rejects.toThrow(/GitHub identity "app"[\s\S]*createIssue/u) + + await expect(publisher.updateIssue({ + repo: 'AgentWorkforce/example', + number: 7, + body: 'body', + })).rejects.toThrow(/GitHub identity "app"[\s\S]*updateIssue/u) + + // Refused before the process boundary — no write reached GitHub. + expect(calls).toEqual([]) + }) + + it('MUST NOT FIRE: Notion intake under "user" still creates and edits through gh', async () => { + const { gh, calls } = fakeGh() + const publisher = new GhCliIssuePublisher('user', gh) + + await expect(publisher.createIssue({ + repo: 'AgentWorkforce/example', + title: 'title', + body: 'body', + labels: ['factory'], + })).resolves.toMatchObject({ number: 1 }) + + await publisher.updateIssue({ repo: 'AgentWorkforce/example', number: 7, body: 'body' }) + + expect(calls.map((args) => args.slice(0, 2))).toEqual([ + ['issue', 'create'], + ['issue', 'edit'], + ]) + }) + + it('MUST NOT FIRE: identity "app" leaves Notion intake READS working', async () => { + // Reads carry no authorship, so gating them would break intake without + // removing any attribution. + const { gh, calls } = fakeGh() + const publisher = new GhCliIssuePublisher('app', gh) + + await publisher.missingLabels('AgentWorkforce/example', ['factory']) + expect(calls.map((args) => args[0])).toEqual(['api']) + }) +}) diff --git a/src/github/gh-identity.ts b/src/github/gh-identity.ts new file mode 100644 index 00000000..3a41a7ea --- /dev/null +++ b/src/github/gh-identity.ts @@ -0,0 +1,57 @@ +/** + * One rule for every GitHub mutation Factory performs through the local `gh` + * CLI. + * + * Factory has two GitHub write identities. Lifecycle writes (PR publication, + * issue comments, status labels, issue closure) are selected by + * `github.identity` and, on exact `app`, are performed server-side by the + * connected workspace GitHub App. Everything else still shells out to `gh`, + * which authenticates as whatever local user happens to be logged in — a + * human account. That produced one product with two audit trails. + * + * The remaining `gh` mutations cannot simply be re-routed: the connected + * `GithubConnectionWrite` surface has no merge operation and no issue-create + * operation, and Factory must never receive or invoke a GitHub credential of + * its own (see `src/mount/github-api-issue-read.ts`). So the honest behavior + * under an explicit `app` identity is to refuse rather than to silently write + * as the operator — a documented limitation instead of an invisible one. + * + * `auto` and `user` keep today's local-`gh` behavior. Only exact `app` + * refuses, and the refusal names the recovery path so the gate does not take + * an honest caller hostage. + */ +export type GithubWriteIdentity = 'app' | 'user' | 'auto' + +/** + * Whether the configured identity permits mutating GitHub through local `gh`. + * + * Reads are always permitted: `gh pr view` leaks no authorship, so read + * provenance is not an identity concern (see `StandalonePullRequest.source`). + */ +export function localGhMutationAllowed(identity: GithubWriteIdentity): boolean { + return identity !== 'app' +} + +/** + * The refusal text for a local-`gh` mutation blocked by `github.identity: "app"`. + * + * @param operation what Factory was about to do, in caller-facing terms. + * @param capability the server-side operation Relayfile Cloud would need to + * expose on the connected App surface for this write to be + * performed as the app instead of refused. + */ +export function localGhMutationRefusal(operation: string, capability: string): string { + return `GitHub identity "app" refuses ${operation} through the local gh CLI, which would attribute the write to the operator's account instead of the workspace GitHub App. ` + + `Performing it as the app requires the connected write capability "${capability}", which the Relayfile GitHub connection does not expose. ` + + 'Set github.identity to "user" or "auto" to deliberately accept local-user attribution for this operation.' +} + +/** Throw the standard refusal when `identity` forbids a local-`gh` mutation. */ +export function assertLocalGhMutationAllowed( + identity: GithubWriteIdentity, + operation: string, + capability: string, +): void { + if (localGhMutationAllowed(identity)) return + throw new Error(localGhMutationRefusal(operation, capability)) +} diff --git a/src/github/index.ts b/src/github/index.ts index 072032ca..0fbdf869 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -4,6 +4,11 @@ export { defaultGhRunner, evaluateGithubMergeGate, } from './merge-gate' +export { + assertLocalGhMutationAllowed, + localGhMutationAllowed, + localGhMutationRefusal, +} from './gh-identity' export { closeProbePr, } from './probe-closer' @@ -37,3 +42,4 @@ export type { StandalonePullRequest, } from './standalone-babysitter' export type { RoutedPrCandidate, RoutedPrDiscoveryReport } from './routed-pr-babysitter' +export type { GithubWriteIdentity } from './gh-identity' diff --git a/src/github/merge-gate.ts b/src/github/merge-gate.ts index 6b11766c..4ca1fda3 100644 --- a/src/github/merge-gate.ts +++ b/src/github/merge-gate.ts @@ -1,6 +1,8 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' +import { localGhMutationAllowed, localGhMutationRefusal, type GithubWriteIdentity } from './gh-identity' + const execFileAsync = promisify(execFile) export interface GhRunResult { @@ -49,9 +51,17 @@ export interface GithubMergeGate { export class GhCliGithubMergeGate implements GithubMergeGate { readonly #run: GhRunner - - constructor(run: GhRunner = defaultGhRunner) { + readonly #identity: GithubWriteIdentity + + /** + * @param identity the configured `github.identity`. `check` is a read and + * ignores it; `merge` mutates GitHub and refuses under exact `app` rather + * than squash-merging as the operator's own account. Defaults to `auto` + * so a directly-constructed gate keeps its historical behavior. + */ + constructor(run: GhRunner = defaultGhRunner, identity: GithubWriteIdentity = 'auto') { this.#run = run + this.#identity = identity } async check(input: GithubMergeGateInput): Promise { @@ -78,6 +88,20 @@ export class GhCliGithubMergeGate implements GithubMergeGate { } async merge(input: GithubMergeInput): Promise { + // Fail closed before spawning `gh`. A guarded merge run through the local + // CLI is recorded by GitHub as the operator merging, which is precisely + // the split audit trail `github.identity: "app"` exists to remove. There + // is no app-authored merge to fall through to, so refuse and say why. + if (!localGhMutationAllowed(this.#identity)) { + return { + merged: false, + reason: localGhMutationRefusal( + `the guarded squash merge of ${input.repo}#${input.number}`, + 'mergePullRequest', + ), + } + } + try { const result = await this.#run([ 'pr', @@ -173,8 +197,14 @@ export function evaluateGithubMergeGate( } export const defaultGhRunner: GhRunner = async (args) => { - // TODO(issue-52): retire this compatibility runner when merge-gate reads and - // guarded merges are fully represented by the mounted GitHub connection. + // Compatibility runner. Retire it when merge-gate reads and guarded merges + // are fully represented by the mounted GitHub connection: that needs a + // `mergePullRequest` capability on `GithubConnectionWrite`, fulfilled + // server-side by Relayfile Cloud so Factory still holds no GitHub + // credential. Until then `merge` refuses under `github.identity: "app"` + // rather than merging as the operator (see ./gh-identity). Tracked on + // AgentWorkforce/factory#221; the previous marker cited issue 52, which is + // closed as completed and no longer owns this work. const { stdout, stderr } = await execFileAsync('gh', args, { maxBuffer: 1024 * 1024 }) return { stdout, stderr } } diff --git a/src/github/standalone-babysitter.ts b/src/github/standalone-babysitter.ts index e24ea189..e777d47b 100644 --- a/src/github/standalone-babysitter.ts +++ b/src/github/standalone-babysitter.ts @@ -25,6 +25,23 @@ export interface StandalonePullRequest { crossRepository?: boolean maintainerCanModify?: boolean filesChanged?: string[] + /** + * Where this PR's metadata was read from — deliberately retained. + * + * `gh` here is READ provenance, not write provenance: the only `gh` call + * this module makes is `gh pr view --json` (see `readStandalonePullRequest`), + * and a read carries no authorship, so it cannot split Factory's audit + * trail the way a `gh`-authored comment or merge would. The member is kept + * because the mounted projection can be stale or incomplete, and callers + * need to know whether a field came from the mount, from live GitHub, or + * from both before they act on it. + * + * This module performs no GitHub writes. Factory's identity-bearing GitHub + * mutations live in `src/writeback/github.ts` (lifecycle, identity-selected) + * and `src/github/merge-gate.ts` (guarded merge, refuses under `app`); the + * babysitter's own review replies and pushes are performed by the dispatched + * agent under the agent's credential, not by this process. + */ source: 'mount' | 'gh' | 'mount+gh' } diff --git a/src/intake/notion.ts b/src/intake/notion.ts index 0c3d38ef..b636c4d7 100644 --- a/src/intake/notion.ts +++ b/src/intake/notion.ts @@ -7,6 +7,7 @@ import lockfile from 'proper-lockfile' import { z } from 'zod' import { dispatchNotionPageIdentity } from '../dispatch/work-unit-identity' +import { assertLocalGhMutationAllowed, type GithubWriteIdentity } from '../github/gh-identity' const INTAKE_LOCK_STALE_MS = 60_000 @@ -91,6 +92,9 @@ export interface ExistingGithubIssue { body: string } +/** Invokes the `gh` CLI with shell-free arguments and optional stdin. */ +export type GhCommandRunner = (args: string[], input?: string) => Promise + export interface GithubIssuePublisher { repositoryVisibility(repo: string): Promise<'public' | 'private' | 'internal'> missingLabels(repo: string, labels: readonly string[]): Promise @@ -378,8 +382,31 @@ export function parseChiefSpecHeader(content: string): { /** GitHub CLI publisher with shell-free arguments, bounded output, and source-marker reconciliation. */ export class GhCliIssuePublisher implements GithubIssuePublisher { + readonly #identity: GithubWriteIdentity + readonly #gh: GhCommandRunner + + /** + * @param identity the GitHub write identity this publisher may use. Notion + * intake is a separate surface from the Factory lifecycle writeback and + * still creates and edits issues through the local `gh` CLI, so its + * issues are authored by the operator. That is a documented exception + * (see README), not a silent fallback: the caller must state the identity + * it is choosing, and exact `app` refuses rather than mislabelling the + * write, because the connected App surface exposes no issue-create + * operation to route it through. + * @param gh the `gh` invoker. Injectable because every method here mutates + * or reads real GitHub: without a seam the only way to exercise this + * class is against the live API, which during development of #221 + * created a junk issue and overwrote a merged PR's body. Tests must pass + * a fake; production takes the default. + */ + constructor(identity: GithubWriteIdentity, gh: GhCommandRunner = runGh) { + this.#identity = identity + this.#gh = gh + } + async repositoryVisibility(repo: string): Promise<'public' | 'private' | 'internal'> { - const output = (await runGh(['repo', 'view', repo, '--json', 'visibility', '--jq', '.visibility'])).trim().toLowerCase() + const output = (await this.#gh(['repo', 'view', repo, '--json', 'visibility', '--jq', '.visibility'])).trim().toLowerCase() if (output !== 'public' && output !== 'private' && output !== 'internal') { throw new Error(`GitHub returned unknown visibility for ${repo}: ${output || '(empty)'}`) } @@ -387,13 +414,13 @@ export class GhCliIssuePublisher implements GithubIssuePublisher { } async missingLabels(repo: string, labels: readonly string[]): Promise { - const output = await runGh(['api', '--paginate', `repos/${repo}/labels?per_page=100`, '--jq', '.[].name']) + const output = await this.#gh(['api', '--paginate', `repos/${repo}/labels?per_page=100`, '--jq', '.[].name']) const available = new Set(output.split('\n').map((label) => label.trim()).filter(Boolean)) return labels.filter((label) => !available.has(label)) } async findBySource(repo: string, sourceKey: string): Promise { - const output = await runGh([ + const output = await this.#gh([ 'issue', 'list', '--repo', repo, '--state', 'all', '--limit', '100', '--search', `"factory-source:${sourceKey}" in:body`, '--json', 'number,url,body', @@ -404,16 +431,22 @@ export class GhCliIssuePublisher implements GithubIssuePublisher { } async createIssue(input: { repo: string; title: string; body: string; labels: readonly string[] }): Promise<{ number: number; url: string }> { + assertLocalGhMutationAllowed(this.#identity, `creating a GitHub issue in ${input.repo}`, 'createIssue') const args = ['issue', 'create', '--repo', input.repo, '--title', input.title, '--body-file', '-'] for (const label of input.labels) args.push('--label', label) - const url = (await runGh(args, input.body)).trim() + const url = (await this.#gh(args, input.body)).trim() const number = Number(/\/issues\/(\d+)\/?$/u.exec(url)?.[1]) if (!Number.isInteger(number) || number <= 0) throw new Error(`GitHub issue create returned an unexpected URL: ${url}`) return { number, url } } async updateIssue(input: { repo: string; number: number; body: string }): Promise { - await runGh(['issue', 'edit', String(input.number), '--repo', input.repo, '--body-file', '-'], input.body) + assertLocalGhMutationAllowed( + this.#identity, + `editing the body of GitHub issue ${input.repo}#${input.number}`, + 'updateIssue', + ) + await this.#gh(['issue', 'edit', String(input.number), '--repo', input.repo, '--body-file', '-'], input.body) } } diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index afc7a357..dbb6eada 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -1222,7 +1222,7 @@ export class FactoryLoop implements Factory { this.#githubWriteback = ports.githubWriteback ?? defaultGithubWriteback(config, ports.mount) this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack this.#github = ports.github ?? MountGithubRead(ports.mount) - this.#mergeGate = ports.mergeGate ?? new GithubMergeGate() + this.#mergeGate = ports.mergeGate ?? defaultMergeGate(config) this.#verificationGate = ports.verificationGate ?? (config.verification.enabled ? new VerificationPipeline({ descriptorPath: config.verification.descriptorPath, @@ -19470,6 +19470,18 @@ export class FactoryLoop implements Factory { } } +/** + * The guarded merge is a GitHub mutation, so it answers to the same identity + * policy as the lifecycle writeback. Under exact `github.identity: "app"` the + * gate refuses the merge instead of squash-merging as the operator's local + * `gh` user; `auto` and `user` keep today's behavior. + * + * Exported so the selection itself is testable rather than buried in the + * constructor — the same shape as `defaultGithubWriteback` below. + */ +export const defaultMergeGate = (config: FactoryConfig): GithubMergeGatePort => + new GithubMergeGate(undefined, config.github.identity) + const defaultGithubWriteback = (config: FactoryConfig, mount: MountClient): GithubWriteback => { if (config.github.identity !== 'app') { return new GhCliGithubWriteback() From 8144b2092a543eaa3e17f5bfe50f96bdbcbfe6ec Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 11:07:20 +0200 Subject: [PATCH 2/5] test(github): move the selector pair off a third heavy factory.ts import CI's `package` job timed out `src/cli/teammate-mcp.test.ts` twice on this branch while passing on the identical base commit. The cause was load, not logic: `gh-identity.test.ts` became the third test file to import the 23.5k-line `src/orchestrator/factory.ts`, and the MCP test has a 5s timeout over a microtask-driven in-memory transport. Move the FactoryLoop selector's must-fire/must-not-fire pair into `factory.test.ts`, which already imports that module. The proof is unchanged and it now lives with the loop it selects for. `defaultMergeGate` gains a test-only runner seam so the pair can assert the `user`/`auto` arms actually merge. Without it those arms would have spawned a real `gh pr merge`; production still passes nothing and gets `defaultGhRunner`. Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22 --- src/github/gh-identity.test.ts | 13 ++++----- src/orchestrator/factory.test.ts | 50 +++++++++++++++++++++++++++++++- src/orchestrator/factory.ts | 9 ++++-- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/github/gh-identity.test.ts b/src/github/gh-identity.test.ts index f3dd0d2e..0346ce8d 100644 --- a/src/github/gh-identity.test.ts +++ b/src/github/gh-identity.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { GhCliGithubMergeGate, type GhRunner } from './merge-gate' import { localGhMutationAllowed } from './gh-identity' import { GhCliIssuePublisher } from '../intake/notion' -import { defaultMergeGate } from '../orchestrator/factory' import { FactoryConfigSchema } from '../config/schema' /** @@ -104,13 +103,11 @@ describe('local gh mutations under github.identity', () => { await expect(gate.check(mergeInput)).resolves.toMatchObject({ verdict: 'READY', ready: true }) }) - it('MUST FIRE: the FactoryLoop selector propagates identity "app" to the merge gate', async () => { - // Without this the guard is a gate nobody invokes: the class would refuse - // correctly while FactoryLoop kept constructing it with the default. - const result = await defaultMergeGate(configWith('app')).merge(mergeInput) - expect(result.merged).toBe(false) - expect(result.reason).toContain('GitHub identity "app"') - }) + // The FactoryLoop selector's own must-fire/must-not-fire pair lives in + // `src/orchestrator/factory.test.ts`. It belongs with the loop it selects + // for, and importing the 23k-line orchestrator module from a third test + // file measurably slowed the parallel CI workers enough to time out an + // unrelated 5s MCP test. it('MUST NOT FIRE: the selector leaves auto and an absent github key merging', async () => { // `github.identity` is synthesised to `auto` when the key is absent, so diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 4ce3cb42..7d212917 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -29,7 +29,8 @@ import { type TriageEngine, type WorkflowRunnerInput, } from '../index' -import { LatePlacementReleasedError, changeEventPath } from './factory' +import { LatePlacementReleasedError, changeEventPath, defaultMergeGate } from './factory' +import type { GhRunner } from '../github' import { RelaySpawnAckTimeoutError } from '../fleet/relay-fleet-client' import { RelayfileOperationTimeoutError } from '../mount/relayfile-operation-timeout' import type { AgentWorktree, AgentWorktreeCleanupInspection, AgentWorktreeManager, AgentWorktreeRepository, ChangeEvent, EventPage, GithubConnectionRead, GithubConnectionWrite, GithubIssueStatus, GithubIssueCloseWriteResult, GithubPublishPullRequestInput, GithubStatusClaimReceipt, GithubStatusWriteResult, GithubWriteback, LinearWriteback, PreviewReference, PreviewStartInput, ProviderSyncStatus, RosterEntry, SlackWriteback, SpawnInput, SpawnResult } from '../ports' @@ -32254,3 +32255,50 @@ describe('probe PR resolution from the pull index', () => { expect(walkedReads).toHaveLength(61) }) }) + +describe('merge gate identity selection', () => { + // The guard in `src/github/gh-identity.ts` is only worth anything if the + // FactoryLoop actually hands it the configured identity. Without this pair + // it would be a gate nobody invokes: `GhCliGithubMergeGate` refusing + // correctly while the loop kept constructing it with the default. + const mergeInput = { repo: 'AgentWorkforce/example', number: 7, expectedHeadSha: 'a'.repeat(40) } + // Every gh invocation is faked. Nothing here may reach the real binary: the + // operation on the other side is an irreversible squash merge. + const fakeGh = (): { run: GhRunner; calls: string[][] } => { + const calls: string[][] = [] + return { calls, run: async (args) => { calls.push(args); return { stdout: '', stderr: '' } } } + } + const configFor = (identity?: 'app' | 'user' | 'auto') => + FactoryConfigSchema.parse({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + ...(identity ? { github: { identity } } : {}), + }) + + it('MUST FIRE: identity "app" yields a merge gate that refuses without invoking gh', async () => { + const { run, calls } = fakeGh() + const result = await defaultMergeGate(configFor('app'), run).merge(mergeInput) + + expect(result.merged).toBe(false) + expect(calls).toEqual([]) + expect(result.reason).toContain('GitHub identity "app"') + expect(result.reason).toContain('mergePullRequest') + }) + + it('MUST NOT FIRE: identity "user" yields a gate that still performs the merge', async () => { + const { run, calls } = fakeGh() + const result = await defaultMergeGate(configFor('user'), run).merge(mergeInput) + + expect(result.merged).toBe(true) + expect(calls[0]?.slice(0, 2)).toEqual(['pr', 'merge']) + }) + + it('MUST NOT FIRE: an absent github key is synthesised to auto and still merges', async () => { + const config = configFor() + expect(config.github.identity).toBe('auto') + + const { run, calls } = fakeGh() + const result = await defaultMergeGate(config, run).merge(mergeInput) + expect(result.merged).toBe(true) + expect(calls).toHaveLength(1) + }) +}) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index dbb6eada..17f943fa 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -19477,10 +19477,13 @@ export class FactoryLoop implements Factory { * `gh` user; `auto` and `user` keep today's behavior. * * Exported so the selection itself is testable rather than buried in the - * constructor — the same shape as `defaultGithubWriteback` below. + * constructor — the same shape as `defaultGithubWriteback` below. `run` is a + * test seam only: production passes nothing and gets `defaultGhRunner`. No + * test may reach the real `gh` binary here, because the operation it would + * perform is an irreversible merge. */ -export const defaultMergeGate = (config: FactoryConfig): GithubMergeGatePort => - new GithubMergeGate(undefined, config.github.identity) +export const defaultMergeGate = (config: FactoryConfig, run?: GhRunner): GithubMergeGatePort => + new GithubMergeGate(run, config.github.identity) const defaultGithubWriteback = (config: FactoryConfig, mount: MountClient): GithubWriteback => { if (config.github.identity !== 'app') { From f31549829277eddb1d7514ee680673a9212a3396 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 11:23:10 +0200 Subject: [PATCH 3/5] fix(intake): resolve the Notion identity from config and refuse before claiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all four review threads on #388. P1 (codex, cubic) — the identity refusal was raised inside `createIssue`, which `publishRepoTask` reaches only after `claimNotionDelivery` has reserved the durable exactly-once claim. An `app`-configured run therefore consumed the claim and returned blocked without a receipt, so the operator's retry under `user`/`auto` hit "durable Notion claim already exists" permanently — the gate took a hostage with no recovery path. `GithubIssuePublisher` gains an optional `assertWritable()`, called at the top of `publishRepoTask` before any claim, receipt or network call. The in-method asserts remain as a backstop. P2 (cubic) — `new GhCliIssuePublisher('user')` hardcoded the identity at the only production caller, so the refusal could never fire and an operator with `identity: "app"` would still get issues authored by their local `gh` user. That is the same "gate nobody invokes" defect this PR fixes for the merge gate, reintroduced one file over. The CLI now resolves `github.identity` from the contract. An absent contract is `auto` (Notion intake does not otherwise require one, and the schema synthesises absent to `auto`); a contract that exists but cannot be parsed is an error rather than a silent downgrade to the permissive value. `githubIdentitySchema` is exported so both readers share one declaration. P3 (cubic) — the README table listed `createIssue` for a combined create/edit row while an edit refusal names `updateIssue`. Split into two rows. Each fix has a must-fail/must-pass pair: the claim is not consumed and a permitted retry succeeds; the CLI blocks under a config-declared `app`; and `assertWritable` refuses without touching gh. Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22 --- README.md | 3 +- src/cli/fleet.test.ts | 62 ++++++++++++++++++++++++++++++++++ src/cli/fleet.ts | 38 +++++++++++++++++---- src/config/schema.ts | 12 ++++++- src/github/gh-identity.test.ts | 19 +++++++++++ src/intake/notion.test.ts | 37 ++++++++++++++++++++ src/intake/notion.ts | 24 +++++++++++++ 7 files changed, 187 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 47baa380..d4af7ab1 100644 --- a/README.md +++ b/README.md @@ -802,7 +802,8 @@ a human-attributed write: | Write | Refuses under `"app"` | Missing connected capability | |---|---|---| | Guarded squash merge (`mergePolicy: "on-green-with-review"`) | the merge is declined and logged; nothing is merged | `mergePullRequest` | -| Notion intake issue create/edit | the intake run fails with the reason | `createIssue` | +| Notion intake issue create | the run is blocked with the reason, before any durable claim is taken | `createIssue` | +| Notion intake issue edit | the run is blocked with the reason, before any durable claim is taken | `updateIssue` | Both refusals name the missing capability and the recovery path: set `github.identity` to `"user"` or `"auto"` to deliberately accept local-user diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 935c58cb..94975869 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -840,6 +840,68 @@ describe('fleet CLI runtime', () => { } }) + it('honours github.identity "app" for Notion intake instead of writing as the local gh user', async () => { + // The gate in GhCliIssuePublisher is worthless if this call site hardcodes + // an identity: this is the only production caller of runNotionIntake, so + // the refusal must be reachable from the resolved contract. + const root = await mkdtemp(join(tmpdir(), 'factory-cli-notion-identity-')) + try { + const mountedPage = join(root, 'notion', 'pages', '3b36800c-1c90-801d-b1cf-c8f2e1cff7cf') + await mkdir(mountedPage, { recursive: true }) + await writeFile(join(mountedPage, 'content.md'), [ + '# Chief Spec', + 'Status: ready', + 'Title: Verify identity gating', + 'Summary: Prove intake refuses under an app identity.', + 'Recipe: single', + 'Repos: AgentWorkforce/cloud', + ].join('\n')) + const manifestPath = join(root, 'notion.json') + await writeFile(manifestPath, JSON.stringify({ + version: 1, + mountRoot: './notion', + statePath: './state.json', + tasks: [{ page: '3b36800c1c90801db1cfc8f2e1cff7cf' }], + })) + const configPath = join(root, 'factory.config.json') + await writeFile(configPath, JSON.stringify({ + repos: { org: 'AgentWorkforce', names: ['cloud'] }, + github: { identity: 'app' }, + })) + + const durableClaims = new Map() + const notionClaims = { + get: vi.fn(async (sourceKey: string) => durableClaims.get(sourceKey)), + findBySourcePrefix: vi.fn(async () => []), + claim: vi.fn(async (claim: { sourceKey: string; digest: string; claimedAt: string }) => { + durableClaims.set(claim.sourceKey, claim) + return { status: 'claimed' as const, claim } + }), + dispose: vi.fn(async () => undefined), + } + const output = buffer() + + const code = await runFleetCli( + ['intake', 'notion', manifestPath, '--config', configPath], + { fleet: new FakeFleetClient(), notionClaims, stdout: output, stderr: buffer() }, + ) + + expect(code).toBe(1) + expect(JSON.parse(output.text())).toMatchObject({ + ok: false, + results: [{ + status: 'blocked', + reason: expect.stringContaining('GitHub identity "app"'), + }], + }) + // The refusal must not have consumed the exactly-once claim. + expect(notionClaims.claim).not.toHaveBeenCalled() + expect(durableClaims.size).toBe(0) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('returns after exact-path intake while preserving the spawned worker infrastructure', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-cli-notion-dispatch-')) try { diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 1c105b33..7b4a7431 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -7,6 +7,8 @@ import { ensureCloudSession, type CloudSession } from '@agent-relay/cloud' import { stringifyLogValue } from '../logging' import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths' +import { githubIdentitySchema } from '../config/schema' +import type { GithubWriteIdentity } from '../github' import { initializeFactory } from './init' import { diagnoseDeployedFactory, renderDeployedDiagnosis } from './diagnose' import { @@ -364,12 +366,11 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom manifest, dispatch: !globals.dryRun, ...(!globals.dryRun ? { - // Notion intake is a separate surface from the Factory lifecycle - // writeback and has no app-authored issue-create route, so its - // issues are deliberately authored by the operator's gh account. - // Stated explicitly here so the attribution is a decision on the - // record rather than an unnoticed default. - github: new GhCliIssuePublisher('user'), + // Notion intake has no app-authored issue-create route, so under an + // explicit `github.identity: "app"` it refuses rather than writing + // as the operator. Hardcoding `'user'` here would make that gate + // unreachable from the only production caller. + github: new GhCliIssuePublisher(await resolveNotionIntakeIdentity(globals.config)), workspace, ...(notionClaims ? { claims: notionClaims } : {}), ...(notionContracts ? { contracts: notionContracts } : {}), @@ -1676,6 +1677,31 @@ function parseFactoryStartFlags(args: Array): { mode: 'live' return { mode } } +/** + * The GitHub write identity Notion intake must honour. + * + * Notion intake does not otherwise require a Factory contract on disk, so an + * absent one is not an error — it is the same as an unset `github` block, + * which the schema synthesises to `auto`. A file that exists but cannot be + * read or parsed IS an error: silently degrading it to `auto` would resolve a + * deliberate `app` selection into the permissive value and reintroduce the + * silent local-user write this gate exists to prevent. + */ +async function resolveNotionIntakeIdentity(path?: string): Promise { + const configPath = path ?? resolve(process.cwd(), 'factory.config.json') + let raw: string + try { + raw = await readFile(configPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'auto' + throw error + } + const identity = githubIdentitySchema.parse( + (JSON.parse(raw) as { github?: { identity?: unknown } } | null)?.github?.identity, + ) + return identity +} + async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise { const configPath = path ?? resolve(process.cwd(), 'factory.config.json') const raw = JSON.parse(await readFile(configPath, 'utf8')) as unknown diff --git a/src/config/schema.ts b/src/config/schema.ts index 6e3370d9..6ff1e907 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -415,12 +415,22 @@ const previewSchema = z.object({ } }).optional() +/** + * The GitHub write identity, and the single place its values are declared. + * + * Exported because callers outside the loaded config must resolve the same + * value the schema would — notably the Notion intake CLI, which reads a + * contract that may not exist. Absent input becomes `auto`, which is the + * compatibility value, so this default must never be changed casually. + */ +export const githubIdentitySchema = z.enum(['app', 'user', 'auto']).default('auto') + const githubSchema = z.object({ // Controls the credential identity used for GitHub writes. Exact `app` // selects the connected App for both PR publication and issue lifecycle // writes. `auto` preserves compatibility: PRs prefer the App, while issue // lifecycle writes retain the operator's local `gh` authentication. - identity: z.enum(['app', 'user', 'auto']).default('auto'), + identity: githubIdentitySchema, }).default({}) const verificationSchema = z.object({ diff --git a/src/github/gh-identity.test.ts b/src/github/gh-identity.test.ts index 0346ce8d..d69987c6 100644 --- a/src/github/gh-identity.test.ts +++ b/src/github/gh-identity.test.ts @@ -158,6 +158,25 @@ describe('local gh mutations under github.identity', () => { ]) }) + it('MUST FIRE: the Notion refusal is raised by assertWritable, before any claim', () => { + // The refusal must be reachable WITHOUT calling createIssue, because + // publishRepoTask reserves an exactly-once delivery claim first. A + // refusal that only lived inside createIssue would burn that claim and + // permanently block the operator's retry under a permitted identity. + const { gh, calls } = fakeGh() + + expect(() => new GhCliIssuePublisher('app', gh).assertWritable()) + .toThrow(/GitHub identity "app"/u) + expect(calls).toEqual([]) + }) + + it('MUST NOT FIRE: assertWritable permits user and auto', () => { + for (const identity of ['user', 'auto'] as const) { + const { gh } = fakeGh() + expect(() => new GhCliIssuePublisher(identity, gh).assertWritable()).not.toThrow() + } + }) + it('MUST NOT FIRE: identity "app" leaves Notion intake READS working', async () => { // Reads carry no authorship, so gating them would break intake without // removing any attribution. diff --git a/src/intake/notion.test.ts b/src/intake/notion.test.ts index 7c85f143..d9ca9613 100644 --- a/src/intake/notion.test.ts +++ b/src/intake/notion.test.ts @@ -184,6 +184,43 @@ describe('Notion spec intake', () => { expect(github.createIssue).toHaveBeenCalledTimes(1) }) + it('refuses an app-identity intake WITHOUT consuming the exactly-once delivery claim', async () => { + // A refusal raised from createIssue would land after claimNotionDelivery, + // burning the claim: the operator's retry under a permitted identity would + // then hit `durable Notion claim already exists` forever. The policy check + // must happen before anything durable is reserved. + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'AgentWorkforce/cloud', labels: [] }), + }) + roots.push(root) + + const refusing = fakeGithub({ visibility: 'private' }) + refusing.assertWritable = () => { + throw new Error('GitHub identity "app" refuses creating or editing Notion intake lifecycle issues') + } + + const blocked = await runNotionIntake({ manifest, dispatch: true, claims, github: refusing }) + + expect(blocked.ok).toBe(false) + expect(blocked.results[0]).toMatchObject({ + status: 'blocked', + reason: expect.stringContaining('GitHub identity "app"'), + }) + // Nothing durable and nothing remote was touched. + expect(vi.mocked(claims.claim)).not.toHaveBeenCalled() + expect(durableClaims.size).toBe(0) + expect(refusing.createIssue).not.toHaveBeenCalled() + expect(refusing.repositoryVisibility).not.toHaveBeenCalled() + + // MUST NOT FIRE: the operator switches to a permitted identity and the + // retry succeeds, proving the aborted run left no wedge behind. + const permitted = fakeGithub({ visibility: 'private' }) + const retried = await runNotionIntake({ manifest, dispatch: true, claims, github: permitted }) + + expect(retried.ok).toBe(true) + expect(permitted.createIssue).toHaveBeenCalledTimes(1) + }) + it('preserves an explicit Factory title prefix without duplicating it', async () => { const { root, manifest } = await fixtureManifest('private mounted body', { bootstrap: { diff --git a/src/intake/notion.ts b/src/intake/notion.ts index b636c4d7..a133cc7c 100644 --- a/src/intake/notion.ts +++ b/src/intake/notion.ts @@ -96,6 +96,15 @@ export interface ExistingGithubIssue { export type GhCommandRunner = (args: string[], input?: string) => Promise export interface GithubIssuePublisher { + /** + * Refuse now if this publisher may not perform GitHub mutations at all. + * + * Called before any durable claim is reserved. An identity refusal raised + * from `createIssue` would arrive after `claimNotionDelivery` has already + * consumed the exactly-once claim, so the operator's retry under a + * permitted identity would then be blocked forever by its own aborted run. + */ + assertWritable?(): void repositoryVisibility(repo: string): Promise<'public' | 'private' | 'internal'> missingLabels(repo: string, labels: readonly string[]): Promise findBySource(repo: string, sourceKey: string): Promise @@ -405,6 +414,14 @@ export class GhCliIssuePublisher implements GithubIssuePublisher { this.#gh = gh } + assertWritable(): void { + assertLocalGhMutationAllowed( + this.#identity, + 'creating or editing Notion intake lifecycle issues', + 'createIssue/updateIssue', + ) + } + async repositoryVisibility(repo: string): Promise<'public' | 'private' | 'internal'> { const output = (await this.#gh(['repo', 'view', repo, '--json', 'visibility', '--jq', '.visibility'])).trim().toLowerCase() if (output !== 'public' && output !== 'private' && output !== 'internal') { @@ -465,6 +482,13 @@ async function publishRepoTask( } if (!input.dispatch) return base if (!input.github) return { ...base, status: 'blocked', reason: 'GitHub issue publisher is not configured' } + try { + // Ahead of every claim, receipt and network call below: a policy refusal + // must not consume the exactly-once delivery claim. + input.github.assertWritable?.() + } catch (error) { + return { ...base, status: 'blocked', reason: error instanceof Error ? error.message : String(error) } + } const receipt = state.receipts[task.sourceKey] if (receipt && receipt.kind !== 'github') { From c796f44d1f47e15cfb8989cdccaf96d85da3f55d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 11:39:51 +0200 Subject: [PATCH 4/5] fix(intake): read the split contract, and refuse at the mutation not the task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on #388; all three threads valid. P1 — `resolveNotionIntakeIdentity` read only a flat `github` key, so a split `workspaceConfig`/`nodeConfig` contract declaring `identity: "app"` resolved to `auto` and silently permitted the local-user write. It now reads both halves, node winning, matching `combineSplitConfigInput`'s spread order. P2 — the guard ran at the top of `publishRepoTask`, ahead of `findBySource`, `repositoryVisibility` and `missingLabels`, so read-only reconciliation and already-dispatched tasks were blocked under an app identity even though they write nothing. The refusal now sits immediately before each mutation: before `ensureNotionWorkUnitClaim` on the create path, so it still precedes the durable claim, and immediately before `updateIssue` on the reconciliation path. P3 — the README paragraph still described the hardcoded `"user"` this PR removed. Rewritten to describe the resolved identity, the split-contract rule, and the fact that only mutations refuse. `FleetCliDeps.notionGithub` is a test seam that receives the resolved identity, matching the existing `notionClaims`/`notionContracts` pattern, so the CLI test proves the wiring without invoking gh. Red/green for each: the flat-only reader resolves `auto` for a split contract; a top-of-task guard blocks reconciliation; the claim is not consumed. Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22 --- README.md | 17 ++++++++++++++--- src/cli/fleet.test.ts | 27 ++++++++++++++++++++++++--- src/cli/fleet.ts | 30 ++++++++++++++++++++++++++---- src/intake/notion.test.ts | 31 ++++++++++++++++++++++++++++++- src/intake/notion.ts | 24 +++++++++++++++++------- 5 files changed, 111 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d4af7ab1..b1cbdd4d 100644 --- a/README.md +++ b/README.md @@ -812,9 +812,20 @@ exactly as they always have. Neither refusal is reachable in the default cloud deployment, which runs `mergePolicy: "never"` and does not run Notion intake. Notion intake is a separate surface from the Factory lifecycle writeback and -still requires local `gh` authentication when enabled; its CLI entry point -states `"user"` explicitly so the attribution is a decision on the record -rather than an unnoticed default. +still requires local `gh` authentication when enabled. Its CLI entry point +resolves `github.identity` from the selected contract — including a split +`workspaceConfig`/`nodeConfig` contract, where the node half wins — so `"app"` +refuses while `"user"` and `"auto"` proceed. An absent contract resolves to +`"auto"`, matching the schema's own synthesis of an unset `github` block; a +contract that exists but cannot be parsed is an error rather than a silent +downgrade to the permissive value. + +Only the mutations refuse. Reconciliation of an already-dispatched task +performs no GitHub write, so it continues to work under `"app"`: the refusal is +raised immediately before the issue create or the issue edit, and in the create +path before the durable delivery claim is taken, so a refused run never +consumes the exactly-once claim and can be retried under a permitted +identity. Read paths are deliberately unaffected. `gh pr view` carries no authorship, so merge-gate reads, Notion intake label/visibility lookups, and the standalone diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 94975869..ee7e5726 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -25,6 +25,7 @@ import { import { MountAuthScopeError, mountAuthRemediation } from '../mount/mount-auth-error' import { DocumentStateStore, FileStateStore } from '../state/file-state-store' import { FakeFleetClient, FakeMountClient, withDeadline } from '../testing' +import { GhCliIssuePublisher } from '../intake/notion' import type { GithubConnectionRead, GithubConnectionWrite, GithubIssueLookup, GithubWriteback, LocalMountOptions, SpawnInput, SpawnResult } from '../ports' import type { HarnessDriverClientLike } from '../fleet/internal-fleet-client' import type { RelayMessaging } from '@agent-relay/sdk' @@ -864,9 +865,12 @@ describe('fleet CLI runtime', () => { tasks: [{ page: '3b36800c1c90801db1cfc8f2e1cff7cf' }], })) const configPath = join(root, 'factory.config.json') + // Deliberately the SPLIT contract shape. A reader that only looked at a + // flat `github` key would miss this and fall back to `auto`, silently + // permitting the local-user write. await writeFile(configPath, JSON.stringify({ - repos: { org: 'AgentWorkforce', names: ['cloud'] }, - github: { identity: 'app' }, + workspaceConfig: { repos: { org: 'AgentWorkforce', names: ['cloud'] } }, + nodeConfig: { github: { identity: 'app' } }, })) const durableClaims = new Map() @@ -880,12 +884,29 @@ describe('fleet CLI runtime', () => { dispose: vi.fn(async () => undefined), } const output = buffer() + // Capture the identity the CLI resolved, and keep the publisher's reads + // hermetic so the refusal is the only thing that can block the task. + const resolved: string[] = [] + const notionGithub = (identity: string) => { + resolved.push(identity) + const publisher = new GhCliIssuePublisher( + identity as 'app' | 'user' | 'auto', + async () => { throw new Error('gh must not be invoked in this test') }, + ) + return Object.assign(publisher, { + repositoryVisibility: async () => 'private' as const, + missingLabels: async () => [], + findBySource: async () => undefined, + }) + } const code = await runFleetCli( ['intake', 'notion', manifestPath, '--config', configPath], - { fleet: new FakeFleetClient(), notionClaims, stdout: output, stderr: buffer() }, + { fleet: new FakeFleetClient(), notionClaims, notionGithub, stdout: output, stderr: buffer() }, ) + // The CLI read the SPLIT contract's node half, not a flat github key. + expect(resolved).toEqual(['app']) expect(code).toBe(1) expect(JSON.parse(output.text())).toMatchObject({ ok: false, diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 7b4a7431..42d27007 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -106,6 +106,7 @@ import { } from '../version-info' import { GhCliIssuePublisher, + type GithubIssuePublisher, NotionApiFactoryTasksClient, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, @@ -181,6 +182,12 @@ export interface FleetCliDeps { notionContracts?: NotionContractPublisher /** Hermetic workspace-global Notion claim store for tests and alternate runtimes. */ notionClaims?: NotionIntakeClaimStore + /** + * Hermetic GitHub issue publisher for intake tests and alternate runtimes. + * Receives the identity resolved from the selected contract, so a test can + * assert the CLI honours `github.identity` without reaching the gh binary. + */ + notionGithub?: (identity: GithubWriteIdentity) => GithubIssuePublisher /** Hermetic Factory Tasks reader for manifest-generation tests and alternate runtimes. */ notionFactoryTasks?: FactoryTasksNotionClient /** Hermetic verification-environment sweep for CLI tests and alternate runtimes. */ @@ -370,7 +377,10 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom // explicit `github.identity: "app"` it refuses rather than writing // as the operator. Hardcoding `'user'` here would make that gate // unreachable from the only production caller. - github: new GhCliIssuePublisher(await resolveNotionIntakeIdentity(globals.config)), + github: await (async () => { + const identity = await resolveNotionIntakeIdentity(globals.config) + return deps.notionGithub?.(identity) ?? new GhCliIssuePublisher(identity) + })(), workspace, ...(notionClaims ? { claims: notionClaims } : {}), ...(notionContracts ? { contracts: notionContracts } : {}), @@ -1686,6 +1696,12 @@ function parseFactoryStartFlags(args: Array): { mode: 'live' * read or parsed IS an error: silently degrading it to `auto` would resolve a * deliberate `app` selection into the permissive value and reintroduce the * silent local-user write this gate exists to prevent. + * + * Both contract shapes are read. A split contract carries `workspaceConfig` + * and `nodeConfig`, and `combineSplitConfigInput` merges them as + * `{ ...workspace, ...node }`, so the node half wins — reading only the flat + * `github` key would miss a split contract's `identity: "app"` entirely and + * fall back to `auto`. */ async function resolveNotionIntakeIdentity(path?: string): Promise { const configPath = path ?? resolve(process.cwd(), 'factory.config.json') @@ -1696,10 +1712,16 @@ async function resolveNotionIntakeIdentity(path?: string): Promise asRecord(asRecord(half)?.github)?.identity + const declared = record && ( + Object.prototype.hasOwnProperty.call(record, 'workspaceConfig') || + Object.prototype.hasOwnProperty.call(record, 'nodeConfig') ) - return identity + // Node half wins, matching combineSplitConfigInput's spread order. + ? identityIn(record.nodeConfig) ?? identityIn(record.workspaceConfig) + : identityIn(record) + return githubIdentitySchema.parse(declared) } async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise { diff --git a/src/intake/notion.test.ts b/src/intake/notion.test.ts index d9ca9613..f51e0391 100644 --- a/src/intake/notion.test.ts +++ b/src/intake/notion.test.ts @@ -210,7 +210,9 @@ describe('Notion spec intake', () => { expect(vi.mocked(claims.claim)).not.toHaveBeenCalled() expect(durableClaims.size).toBe(0) expect(refusing.createIssue).not.toHaveBeenCalled() - expect(refusing.repositoryVisibility).not.toHaveBeenCalled() + // Reads carry no authorship and stay available: the refusal is raised at + // the mutation, not at the top of the task. + expect(refusing.repositoryVisibility).toHaveBeenCalled() // MUST NOT FIRE: the operator switches to a permitted identity and the // retry succeeds, proving the aborted run left no wedge behind. @@ -221,6 +223,33 @@ describe('Notion spec intake', () => { expect(permitted.createIssue).toHaveBeenCalledTimes(1) }) + it('still reconciles an already-dispatched task under an app identity, because it writes nothing', async () => { + // A blanket refusal at the top of publishRepoTask would break read-only + // reconciliation for every app-configured host. Only mutations refuse. + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'AgentWorkforce/cloud', labels: [] }), + }) + roots.push(root) + + const permitted = fakeGithub({ visibility: 'private' }) + const first = await runNotionIntake({ manifest, dispatch: true, claims, github: permitted }) + expect(first.ok).toBe(true) + const created = await vi.mocked(permitted.createIssue).mock.results[0]!.value as { number: number; url: string } + + const refusing = fakeGithub({ visibility: 'private' }) + refusing.assertWritable = () => { throw new Error('GitHub identity "app" refuses') } + refusing.findBySource = vi.fn(async () => ({ + ...created, + body: vi.mocked(permitted.createIssue).mock.calls[0]![0].body, + })) + + const reconciled = await runNotionIntake({ manifest, dispatch: true, claims, github: refusing }) + + expect(reconciled.ok).toBe(true) + expect(reconciled.results[0]).toMatchObject({ status: 'already-dispatched' }) + expect(refusing.updateIssue).not.toHaveBeenCalled() + }) + it('preserves an explicit Factory title prefix without duplicating it', async () => { const { root, manifest } = await fixtureManifest('private mounted body', { bootstrap: { diff --git a/src/intake/notion.ts b/src/intake/notion.ts index a133cc7c..9c8246e1 100644 --- a/src/intake/notion.ts +++ b/src/intake/notion.ts @@ -482,13 +482,6 @@ async function publishRepoTask( } if (!input.dispatch) return base if (!input.github) return { ...base, status: 'blocked', reason: 'GitHub issue publisher is not configured' } - try { - // Ahead of every claim, receipt and network call below: a policy refusal - // must not consume the exactly-once delivery claim. - input.github.assertWritable?.() - } catch (error) { - return { ...base, status: 'blocked', reason: error instanceof Error ? error.message : String(error) } - } const receipt = state.receipts[task.sourceKey] if (receipt && receipt.kind !== 'github') { @@ -555,6 +548,13 @@ async function publishRepoTask( } const delivery = await prepareContractDelivery(task, input, receipt?.delivery ?? bodyDelivery) if (delivery && !bodyDelivery) { + // The only mutation on the reconciliation path. Everything above it is + // a read and stays available under an app identity. + try { + input.github.assertWritable?.() + } catch (error) { + return { ...base, status: 'blocked', issue: existing, reason: error instanceof Error ? error.message : String(error) } + } await assertMountedTaskUnchanged(task) await input.github.updateIssue({ repo: target.repo, @@ -584,6 +584,16 @@ async function publishRepoTask( if (missing.length > 0) { return { ...base, status: 'blocked', reason: `missing required GitHub labels: ${missing.join(', ')}` } } + // A create is now certain, so refuse here if this publisher may not write. + // Deliberately after the read-only checks above -- reconciliation and + // already-dispatched tasks need no mutation and must keep working under an + // app identity -- and deliberately before the first durable claim, so a + // policy refusal never consumes the exactly-once claim. + try { + input.github.assertWritable?.() + } catch (error) { + return { ...base, status: 'blocked', reason: error instanceof Error ? error.message : String(error) } + } await ensureNotionWorkUnitClaim(task, input) const delivery = await prepareContractDelivery(task, input) const claim = await claimNotionDelivery(task, input) From f2270c09d38f9123e29c634e5cf934980c64ea94 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 26 Aug 2026 12:27:13 +0200 Subject: [PATCH 5/5] fix(intake): refuse before portable delivery side effects Session-Id: 01a03d7c-82f6-7b60-9500-45a3f158870b --- src/cli/fleet.test.ts | 25 ++++++++++++++++++ src/cli/fleet.ts | 22 +++++----------- src/intake/notion.test.ts | 55 +++++++++++++++++++++++++++++++++++++++ src/intake/notion.ts | 31 ++++++++++++---------- 4 files changed, 103 insertions(+), 30 deletions(-) diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index ee7e5726..bb9053b4 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -897,6 +897,7 @@ describe('fleet CLI runtime', () => { repositoryVisibility: async () => 'private' as const, missingLabels: async () => [], findBySource: async () => undefined, + createIssue: async () => ({ number: 42, url: 'https://github.test/issues/42' }), }) } @@ -918,6 +919,30 @@ describe('fleet CLI runtime', () => { // The refusal must not have consumed the exactly-once claim. expect(notionClaims.claim).not.toHaveBeenCalled() expect(durableClaims.size).toBe(0) + + // Split config is a shallow top-level merge. An explicitly present, + // empty node github block replaces the workspace block, so the schema + // default is `auto`; falling back to the workspace identity here would + // disagree with loadFactoryConfig. + await writeFile(configPath, JSON.stringify({ + workspaceConfig: { + repos: { org: 'AgentWorkforce', names: ['cloud'] }, + github: { identity: 'app' }, + }, + nodeConfig: { github: {} }, + })) + const permittedOutput = buffer() + const permittedCode = await runFleetCli( + ['intake', 'notion', manifestPath, '--config', configPath], + { fleet: new FakeFleetClient(), notionClaims, notionGithub, stdout: permittedOutput, stderr: buffer() }, + ) + + expect(resolved).toEqual(['app', 'auto']) + expect(permittedCode).toBe(0) + expect(JSON.parse(permittedOutput.text())).toMatchObject({ + ok: true, + results: [{ status: 'dispatched' }], + }) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 42d27007..3e4e65c8 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -7,7 +7,7 @@ import { ensureCloudSession, type CloudSession } from '@agent-relay/cloud' import { stringifyLogValue } from '../logging' import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths' -import { githubIdentitySchema } from '../config/schema' +import { loadFactoryConfig } from '../config/schema' import type { GithubWriteIdentity } from '../github' import { initializeFactory } from './init' import { diagnoseDeployedFactory, renderDeployedDiagnosis } from './diagnose' @@ -1697,11 +1697,10 @@ function parseFactoryStartFlags(args: Array): { mode: 'live' * deliberate `app` selection into the permissive value and reintroduce the * silent local-user write this gate exists to prevent. * - * Both contract shapes are read. A split contract carries `workspaceConfig` - * and `nodeConfig`, and `combineSplitConfigInput` merges them as - * `{ ...workspace, ...node }`, so the node half wins — reading only the flat - * `github` key would miss a split contract's `identity: "app"` entirely and - * fall back to `auto`. + * Use the canonical config loader for both contract shapes. In particular, + * split config is a shallow top-level merge: a present `nodeConfig.github` + * replaces `workspaceConfig.github` even when the node block is empty, after + * which the GitHub schema supplies its `auto` default. */ async function resolveNotionIntakeIdentity(path?: string): Promise { const configPath = path ?? resolve(process.cwd(), 'factory.config.json') @@ -1712,16 +1711,7 @@ async function resolveNotionIntakeIdentity(path?: string): Promise asRecord(asRecord(half)?.github)?.identity - const declared = record && ( - Object.prototype.hasOwnProperty.call(record, 'workspaceConfig') || - Object.prototype.hasOwnProperty.call(record, 'nodeConfig') - ) - // Node half wins, matching combineSplitConfigInput's spread order. - ? identityIn(record.nodeConfig) ?? identityIn(record.workspaceConfig) - : identityIn(record) - return githubIdentitySchema.parse(declared) + return loadFactoryConfig(JSON.parse(raw)).factoryConfig.github.identity } async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise { diff --git a/src/intake/notion.test.ts b/src/intake/notion.test.ts index f51e0391..55951b12 100644 --- a/src/intake/notion.test.ts +++ b/src/intake/notion.test.ts @@ -385,6 +385,61 @@ describe('Notion spec intake', () => { expect(reconciled.receipts[`notion:${pageId}:repo:agentworkforce/cloud`].delivery.messageIds).toEqual(['message-1']) }) + it('refuses portable issue migration before its claim or contract publication while preserving metadata reconciliation', async () => { + const { root, manifest } = await fixtureManifest('private mounted implementation detail', { + bootstrap: bootstrap({ repo: 'AgentWorkforce/cloud', labels: [] }), + }) + roots.push(root) + const github = fakeGithub({ visibility: 'private' }) + await runNotionIntake({ manifest, dispatch: true, claims, github }) + const originalBody = vi.mocked(github.createIssue).mock.calls[0]![0].body + vi.mocked(github.findBySource).mockResolvedValue({ + number: 42, + url: 'https://github.test/issues/42', + body: originalBody, + }) + manifest.workerMountTransport = { kind: 'relay-channel' } + const contracts: NotionContractPublisher = { + publish: vi.fn(async () => ({ + kind: 'relay-channel', + channel: 'factory-notion-e1cff7cf-aabbccddee', + messageIds: ['message-1'], + encoding: 'base64-chunks-v1', + })), + } + durableClaims.clear() + vi.mocked(claims.claim).mockClear() + github.assertWritable = () => { throw new Error('GitHub identity "app" refuses') } + + const blocked = await runNotionIntake({ manifest, dispatch: true, claims, github, contracts }) + + expect(blocked.results[0]).toMatchObject({ + status: 'blocked', + reason: expect.stringContaining('GitHub identity "app"'), + }) + expect(claims.claim).not.toHaveBeenCalled() + expect(durableClaims.size).toBe(0) + expect(contracts.publish).not.toHaveBeenCalled() + expect(github.updateIssue).not.toHaveBeenCalled() + + delete github.assertWritable + const migrated = await runNotionIntake({ manifest, dispatch: true, claims, github, contracts }) + expect(migrated.results[0]).toMatchObject({ status: 'already-dispatched' }) + const migratedBody = vi.mocked(github.updateIssue).mock.calls[0]![0].body + vi.mocked(github.findBySource).mockResolvedValue({ + number: 42, + url: 'https://github.test/issues/42', + body: migratedBody, + }) + github.assertWritable = () => { throw new Error('GitHub identity "app" refuses') } + vi.mocked(github.updateIssue).mockClear() + + const reconciled = await runNotionIntake({ manifest, dispatch: true, claims, github, contracts }) + + expect(reconciled.results[0]).toMatchObject({ status: 'already-dispatched' }) + expect(github.updateIssue).not.toHaveBeenCalled() + }) + it('refuses to overwrite a manually edited lifecycle issue during portable mount migration', async () => { const { root, manifest } = await fixtureManifest('private mounted implementation detail', { bootstrap: bootstrap({ repo: 'AgentWorkforce/cloud', labels: [] }), diff --git a/src/intake/notion.ts b/src/intake/notion.ts index 9c8246e1..d18a3898 100644 --- a/src/intake/notion.ts +++ b/src/intake/notion.ts @@ -503,24 +503,22 @@ async function publishRepoTask( if (currentDigest !== task.digest) { return { ...base, status: 'blocked', issue: existing, reason: 'mounted spec changed after the lifecycle issue was created' } } - await ensureNotionWorkUnitClaim(task, input) let claim = await observeNotionDeliveryClaim(task, input) - if (!claim) { - if (!receipt) { - return { - ...base, - status: 'blocked', - issue: existing, - reason: 'lifecycle issue marker has neither a durable shared claim nor a local migration receipt', - } + if (!claim && !receipt) { + return { + ...base, + status: 'blocked', + issue: existing, + reason: 'lifecycle issue marker has neither a durable shared claim nor a local migration receipt', } - claim = (await claimNotionDelivery(task, input)).claim } const bodyDelivery = contractDeliveryFromBody(existing.body) if (input.manifest.workerMountTransport.kind === 'local') { if (receipt?.delivery || bodyDelivery) { return { ...base, status: 'blocked', issue: existing, reason: 'portable Notion delivery cannot be downgraded to a local worker mount' } } + await ensureNotionWorkUnitClaim(task, input) + claim ??= (await claimNotionDelivery(task, input)).claim state.receipts[task.sourceKey] = receipt ?? { kind: 'github', digest: task.digest, @@ -546,15 +544,20 @@ async function publishRepoTask( if (receipt?.delivery && bodyDelivery && !sameContractDelivery(receipt.delivery, bodyDelivery)) { return { ...base, status: 'blocked', issue: existing, reason: 'lifecycle issue portable delivery does not match its local receipt cache' } } - const delivery = await prepareContractDelivery(task, input, receipt?.delivery ?? bodyDelivery) - if (delivery && !bodyDelivery) { - // The only mutation on the reconciliation path. Everything above it is - // a read and stays available under an app identity. + if (!bodyDelivery) { + // A portable migration must edit the issue. Refuse before either claim + // can be reserved and before the Relay contract publisher emits any + // messages. Existing body metadata takes the read-only path below. try { input.github.assertWritable?.() } catch (error) { return { ...base, status: 'blocked', issue: existing, reason: error instanceof Error ? error.message : String(error) } } + } + await ensureNotionWorkUnitClaim(task, input) + claim ??= (await claimNotionDelivery(task, input)).claim + const delivery = await prepareContractDelivery(task, input, receipt?.delivery ?? bodyDelivery) + if (delivery && !bodyDelivery) { await assertMountedTaskUnchanged(task) await input.github.updateIssue({ repo: target.repo,