Skip to content
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ After init, add the `factory` label to an open issue and run a dry run below.
1. **Connect GitHub to your relay workspace** with push access for the target
repositories. Factory uses that workspace connection to publish branches and
open pull requests by default. A local `gh` installation and `gh auth login`
are required only when `github.identity` selects the user path (or for the
existing GitHub issue lifecycle writeback described below). If a required
are required when `github.identity` is `"user"`, or when the default
`"auto"` mode uses the compatibility GitHub issue lifecycle path. Exact
`"app"` mode needs no local GitHub credential. If a required
connection is missing, an interactive Factory command offers to open the
Relayfile connection flow and waits for it to finish. Linear-backed operations
require both Linear and GitHub; GitHub-native operations require GitHub.
Expand Down Expand Up @@ -691,7 +692,7 @@ an invalid config fails fast with a field-level error. See
and [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json) for a
worked example (including offline fixture mode).

Factory PR authorship is controlled explicitly with `github.identity`:
Factory GitHub write attribution is controlled explicitly with `github.identity`:

```jsonc
{
Expand All @@ -701,19 +702,27 @@ Factory PR authorship is controlled explicitly with `github.identity`:
}
```

- `"app"` always publishes through the connected workspace GitHub App. If that
write path is unavailable, Factory fails loudly and never falls back to a
personal account.
- `"user"` always publishes with the account authenticated by the local `gh`
CLI, even when the app path is available.
- `"auto"` is the default and preserves compatibility: prefer the app path,
then fall back to the local `gh` user when the app writer is unavailable.
- `"app"` publishes pull requests and performs GitHub issue lifecycle writes
through the connected workspace GitHub App. Status transitions provision the
target Factory label, add only that label, and remove only the prior Factory
label, so labels applied by people are never replaced from a stale mount
projection. If any required write capability is unavailable, Factory fails
loudly and never falls back to a personal account.
- `"user"` publishes pull requests and performs issue lifecycle writes with the
account authenticated by the local `gh` CLI, even when the app path is
available.
- `"auto"` is the default and preserves compatibility: pull requests prefer the
App path and fall back to the local `gh` user, while issue comments, status
labels, and issue closure retain the local `gh` path.

Each successful publication log includes `identity` (`app` or `user`) and the
confirmed `author`. This setting currently controls PR creation only. GitHub
issue comments and lifecycle status labels still use the existing local `gh`
writeback; extending the identity policy to those operations requires a
connected-app issue writeback surface.
confirmed `author`. App writes are not reported complete until the connected
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.

Authenticated Factory progress reporting is enabled by default for real CLI
sessions. Factory sends privacy-bounded lifecycle events, worker ownership,
Expand Down
107 changes: 107 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { DocumentStateStore, FileStateStore } from '../state/file-state-store'
import { FakeFleetClient, FakeMountClient, withDeadline } from '../testing'
import type { GithubConnectionRead, GithubConnectionWrite, GithubIssueLookup, LocalMountOptions, SpawnInput, SpawnResult } from '../ports'
import type { HarnessDriverClientLike } from '../fleet/internal-fleet-client'
import { factoryGithubIssueCommentDraftName } from '../github/writeback-paths'
import { ensureLocalMount as runLocalMountPreflight } from '../mount/local-mount-preflight'
import { formatLogArgs, installFactoryStopSignalHandlers, parseFleetCommand, parseGithubIssueSelector, parseGlobalOptions, reportFactoryVersionDrift, resolveBrokerConnectionPath, runFleetCli } from './fleet'

Expand Down Expand Up @@ -3367,6 +3368,112 @@ describe('fleet CLI runtime', () => {
expect(JSON.parse(output.text())).toEqual({ repo: 'AgentWorkforce/pear', prNumber: 42, state: 'CLOSED' })
})

