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
2 changes: 2 additions & 0 deletions packages/core/src/application/HITLService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class HITLService {
...(draft.externalReferences ? { externalReferences: draft.externalReferences } : {}),
owner: draft.submitterId ?? 'system',
...(submitter?.displayName ? { ownerDisplayName: submitter.displayName } : {}),
...(submitter?.kind ? { ownerKind: submitter.kind } : {}),
})
return this.withLockedWorkspace(draft.workspaceId, async (workspace) => {
await this.deps.proposalRepository.save(proposal)
Expand Down Expand Up @@ -115,6 +116,7 @@ export class HITLService {
...(draft.ambiguityType ? { ambiguityType: draft.ambiguityType } : {}),
owner: draft.submitterId ?? 'system',
...(submitter?.displayName ? { ownerDisplayName: submitter.displayName } : {}),
...(submitter?.kind ? { ownerKind: submitter.kind } : {}),
})
return this.withLockedWorkspace(draft.workspaceId, async (workspace) => {
await this.deps.clarificationRepository.save(ticket)
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/application/ReactorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export interface ReactorServiceDeps {
readonly skillRunner: SkillRunner
readonly sourceUnitObservationService: SourceUnitObservationService
readonly unitLister: UnitLister
/**
* Yields the caller token the reactor's autonomous skill runs act under.
* The reactor has no human caller, so it runs as the `reactor` service account,
* and its proposals are owned by it. Absent means run tokenless,
* which falls back to the deployment's default principal.
*/
readonly reactorToken?: () => Promise<string | undefined>
readonly digest: SourceUnitDigest
readonly reactorCycleRepository: ReactorCycleRepository
readonly clock: Clock
Expand Down Expand Up @@ -104,6 +111,8 @@ interface CycleContext {
readonly workspace: Workspace
readonly sourceId: SourceId
readonly batchBinding: OntologyBatchBinding
// Token the skill runs act under, resolved once per cycle.
readonly callerToken?: string
cycle: ReactorCycle
}

Expand Down Expand Up @@ -224,6 +233,7 @@ export class ReactorService {
if (!batchBinding?.perUnit?.skillId)
return undefined
const startedAt = this.deps.clock.now()
const callerToken = await this.deps.reactorToken?.()
const cycle: ReactorCycle = {
id: newReactorCycleId(),
workspaceId: workspace.id,
Expand All @@ -232,7 +242,7 @@ export class ReactorService {
status: 'dispatched',
units: [],
}
return { workspace, sourceId: event.sourceId, batchBinding, cycle }
return { workspace, sourceId: event.sourceId, batchBinding, cycle, ...(callerToken ? { callerToken } : {}) }
}

private async changedUnitPaths(context: CycleContext): Promise<readonly string[]> {
Expand Down Expand Up @@ -270,7 +280,7 @@ export class ReactorService {
let runId: SkillRunId
try {
const args = argsForPath(batchBinding.perUnit, path)
runId = await this.deps.skillRunner.start(workspace, batchBinding.perUnit.skillId, args)
runId = await this.deps.skillRunner.start(workspace, batchBinding.perUnit.skillId, args, context.callerToken ? { callerToken: context.callerToken } : undefined)
context.cycle = updateUnit(cycle, index, { status: 'running', skillRunId: runId, startedAt: this.deps.clock.now() })
await this.persistAndEmit(context, this.unitStartedEvent(context, index, runId, total))
await waitForCompletion(this.deps.skillRunner, runId)
Expand Down Expand Up @@ -313,7 +323,7 @@ export class ReactorService {
const { workspace } = context
const startedAt = this.deps.clock.now()
try {
const runId = await this.deps.skillRunner.start(workspace, skillId, '')
const runId = await this.deps.skillRunner.start(workspace, skillId, '', context.callerToken ? { callerToken: context.callerToken } : undefined)
context.cycle = updateCheckpoint(context.cycle, { skillId, status: 'running', skillRunId: runId, startedAt })
await this.persistAndEmit(context, this.checkpointStartedEvent(context, skillId, runId))
await waitForCompletion(this.deps.skillRunner, runId)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/hitl/Clarification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class Clarification {
get proposalId(): ProposalId | undefined { return this.data.proposalId }
get owner(): Actor { return this.data.owner }
get ownerDisplayName(): string | undefined { return this.data.ownerDisplayName }
get ownerKind(): ClarificationData['ownerKind'] { return this.data.ownerKind }
get externalReferences(): readonly ExternalReference[] | undefined { return this.data.externalReferences }

resolveCandidate(candidateId: ClarificationCandidateId): readonly GraphOperation[] {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/hitl/Proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class Proposal {
get reviewedBy(): UserId | undefined { return this.data.reviewedBy }
get owner(): Actor { return this.data.owner }
get ownerDisplayName(): string | undefined { return this.data.ownerDisplayName }
get ownerKind(): ProposalData['ownerKind'] { return this.data.ownerKind }
get externalReferences(): readonly ExternalReference[] | undefined { return this.data.externalReferences }

// Returns a new Proposal in 'applied' state. Caller must persist the new instance and run the operations,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/domain/users/UserDirectory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { UserId } from '@braidhq/schema'
import type { UserId, UserKind } from '@braidhq/schema'

/**
* Read-only lookup of a userId's human-facing fields.
Expand All @@ -17,6 +17,9 @@ export interface UserDirectory {
export interface UserAuthor {
readonly displayName: string
readonly email?: string
// Snapshotted onto proposals and tickets at submit time,
// so the HITL views classify an autonomous owner without a read-time lookup.
readonly kind?: UserKind
}

/** Drop-in directory that always returns null. Used by tests and the in-memory composition. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export class InMemoryClarificationRepository implements ClarificationRepository
}
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
tickets = tickets.filter(ticket =>
ticket.status !== 'pending' || ticket.owner === 'system' || ticket.owner === viewerId,
ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'),
)
}
return paginate(tickets, filter?.limit, filter?.offset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export class InMemoryProposalRepository implements ProposalRepository {
}
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
proposals = proposals.filter(proposal =>
proposal.status !== 'pending' || proposal.owner === 'system' || proposal.owner === viewerId,
proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'),
)
}
return paginate(proposals, filter?.limit, filter?.offset)
Expand Down
27 changes: 24 additions & 3 deletions packages/core/test/application/ReactorService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,17 @@ function secondarySource(id: string): SourceDescriptor {
}

class FakeSkillRunner implements SkillRunner {
readonly startCalls: Array<{ skillId: SkillId, args: string }> = []
readonly startCalls: Array<{ skillId: SkillId, args: string, callerToken?: string }> = []
private readonly listeners = new Map<SkillRunId, SkillEventListener>()
// When set, start() defers completion until flushOne fires it.
controlled = false
// Pending completions the test fires manually while controlled.
private readonly pending: Array<() => void> = []
exitCodes: number[] = []

async start(_workspace: unknown, skillId: SkillId, args: string): Promise<SkillRunId> {
async start(_workspace: unknown, skillId: SkillId, args: string, options?: { callerToken?: string }): Promise<SkillRunId> {
const runId = `r-${this.startCalls.length}` as SkillRunId
this.startCalls.push({ skillId, args })
this.startCalls.push({ skillId, args, ...(options?.callerToken ? { callerToken: options.callerToken } : {}) })
const code = this.exitCodes.shift() ?? 0
const fire = () => {
const listener = this.listeners.get(runId)
Expand Down Expand Up @@ -109,6 +109,7 @@ async function setup(opts: {
hasPerUnit?: boolean
hasCheckpoint?: boolean
maxRunsPerHour?: number
reactorToken?: string
} = {}) {
resetTestIds()
const workspaceRepo = new InMemoryWorkspaceRepository()
Expand Down Expand Up @@ -200,6 +201,7 @@ async function setup(opts: {
skillRunner,
sourceUnitObservationService,
unitLister,
...(opts.reactorToken ? { reactorToken: async () => opts.reactorToken } : {}),
digest,
reactorCycleRepository,
workspaceLock: new WorkspaceLock(),
Expand Down Expand Up @@ -240,6 +242,25 @@ async function tick(ms = 20): Promise<void> {
}

describe('ReactorService', () => {
it('runs its skills under the reactor caller token, so proposals are owned by it', async () => {
const { workspace, eventBus, skillRunner } = await setup({ hasCheckpoint: true, reactorToken: 'reactor-token' })
emitSync(eventBus, workspace.id, 'issues')
await tick(100)

// Every dispatched run, per-unit and checkpoint, carries the token.
expect(skillRunner.startCalls.length).toBeGreaterThan(0)
expect(skillRunner.startCalls.every(c => c.callerToken === 'reactor-token')).toBe(true)
})

it('runs tokenless when no reactor token is provided', async () => {
const { workspace, eventBus, skillRunner } = await setup()
emitSync(eventBus, workspace.id, 'issues')
await tick(100)

expect(skillRunner.startCalls.length).toBeGreaterThan(0)
expect(skillRunner.startCalls.every(c => c.callerToken === undefined)).toBe(true)
})

it('dispatches one per-unit run for each of three new units, then one checkpoint', async () => {
const { workspace, eventBus, skillRunner, captured } = await setup({ hasCheckpoint: true })
emitSync(eventBus, workspace.id, 'issues')
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/src/clarification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
WorkspaceId,
} from './common.js'
import { GraphOperation } from './proposal.js'
import { UserKind } from './user.js'

// Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer.
const clarificationQuestion = z.string().min(1).max(400).describe('The single question shown to the reviewer.')
Expand Down Expand Up @@ -47,6 +48,9 @@ export const Clarification = z.object({
owner: Actor,
// Display-name snapshot at submit time. Absent for the 'system' owner.
ownerDisplayName: z.string().min(1).optional(),
// Owner's kind snapshotted at submit time.
// Absent means a human's private ticket, 'service' is autonomous and owner-visible.
ownerKind: UserKind.optional(),
// Set when the resolution becomes a Proposal, so the UI can link the two.
proposalId: ProposalId.optional(),
externalReferences: z.array(ExternalReference).optional(),
Expand Down Expand Up @@ -87,5 +91,7 @@ export const ClarificationFilter = z.object({
offset: z.number().int().nonnegative().optional(),
// When set, hides others' pending tickets. Non-pending stay visible, absent shows all.
viewerId: UserId.optional(),
// Owner-only, also shows service-owned (autonomous) pending to this viewer.
includeServiceOwned: z.boolean().optional(),
})
export type ClarificationFilter = z.infer<typeof ClarificationFilter>
6 changes: 6 additions & 0 deletions packages/schema/src/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
GraphNodeCreate,
GraphNodeUpdate,
} from './model.js'
import { UserKind } from './user.js'

// Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer.
const proposalRationale = z.string().min(1).max(1500).describe('One-paragraph plain-text summary of what changed and why.')
Expand Down Expand Up @@ -56,6 +57,9 @@ export const Proposal = z.object({
owner: Actor,
// Name at submit time, survives renames. Absent for the 'system' owner.
ownerDisplayName: z.string().min(1).optional(),
// Owner's kind snapshotted at submit time.
// Absent means a human's private draft, 'service' is autonomous and owner-visible.
ownerKind: UserKind.optional(),
})
export type Proposal = z.infer<typeof Proposal>

Expand All @@ -76,5 +80,7 @@ export const ProposalFilter = z.object({
offset: z.number().int().nonnegative().optional(),
// When set, hides others' pending proposals. Non-pending stay visible, absent shows all.
viewerId: UserId.optional(),
// Owner-only, also shows service-owned (autonomous) pending to this viewer.
includeServiceOwned: z.boolean().optional(),
})
export type ProposalFilter = z.infer<typeof ProposalFilter>
7 changes: 7 additions & 0 deletions packages/schema/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import { Timestamp, UserId } from './common.js'
export const ServerRole = z.enum(['admin', 'user'])
export type ServerRole = z.infer<typeof ServerRole>

// Absent or 'human' is a person who logs in.
// 'service' is an autonomous component acting via its own token,
// seeded by whatever wires it.
export const UserKind = z.enum(['human', 'service'])
export type UserKind = z.infer<typeof UserKind>

export const User = z.object({
id: UserId,
googleSub: z.string().min(1).optional(),
email: z.string().email().optional(),
displayName: z.string().min(1),
serverRole: ServerRole.default('user'),
kind: UserKind.optional(),
createdAt: Timestamp,
})
export type User = z.infer<typeof User>
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/composeApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ export interface ComposeOptions {
// to a no-op stub that throws, fine unless a batch or reactor runs.
unitLister?: UnitLister
sourceUnitDigest?: SourceUnitDigest
// Yields the caller token the reactor's autonomous runs act under.
// Absent means the reactor runs tokenless, falling back to the default principal.
reactorToken?: () => Promise<string | undefined>

// Skills.
skillRegistry?: SkillRegistry
Expand Down Expand Up @@ -263,6 +266,7 @@ export function composeApp(options: ComposeOptions = {}): AppDependencies {
skillRunner: options.skillRunner,
sourceUnitObservationService,
unitLister: options.unitLister,
...(options.reactorToken ? { reactorToken: options.reactorToken } : {}),
digest: sourceUnitDigest,
reactorCycleRepository,
workspaceLock,
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/composeFsApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { UserRegistryFile } from './infrastructure/users/UserRegistryFile.js'
import { FsWorkspaceRepository } from './infrastructure/workspace/FsWorkspaceRepository.js'
import { discoverCanonicalWorkspaces } from './infrastructure/workspace/WorkspaceDiscovery.js'
import { WorkspaceRegistryFile } from './infrastructure/workspace/WorkspaceRegistryFile.js'
import { ensureServiceAccount, REACTOR_USER_ID } from './serviceAccounts.js'
import { startupBeforeServe } from './startup.js'

// The coding preset's default plugin identities, its worldview in one place.
Expand Down Expand Up @@ -144,6 +145,10 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise<AppD
const authMode = parseBoolEnv(process.env.BRAID_LOCAL_TRUST, true) ? localTrust : authenticated
// Local trust seeds `local-user`, authenticated mode syncs the login allowlist.
await authMode.provision({ userRegistry, accessPolicy })
// The reactor is an autonomous component, so it seeds its own service account.
// Its kind=service rides onto every proposal it submits,
// so the HITL views classify it without a read-time lookup.
await ensureServiceAccount(userRegistry, REACTOR_USER_ID, 'Reactor')
const studioUrl = process.env.BRAID_STUDIO_URL ?? 'http://localhost:5173'
const workspaceRoots = async (): Promise<ReadonlyMap<WorkspaceId, AbsolutePath>> => {
const workspaces = await workspaceRepository.list()
Expand Down Expand Up @@ -325,6 +330,9 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise<AppD
bootstrap,
batchPlanRepository: new FsBatchPlanRepository(),
unitLister: workspace => listUnitItems(workspace, unitBearingRolesOf(pluginRegistry, workspace)),
// The reactor has no human caller, so it acts as the `reactor` service account,
// minting a short-lived session so its API calls authenticate.
reactorToken: async () => (await sessionStore.issue(REACTOR_USER_ID, { ttlSeconds: 3600 })).token,
sourceUnitObservationRepository: new FsSourceUnitObservationRepository({ workspaceRoots }),
reactorCycleRepository: new FsReactorCycleRepository({ workspaceRoots }),
sourceUnitDigest: new FsSourceUnitDigest(),
Expand Down
7 changes: 5 additions & 2 deletions packages/server/src/infrastructure/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ export class FsSessionStore implements SessionStore {
const content = await this.read()
const token = randomBytes(32).toString('base64url')
const tokenHash = sha256Hex(token)
const createdAt = new Date().toISOString()
const now = Date.now()
const createdAt = new Date(now).toISOString()
const expiresAt = options.ttlSeconds
? new Date(Date.now() + options.ttlSeconds * 1000).toISOString()
? new Date(now + options.ttlSeconds * 1000).toISOString()
: undefined
// Drop already-expired rows so short-lived service tokens do not accumulate.
content.sessions = content.sessions.filter(s => !s.expiresAt || Date.parse(s.expiresAt) >= now)
content.sessions.push({ tokenHash, userId, createdAt, ...(expiresAt ? { expiresAt } : {}) })
await this.write(content)
return { token, userId, ...(expiresAt ? { expiresAt } : {}) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ export class FsClarificationRepository implements ClarificationRepository {
// Answered, applied, and skipped tickets stay workspace-shared.
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
tickets = tickets.filter(ticket =>
ticket.status !== 'pending' || ticket.owner === 'system' || ticket.owner === viewerId,
ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'),
)
}
return paginate(tickets, filter?.limit, filter?.offset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ export class FsProposalRepository implements ProposalRepository {
// Absent viewerId means no filter, for Owner Show All and legacy callers.
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
proposals = proposals.filter(proposal =>
proposal.status !== 'pending' || proposal.owner === 'system' || proposal.owner === viewerId,
proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'),
)
}
return paginate(proposals, filter?.limit, filter?.offset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class UserDirectoryFromRegistry implements UserDirectory {
return {
displayName: user.displayName,
...(user.email ? { email: user.email } : {}),
...(user.kind ? { kind: user.kind } : {}),
}
}
}
10 changes: 10 additions & 0 deletions packages/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ export function authMiddleware(options: AuthMiddlewareOptions): MiddlewareHandle
const path = context.req.path

if (!options.requireAuth) {
// A presented, valid Bearer session wins even under local trust,
// so an internal caller like the reactor is identified as its service account.
// Studio under local trust sends no Bearer, so this never shadows it.
const token = extractBearerToken(context)
const session = token ? await options.sessionStore?.resolve(token) : undefined
if (session) {
context.set('userId', session.userId)
await next()
return undefined
}
const header = context.req.header(USER_HEADER)
const fromHeader = header && header.length > 0 ? UserId.parse(header) : null
const resolved = fromHeader ?? options.defaultPrincipal
Expand Down
Loading
Loading