Merge pull request #4068 from code-yeongyu/feat/pre-publish-fix-v420
v4.2.0: pre-publish review fixes (BLOCKER-1..3, HIGH-5..10, MID-11/12)
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import ts from "typescript"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for doctor dependency module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "cli", "doctor", "checks", "dependencies.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for session recovery module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "index.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for auto-update checker hook module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "auto-update-checker", "hook.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux layout-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "layout-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-dimensions module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-dimensions.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux stale-session sweep module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "stale-session-sweep-runtime.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
])
|
||||
|
||||
async function listTestFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const nestedFiles = await Promise.all(entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return listTestFiles(entryPath)
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".test.ts") && !entry.name.endsWith(".d.ts")) {
|
||||
return [entryPath]
|
||||
}
|
||||
return []
|
||||
}))
|
||||
|
||||
return nestedFiles.flat()
|
||||
}
|
||||
|
||||
function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function isMockModuleCall(node: ts.CallExpression): boolean {
|
||||
const expression = node.expression
|
||||
return ts.isPropertyAccessExpression(expression)
|
||||
&& ts.isIdentifier(expression.expression)
|
||||
&& expression.expression.text === "mock"
|
||||
&& expression.name.text === "module"
|
||||
}
|
||||
|
||||
function getMockModulePath(node: ts.CallExpression): string | null {
|
||||
if (!isMockModuleCall(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modulePath = node.arguments[0]
|
||||
if (!modulePath || !ts.isStringLiteralLike(modulePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return modulePath.text
|
||||
}
|
||||
|
||||
function collectMockModulePaths(sourceFile: ts.SourceFile): string[] {
|
||||
const modulePaths: string[] = []
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const modulePath = getMockModulePath(node)
|
||||
if (modulePath) {
|
||||
modulePaths.push(modulePath)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return modulePaths
|
||||
}
|
||||
|
||||
function hasMockModuleCall(sourceFile: ts.SourceFile): boolean {
|
||||
return collectMockModulePaths(sourceFile).length > 0
|
||||
}
|
||||
|
||||
function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean {
|
||||
const seenModulePaths = new Set<string>()
|
||||
for (const modulePath of collectMockModulePaths(sourceFile)) {
|
||||
if (seenModulePaths.has(modulePath)) {
|
||||
return true
|
||||
}
|
||||
seenModulePaths.add(modulePath)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isCleanupCall(node: ts.CallExpression): boolean {
|
||||
if (ts.isIdentifier(node.expression)) {
|
||||
return node.expression.text === "afterEach" || node.expression.text === "afterAll"
|
||||
}
|
||||
|
||||
const expression = node.expression
|
||||
return ts.isPropertyAccessExpression(expression)
|
||||
&& ts.isIdentifier(expression.expression)
|
||||
&& expression.expression.text === "mock"
|
||||
&& expression.name.text === "restore"
|
||||
}
|
||||
|
||||
function hasCleanupPattern(sourceFile: ts.SourceFile): boolean {
|
||||
if (hasDuplicateModuleReset(sourceFile)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let foundCleanup = false
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (foundCleanup) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node) && isCleanupCall(node)) {
|
||||
foundCleanup = true
|
||||
return
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return foundCleanup
|
||||
}
|
||||
|
||||
describe("mock.module lifecycle hygiene", () => {
|
||||
test("#given test files using mock.module #when audited #then each must pair with cleanup", async () => {
|
||||
// given
|
||||
const files = await listTestFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (MOCK_MODULE_LIFECYCLE_ALLOWLIST.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
|
||||
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders.sort()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
PROMPT_TIMEOUT_MS,
|
||||
type PromptRetryOptions,
|
||||
} from "./prompt-timeout-context"
|
||||
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle } from "./prompt-async-gate"
|
||||
import {
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -119,6 +123,7 @@ export async function promptWithModelSuggestionRetry(
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
|
||||
}
|
||||
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry")
|
||||
throw error
|
||||
} finally {
|
||||
timeoutContext.cleanup()
|
||||
@@ -169,6 +174,11 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
throw error
|
||||
}
|
||||
|
||||
// The first attempt failed synchronously with ProviderModelNotFoundError, which means the
|
||||
// prompt did not reach the server. Release the post-dispatch reservation hold so the
|
||||
// immediate retry can dispatch without waiting for the hold window to expire.
|
||||
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry:sync")
|
||||
|
||||
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
|
||||
original: `${suggestion.providerID}/${suggestion.modelID}`,
|
||||
suggested: suggestion.suggestion,
|
||||
|
||||
+140
-103
@@ -6,6 +6,7 @@ import {
|
||||
} from "./session-idle-settle"
|
||||
|
||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path?: { id?: string }
|
||||
@@ -36,6 +37,9 @@ type PromptAsyncReservation = {
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||
|
||||
export type PromptAsyncGateResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "active" }
|
||||
@@ -84,11 +88,114 @@ function reservationSourceMatches(
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof expectedPrefix === "string") {
|
||||
return reservationSource.startsWith(expectedPrefix)
|
||||
const prefixes = typeof expectedPrefix === "string" ? [expectedPrefix] : expectedPrefix
|
||||
return prefixes
|
||||
.filter((prefix) => prefix.length > 0 && prefix.endsWith(":"))
|
||||
.some((prefix) => reservationSource.startsWith(prefix))
|
||||
}
|
||||
|
||||
async function withDispatchTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
dispatchTimeoutMs: number,
|
||||
operationName: string,
|
||||
): Promise<T> {
|
||||
if (dispatchTimeoutMs <= 0) {
|
||||
return operation
|
||||
}
|
||||
|
||||
return expectedPrefix.some((prefix) => reservationSource.startsWith(prefix))
|
||||
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
|
||||
}, dispatchTimeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([operation, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
sessionName: "promptAsync" | "prompt"
|
||||
client: { session?: { status?: () => Promise<unknown> } }
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs: number
|
||||
postDispatchHoldMs: number
|
||||
dispatchTimeoutMs: number
|
||||
checkStatus: boolean
|
||||
dispatch: (input: TInput) => Promise<unknown>
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
sessionName,
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus,
|
||||
dispatch,
|
||||
} = args
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log(`[prompt-async-gate] ${sessionName} skipped because session is reserved`, {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let dispatchAttempted = false
|
||||
|
||||
try {
|
||||
const canReadStatus = checkStatus && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
||||
dispatchAttempted = true
|
||||
const response = await withDispatchTimeout(
|
||||
dispatch(input),
|
||||
dispatchTimeoutMs,
|
||||
`[prompt-async-gate] ${sessionName} dispatch`,
|
||||
)
|
||||
log(`[prompt-async-gate] ${sessionName} dispatched`, { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (dispatchAttempted && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
@@ -98,6 +205,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
@@ -108,62 +216,26 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const promptAsync = client.session?.promptAsync
|
||||
|
||||
if (typeof client.session?.promptAsync !== "function") {
|
||||
if (typeof promptAsync !== "function") {
|
||||
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "promptAsync",
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
|
||||
const response = await client.session.promptAsync(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] promptAsync failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
dispatch: (dispatchInput) => promptAsync(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
@@ -173,6 +245,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
@@ -183,62 +256,26 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const prompt = client.session?.prompt
|
||||
|
||||
if (typeof client.session?.prompt !== "function") {
|
||||
if (typeof prompt !== "function") {
|
||||
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] prompt skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "prompt",
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] prompt skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
|
||||
const response = await client.session.prompt(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] prompt failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
dispatch: (dispatchInput) => prompt(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import ts from "typescript"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts")
|
||||
const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
|
||||
[
|
||||
path.join(SOURCE_ROOT, "plugin", "event.ts"),
|
||||
"team idle wake hint wires a client facade for downstream gate-routed dispatch",
|
||||
],
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
||||
"runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle",
|
||||
],
|
||||
])
|
||||
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
@@ -30,35 +41,220 @@ function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function uncommentedLines(contents: string): string[] {
|
||||
return contents
|
||||
.split("\n")
|
||||
.map((line) => line.trimStart())
|
||||
.filter((line) => !line.startsWith("//") && !line.startsWith("*"))
|
||||
function getPropertyName(node: ts.PropertyName | ts.MemberName | ts.Expression): string | null {
|
||||
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
||||
if (ts.isParenthesizedExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
if (ts.isNonNullExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
return expression
|
||||
}
|
||||
|
||||
function isSessionAccessExpression(expression: ts.Expression): boolean {
|
||||
const unwrapped = unwrapExpression(expression)
|
||||
|
||||
if (ts.isIdentifier(unwrapped)) {
|
||||
return unwrapped.text === "session"
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isPropertyAccessExpression(unwrapped)
|
||||
|| ts.isPropertyAccessChain(unwrapped)
|
||||
) {
|
||||
const propertyName = getPropertyName(unwrapped.name)
|
||||
return propertyName === "session"
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isElementAccessExpression(unwrapped)
|
||||
|| ts.isElementAccessChain(unwrapped)
|
||||
) {
|
||||
const argument = unwrapped.argumentExpression
|
||||
if (!argument) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getPropertyName(argument) === "session"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isRawPromptPropertyAccess(node: ts.Node): boolean {
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node)
|
||||
|| ts.isPropertyAccessChain(node)
|
||||
) {
|
||||
const propertyName = getPropertyName(node.name)
|
||||
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
|
||||
return false
|
||||
}
|
||||
|
||||
return isSessionAccessExpression(node.expression)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isElementAccessExpression(node)
|
||||
|| ts.isElementAccessChain(node)
|
||||
) {
|
||||
const argument = node.argumentExpression
|
||||
if (!argument) {
|
||||
return false
|
||||
}
|
||||
|
||||
const propertyName = getPropertyName(argument)
|
||||
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
|
||||
return false
|
||||
}
|
||||
|
||||
return isSessionAccessExpression(node.expression)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isPromptBindingPattern(node: ts.Node): boolean {
|
||||
if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isObjectBindingPattern(node.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isSessionAccessExpression(node.initializer)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return node.name.elements.some((element) => {
|
||||
const keyName = element.propertyName
|
||||
? getPropertyName(element.propertyName)
|
||||
: getPropertyName(element.name)
|
||||
return keyName === "prompt" || keyName === "promptAsync"
|
||||
})
|
||||
}
|
||||
|
||||
function isReflectApplyPromptCall(node: ts.Node): boolean {
|
||||
if (!ts.isCallExpression(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const callee = unwrapExpression(node.expression)
|
||||
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "apply") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!ts.isIdentifier(callee.expression) || callee.expression.text !== "Reflect") {
|
||||
return false
|
||||
}
|
||||
|
||||
const firstArgument = node.arguments[0]
|
||||
if (!firstArgument) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isRawPromptPropertyAccess(firstArgument)
|
||||
}
|
||||
|
||||
function isTypeofPromptCheck(node: ts.Node): boolean {
|
||||
return ts.isTypeOfExpression(node.parent)
|
||||
}
|
||||
|
||||
function detectRawPromptInSnippet(contents: string): boolean {
|
||||
const sourceFile = ts.createSourceFile("audit-snippet.ts", contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
let detected = false
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (detected) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRawPromptAccess = isRawPromptPropertyAccess(node) && !isTypeofPromptCheck(node)
|
||||
if (isRawPromptAccess || isPromptBindingPattern(node) || isReflectApplyPromptCall(node)) {
|
||||
detected = true
|
||||
return
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return detected
|
||||
}
|
||||
|
||||
describe("production prompt injection routes", () => {
|
||||
test("#given a destructuring promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const { promptAsync } = client.session"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given bracket promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const value = client['session']['promptAsync']"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given type-cast promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const promptAsync = (client.session as { promptAsync?: unknown }).promptAsync"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given optional-chain promptAsync call #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "await client.session?.promptAsync({ body: { text: 'hi' } })"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
const rawPromptPatterns = [
|
||||
/\bsession\.promptAsync\s*\(/,
|
||||
/\bsession\.prompt\s*\(/,
|
||||
/\bReflect\.apply\s*\(\s*\w*promptAsync\b/,
|
||||
/\bReflect\.apply\s*\(\s*\w*prompt\b/,
|
||||
/\b(?:const|let|var)\s+\w*promptAsync\w*\s*=\s*[\w.]+\.session\.promptAsync\b/,
|
||||
/\b(?:const|let|var)\s+\w*prompt\w*\s*=\s*[\w.]+\.session\.prompt\b/,
|
||||
]
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (filePath === PROMPT_GATE_FILE) {
|
||||
if (filePath === PROMPT_GATE_FILE || RAW_PROMPT_ALLOWLIST.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (rawPromptPatterns.some((pattern) => pattern.test(contents))) {
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
if (detectRawPromptInSnippet(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
@@ -74,7 +270,7 @@ describe("production prompt injection routes", () => {
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user