it('scope-checks GitHub lifecycle drafts in the ordinary CLI cloud mount', async () => {
const root = await mkdtemp(join(tmpdir(), 'factory-cli-github-draft-scope-'))
try {
const configPath = await writeConfig(root, { issueSource: 'github' })
const outOfScopeIssue = githubIssueFile('pear', 222)
outOfScopeIssue.payload.labels = [{ name: 'bug' }]
const closedIssue = githubIssueFile('pear', 223)
closedIssue.payload.state = 'closed'
const integrations = fakeIntegrationConnections(async () => ({ ready: true, state: 'ready' }))
const mount = mountWithIntegrationConnections({
'/github/repos/AgentWorkforce/pear/issues/by-id/221.json': githubIssueFile('pear', 221),
'/github/repos/AgentWorkforce/pear/issues/by-id/222.json': outOfScopeIssue,
'/github/repos/AgentWorkforce/pear/issues/by-id/223.json': closedIssue,
}, integrations)
let predicate: (
path: string,
content: unknown,
opts?: { guarded?: boolean },
) => boolean | Promise<boolean> = () => false
const cloudMountFromConfig = vi.fn(async (opts) => {
predicate = opts?.isAllowedDraft ?? predicate
return mount
})
const factory = {
status: vi.fn(() => ({ inFlight: [], queued: [], counters: {} })),
} as unknown as Factory

const code = await runFleetCli(['status', '--config', configPath], {
cloudMountFromConfig,
fleet: new FakeFleetClient(),
createFactory: () => factory,
stdout: buffer(),
stderr: buffer(),
})

expect(code).toBe(0)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221.json',
{ state: 'closed' },
{ guarded: true },
)).resolves.toBe(true)
const commentBody = 'Factory dispatch'
await expect(predicate(
`/github/repos/AgentWorkforce/pear/issues/221/comments/${factoryGithubIssueCommentDraftName(commentBody)}`,
{ body: commentBody },
{ guarded: true },
)).resolves.toBe(true)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221/comments/factory-abcdef012345abcdef012345.json',
{ body: commentBody },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/labels/factory-11111111-1111-4111-8111-111111111111.json',
{
name: 'factory:in-progress',
color: '1d76db',
description: 'Factory agents are working on this issue.',
},
{ guarded: true },
)).resolves.toBe(true)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221/labels/factory-22222222-2222-4222-8222-222222222222.json',
{ operation: 'add', labels: ['factory:in-progress'] },
{ guarded: true },
)).resolves.toBe(true)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221.json',
{ labels: ['factory:in-progress'] },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221/labels/factory-33333333-3333-4333-8333-333333333333.json',
{ operation: 'remove', label: 'bug' },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221/comments/operator.json',
{ body: 'unscoped draft' },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/221/comments/factory-abcdef012345.json',
{ body: 'wrong-length digest' },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/222.json',
{ state: 'closed' },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/AgentWorkforce/pear/issues/223.json',
{ state: 'closed' },
{ guarded: true },
)).resolves.toBe(false)
await expect(predicate(
'/github/repos/OtherOrg/other/issues/221.json',
{ state: 'closed' },
{ guarded: true },
)).resolves.toBe(false)
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('runs factory loop through the bounded runner and emits a heartbeat-backed status', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-loop-'))
try {
Expand Down
16 changes: 4 additions & 12 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
explicitLinkedIssueKey,
githubIssuePathParts,
githubWatchStatePath,
isAllowedFactoryGithubDraft,
isAllowedFactoryGithubArtifactDraft,
isInFactoryScope,
parseGithubFactoryIssue,
parseLinearIssue,
Expand Down Expand Up @@ -305,7 +307,7 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
const workspaceId = (await (deps.resolveWorkspace ?? resolveFactoryWorkspace)()).workspaceId
mount = deps.mount ?? await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
workspaceId,
isAllowedDraft: (path, _content, opts) => isAllowedFactoryGithubDraft(path, opts),
isAllowedDraft: (path, _content, opts) => isAllowedFactoryGithubArtifactDraft(path, opts),
})
await prepareFactoryIntegrations(command, mount, undefined, globals, deps, workspaceId, err)
githubWrite = mount.githubWrite
Expand Down Expand Up @@ -1930,21 +1932,11 @@ async function isAllowedFactoryDraft(
return true
}

if (isAllowedFactoryGithubDraft(path, opts)) {
return true
}
if (await isAllowedFactoryGithubDraft(path, content, opts, mount, config)) return true

return false
}

const isFactoryGithubWritebackPath = (path: string): boolean =>
/^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path)

const isAllowedFactoryGithubDraft = (
path: string,
opts: { guarded?: boolean } | undefined,
): boolean => opts?.guarded === true && isFactoryGithubWritebackPath(path)

const scopeIssueFromDraftContent = (content: unknown) => ({
title: typeof asRecord(content)?.title === 'string' ? asRecord(content)?.title as string : '',
team: typeof asRecord(asRecord(content)?.team)?.key === 'string'
Expand Down
8 changes: 4 additions & 4 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,10 @@ const previewSchema = z.object({
}).optional()

const githubSchema = z.object({
// Controls the credential identity used when Factory creates pull requests.
// `auto` preserves the compatibility behavior: prefer the connected
// workspace GitHub App, then use the operator's local `gh` authentication
// when the app write path is unavailable.
// 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'),
}).default({})

Expand Down
32 changes: 32 additions & 0 deletions src/github/writeback-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createHash } from 'node:crypto'

const FACTORY_ISSUE_COMMENT_DRAFT_PREFIX = 'factory-'
const FACTORY_ISSUE_COMMENT_DIGEST_LENGTH = 24
const FACTORY_GITHUB_OPERATION_DRAFT = /^factory-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/iu

/** Stable filename contract shared by the App writer and the mount guard. */
export const factoryGithubIssueCommentDraftName = (body: string): string => {
const digest = createHash('sha256')
.update(body)
.digest('hex')
.slice(0, FACTORY_ISSUE_COMMENT_DIGEST_LENGTH)
return `${FACTORY_ISSUE_COMMENT_DRAFT_PREFIX}${digest}.json`
}

export const isFactoryGithubIssueCommentDraftName = (value: string): boolean => {
if (!value.startsWith(FACTORY_ISSUE_COMMENT_DRAFT_PREFIX) || !value.endsWith('.json')) return false
const digest = value.slice(FACTORY_ISSUE_COMMENT_DRAFT_PREFIX.length, -'.json'.length)
return digest.length === FACTORY_ISSUE_COMMENT_DIGEST_LENGTH && /^[a-f0-9]+$/u.test(digest)
}

/** Unique operation drafts force every lifecycle transition through writeback. */
export const factoryGithubOperationDraftName = (operationId: string): string => {
const name = `factory-${operationId}.json`
if (!isFactoryGithubOperationDraftName(name)) {
throw new Error(`GitHub operation id must be a UUID: ${operationId}`)
}
return name
}

export const isFactoryGithubOperationDraftName = (value: string): boolean =>
FACTORY_GITHUB_OPERATION_DRAFT.test(value)
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export {
findDependencyCycle,
issueKey,
isDispatchableIssue,
isAllowedFactoryGithubDraft,
isAllowedFactoryGithubArtifactDraft,
isLiveDispatchStateChangedError,
isRealLinearIssue,
githubIssuePathParts,
Expand Down Expand Up @@ -182,6 +184,8 @@ export type {
LlmTriageOptions,
} from './triage'
export {
AppGithubWriteback,
FACTORY_GITHUB_STATUS_LABELS,
GhCliGithubWriteback,
FACTORY_MOUNT_HEALTH_PATH,
linearCommentName,
Expand Down
Loading