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
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,47 @@ 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 | the run is blocked with the reason, before any durable claim is taken | `createIssue` |
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
| 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
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
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
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,
Expand Down
108 changes: 108 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -840,6 +841,113 @@ 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')
// 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({
workspaceConfig: { repos: { org: 'AgentWorkforce', names: ['cloud'] } },
nodeConfig: { github: { identity: 'app' } },
}))

const durableClaims = new Map<string, { sourceKey: string; digest: string; claimedAt: string }>()
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()
// 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,
createIssue: async () => ({ number: 42, url: 'https://github.test/issues/42' }),
})
}

const code = await runFleetCli(
['intake', 'notion', manifestPath, '--config', configPath],
{ 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,
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)

// 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 })
}
})

it('returns after exact-path intake while preserving the spawned worker infrastructure', async () => {
const root = await mkdtemp(join(tmpdir(), 'factory-cli-notion-dispatch-'))
try {
Expand Down
45 changes: 44 additions & 1 deletion src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { loadFactoryConfig } from '../config/schema'
import type { GithubWriteIdentity } from '../github'
import { initializeFactory } from './init'
import { diagnoseDeployedFactory, renderDeployedDiagnosis } from './diagnose'
import {
Expand Down Expand Up @@ -104,6 +106,7 @@ import {
} from '../version-info'
import {
GhCliIssuePublisher,
type GithubIssuePublisher,
NotionApiFactoryTasksClient,
RelayChannelNotionClaimStore,
RelayChannelNotionContractPublisher,
Expand Down Expand Up @@ -179,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. */
Expand Down Expand Up @@ -364,7 +373,14 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
manifest,
dispatch: !globals.dryRun,
...(!globals.dryRun ? {
github: new GhCliIssuePublisher(),
// 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: await (async () => {
const identity = await resolveNotionIntakeIdentity(globals.config)
return deps.notionGithub?.(identity) ?? new GhCliIssuePublisher(identity)
})(),
workspace,
...(notionClaims ? { claims: notionClaims } : {}),
...(notionContracts ? { contracts: notionContracts } : {}),
Expand Down Expand Up @@ -1671,6 +1687,33 @@ function parseFactoryStartFlags(args: Array<string | undefined>): { 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.
*
* 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<GithubWriteIdentity> {
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
}
return loadFactoryConfig(JSON.parse(raw)).factoryConfig.github.identity
}

async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise<LoadedConfig> {
const configPath = path ?? resolve(process.cwd(), 'factory.config.json')
const raw = JSON.parse(await readFile(configPath, 'utf8')) as unknown
Expand Down
12 changes: 11 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading