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
32 changes: 25 additions & 7 deletions src/app/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,15 +412,21 @@ export class BraidApplication {
readonly text: string
readonly runId?: string
}): QueueReceipt {
return queueRunInput(this.#portViews.queue, input)
return queueRunInput(this.#portViews.queue, {
...input,
operationId: operationId(input.operationId, 'queue'),
})
}

async steer(input: {
readonly operationId: string
readonly runId?: string
readonly text: string
}): Promise<ControlReceipt> {
return steerRun(this.#portViews.control, input)
return steerRun(this.#portViews.control, {
...input,
operationId: operationId(input.operationId, 'steer'),
})
}

async cancelRun(input: {
Expand All @@ -430,7 +436,10 @@ export class BraidApplication {
readonly terminalStatus?: 'cancelled' | 'aborted'
readonly legacy?: boolean
}): Promise<ControlReceipt> {
return cancelRun(this.#portViews.control, input)
return cancelRun(this.#portViews.control, {
...input,
operationId: operationId(input.operationId, 'cancel'),
})
}

cancel(input: CancelInput): CancelReceipt {
Expand Down Expand Up @@ -479,7 +488,10 @@ export class BraidApplication {
readonly operationId: string
readonly runId?: string
}): Promise<ControlReceipt> {
return detachRun(this.#portViews.control, input)
return detachRun(this.#portViews.control, {
...input,
operationId: operationId(input.operationId, 'detach'),
})
}

async respondInteraction(input: {
Expand All @@ -488,7 +500,10 @@ export class BraidApplication {
readonly interactionId: string
readonly response: InteractionResponse
}): Promise<InteractionReceipt> {
return this.#interactions.respond(input)
return this.#interactions.respond({
...input,
operationId: operationId(input.operationId, 'respond-interaction'),
})
}

async #executeControl(
Expand Down Expand Up @@ -520,7 +535,10 @@ export class BraidApplication {
readonly text: string
readonly runId?: string
}): Promise<SendReceipt> {
return continueNative(this.#portViews.nativeContinuation, input)
return continueNative(this.#portViews.nativeContinuation, {
...input,
operationId: operationId(input.operationId, 'continue'),
})
}

shutdown(input: {
Expand Down Expand Up @@ -584,7 +602,7 @@ export class BraidApplication {
this.#assertAdmissionOpen()
return admitRun(
this.#portViews.admission,
input,
{ ...input, operationId: operationId(input.operationId, 'admit') },
conversationId,
branchId,
contextTransfer,
Expand Down
2 changes: 1 addition & 1 deletion src/bin/plain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface PlainOptions {
}

function nextOperationId(): string {
return `op-plain-${Date.now().toString(36)}-${randomUUID()}`
return `op-${randomUUID()}`
}

export async function runPlain(
Expand Down
9 changes: 6 additions & 3 deletions src/domain/ids-core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IdForKind, IdKind, Digest, ReplayCursor } from './ids-types.js'
import type { Digest, IdForKind, IdKind, ReplayCursor } from './ids-types.js'
import { redactSensitiveText } from './secret-sanitizer.js'

export const prefixes: Readonly<Record<IdKind, readonly string[]>> = {
workspace: ['workspace-'],
Expand Down Expand Up @@ -44,7 +45,8 @@ export function parsePrefixedId<K extends IdKind>(kind: K, value: unknown): IdFo
if (
typeof value !== 'string' ||
!idPattern.test(value) ||
!prefixes[kind].some((prefix) => value.startsWith(prefix))
!prefixes[kind].some((prefix) => value.startsWith(prefix)) ||
redactSensitiveText(value) !== value
) {
throw new TypeError(`Invalid ${kind} identifier`)
}
Expand All @@ -55,7 +57,8 @@ export function isPrefixedId<K extends IdKind>(kind: K, value: unknown): value i
return (
typeof value === 'string' &&
idPattern.test(value) &&
prefixes[kind].some((prefix) => value.startsWith(prefix))
prefixes[kind].some((prefix) => value.startsWith(prefix)) &&
redactSensitiveText(value) === value
)
}

Expand Down
23 changes: 22 additions & 1 deletion test/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,15 +544,36 @@ test('async admission reserves one run before the provider becomes visible', asy
assert.equal(app.storageFailure(), undefined)
})

test('send rejects malformed operation identities and oversized input before journaling', () => {
test('public mutations reject unsafe operation identities before work or journaling', async () => {
const app = createBraidApplication({ fixture: 'deterministic' })
app.initialize('/workspace')
const eventCount = app.events().length
const unsafeOperationId = `op-plain-sk-${'a'.repeat(24)}`

assert.throws(
() => app.send({ operationId: 'token=do-not-store', text: 'hello' }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
assert.throws(
() => app.queueInput({ operationId: unsafeOperationId, text: 'queue this' }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
await assert.rejects(
app.steer({ operationId: unsafeOperationId, text: 'steer this' }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
await assert.rejects(
app.cancelRun({ operationId: unsafeOperationId }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
await assert.rejects(
app.detachRun({ operationId: unsafeOperationId }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
await assert.rejects(
app.continueNative({ operationId: unsafeOperationId, text: 'continue this' }),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID',
)
assert.throws(
() => app.send({ operationId: 'op-too-large', text: 'x'.repeat(1024 * 1024 + 1) }),
(error: unknown) => error instanceof AppError && error.code === 'MESSAGE_TOO_LARGE',
Expand Down
9 changes: 8 additions & 1 deletion test/domain-ids.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ConversationId } from '../src/domain/ids.js'
import {
createAnalysisId,
createAnalysisRunId,
Expand Down Expand Up @@ -43,7 +44,6 @@ import {
parseConversationId,
parseRunId,
} from '../src/domain/ids.js'
import type { ConversationId } from '../src/domain/ids.js'

test('every domain identifier has a constructor and a nominal runtime prefix', () => {
const constructors: readonly [(value: string) => string, string][] = [
Expand Down Expand Up @@ -105,6 +105,13 @@ test('identifier validators reject values from another domain', () => {
assert.throws(() => parseConversationId('run-1'), /Invalid conversation identifier/u)
})

test('identifier validators reject values that secret redaction would change', () => {
assert.throws(
() => createOperationId(`op-plain-sk-${'a'.repeat(24)}`),
/Invalid operation identifier/u,
)
})

test('brands prevent accidental compile-time substitution', () => {
const run = createRunId('run-compile')
// @ts-expect-error RunId and ConversationId are intentionally nominally distinct.
Expand Down
18 changes: 18 additions & 0 deletions test/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/compos
import { MemoryJournal } from '../src/app/journal.js'
import { runPlain } from '../src/bin/plain.js'
import { canonicalDigest } from '../src/domain/canonical.js'
import { redactSensitiveText } from '../src/domain/secret-sanitizer.js'
import { FixedClock } from '../src/ports/clock.js'
import {
DEFAULT_RUN_CAPABILITIES,
Expand Down Expand Up @@ -893,6 +894,23 @@ test('plain output failure cancels the delayed run before the outer close', asyn
assert.equal(app.events().length, eventsAfterClose)
})

test('plain mode generates redaction-stable operation identifiers', async () => {
const app = createBraidApplication({ fixture: 'deterministic' })
async function* input(): AsyncGenerator<string> {
yield 'verify generated operation identity\n'
}

await runPlain(controllerFor(app), '/workspace', input(), { write: () => true })

const operationId = app.state().runs[0]?.operationId
assert(operationId)
assert.match(
operationId,
/^op-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
)
assert.equal(redactSensitiveText(operationId), operationId)
})

test('JSONL requires initialize and stable operation identity', async () => {
const app = createBraidApplication({ fixture: 'deterministic' })
let output = ''
Expand Down