Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
@@ -863,4 +863,96 @@ describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the
|
|||||||
process.off('unhandledRejection', onUnhandled);
|
process.off('unhandledRejection', onUnhandled);
|
||||||
expect(unhandled).toHaveLength(0);
|
expect(unhandled).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
|
||||||
|
const session = makeAgentSession(USER_A);
|
||||||
|
const detach = vi.fn();
|
||||||
|
const svc = {
|
||||||
|
getSession: vi.fn(() => session),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
onEvent: vi.fn(() => detach),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
|
||||||
|
// `await prompt()` the timer could never even be observed, so the turn hung forever.
|
||||||
|
prompt: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown): void => {
|
||||||
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'a prompt that never returns',
|
||||||
|
});
|
||||||
|
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
|
||||||
|
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
|
||||||
|
await vi.advanceTimersByTimeAsync(200_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
|
||||||
|
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
|
||||||
|
const session = makeAgentSession(USER_A);
|
||||||
|
const detach = vi.fn();
|
||||||
|
let rejectPrompt: (reason: unknown) => void = () => undefined;
|
||||||
|
const prompting = new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectPrompt = reject;
|
||||||
|
});
|
||||||
|
const svc = {
|
||||||
|
getSession: vi.fn(() => session),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
onEvent: vi.fn(() => detach),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn(() => prompting),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown): void => {
|
||||||
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'prompt settles after the deadline',
|
||||||
|
});
|
||||||
|
// The timeout wins the race while prompt is still pending.
|
||||||
|
await vi.advanceTimersByTimeAsync(200_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
|
||||||
|
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
|
||||||
|
// rejection is already observed and must not escape as an unhandledRejection.
|
||||||
|
rejectPrompt(new Error('late backend failure'));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -967,7 +967,11 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv
|
|||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const harnessConversations = { append: vi.fn() };
|
const harnessConversations = { append: vi.fn() };
|
||||||
const create = vi.fn().mockResolvedValue(undefined);
|
// The durable store honours the mint: it returns the exact record asked for (same id, same
|
||||||
|
// scoped owner). Admission requires that non-null, correctly-identified record before any effect.
|
||||||
|
const create = vi.fn((data: { id: string; userId: string }) =>
|
||||||
|
Promise.resolve({ id: data.id, userId: data.userId }),
|
||||||
|
);
|
||||||
const brain = {
|
const brain = {
|
||||||
conversations: {
|
conversations: {
|
||||||
findById: vi.fn().mockResolvedValue(undefined),
|
findById: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -1009,6 +1013,159 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv
|
|||||||
expect(agentService.prompt).toHaveBeenCalledTimes(1);
|
expect(agentService.prompt).toHaveBeenCalledTimes(1);
|
||||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
|
||||||
|
// Minting is not fire-and-forget: if create resolves nullish (the durable write silently
|
||||||
|
// produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch
|
||||||
|
// against a conversation that was never persisted.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||||
|
const order: string[] = [];
|
||||||
|
const counters = { dispatch: 0, dispose: 0 };
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession: vi.fn().mockResolvedValue(session),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const create = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const router = readyRouter('legacy', agentService, harnessConversations);
|
||||||
|
instrumentLease(router, order, counters);
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
router as never,
|
||||||
|
{ api: { getSession: vi.fn() } } as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'browser-new-nullish-1',
|
||||||
|
data: { user: { id: 'user-a' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
{
|
||||||
|
content: 'first message, mint resolves nullish',
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mint was attempted for the sender, then admission failed closed: no persist, no session
|
||||||
|
// mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
|
||||||
|
expect(create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
|
||||||
|
);
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(counters.dispatch).toBe(0);
|
||||||
|
expect(counters.dispose).toBe(0);
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ code: 'conversation_unavailable' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
|
||||||
|
// A create that rejects (durable store error) is a persistence failure, not a reason to proceed:
|
||||||
|
// the exception is caught at the admission seam and collapses to the same fail-closed refusal.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||||
|
const order: string[] = [];
|
||||||
|
const counters = { dispatch: 0, dispose: 0 };
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession: vi.fn().mockResolvedValue(session),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const create = vi.fn().mockRejectedValue(new Error('durable store unavailable'));
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const router = readyRouter('legacy', agentService, harnessConversations);
|
||||||
|
instrumentLease(router, order, counters);
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
router as never,
|
||||||
|
{ api: { getSession: vi.fn() } } as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'browser-new-throw-1',
|
||||||
|
data: { user: { id: 'user-a' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
{
|
||||||
|
content: 'first message, mint throws',
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mint was attempted for the sender, then admission failed closed on the thrown error: no
|
||||||
|
// persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
|
||||||
|
expect(create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
|
||||||
|
);
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(counters.dispatch).toBe(0);
|
||||||
|
expect(counters.dispose).toBe(0);
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ code: 'conversation_unavailable' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Chat DTO validation', () => {
|
describe('Chat DTO validation', () => {
|
||||||
|
|||||||
@@ -492,16 +492,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Reconcile the verified binding against the durable agent record BEFORE claiming the message
|
// Reconcile the verified binding against the durable agent record BEFORE claiming the message
|
||||||
// id (security fix, finding 3). The configured agent must exist and its record name must match
|
// id (security fix, finding 3). The configured agent must exist and its record must match the
|
||||||
// the binding's instance id exactly; a missing record, a name mismatch, or a lookup error
|
// binding EXACTLY on BOTH id and name — a record whose id differs from the binding's
|
||||||
// rejects the turn WITHOUT consuming the replay claim, so a corrected retry is still admitted.
|
// agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A
|
||||||
// The branded identity is derived from the record (id + name), never from the raw binding
|
// missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT
|
||||||
// strings — an unreconciled binding must not execute a default or different embedded agent
|
// consuming the replay claim, so a corrected retry is still admitted. The branded identity is
|
||||||
// under a verified label.
|
// derived from the record (id + name), never from the raw binding strings — an unreconciled
|
||||||
|
// binding must not execute a default or different embedded agent under a verified label.
|
||||||
let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string };
|
let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string };
|
||||||
try {
|
try {
|
||||||
const record = await this.brain.agents.findById(binding.agentConfigId);
|
const record = await this.brain.agents.findById(binding.agentConfigId);
|
||||||
if (!record || record.name !== binding.instanceId) {
|
if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`,
|
`Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`,
|
||||||
);
|
);
|
||||||
@@ -1164,8 +1165,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const owned = await this.brain.conversations.findById(suppliedConversationId, userId);
|
const owned = await this.brain.conversations.findById(suppliedConversationId, userId);
|
||||||
return owned !== undefined;
|
return owned !== undefined;
|
||||||
}
|
}
|
||||||
await this.brain.conversations.create({ id: conversationId, userId });
|
// Minting a new conversation must actually yield the durable record we asked for before any
|
||||||
return true;
|
// runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that
|
||||||
|
// is not this exact id owned by this user, is a persistence failure — fail closed so the caller
|
||||||
|
// never dispatches/persists against an unpersisted or mis-scoped conversation.
|
||||||
|
const created = await this.brain.conversations.create({ id: conversationId, userId });
|
||||||
|
return created != null && created.id === conversationId && created.userId === userId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Conversation admission failed for conversation=${conversationId}`,
|
`Conversation admission failed for conversation=${conversationId}`,
|
||||||
|
|||||||
@@ -94,9 +94,16 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||||
|
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||||
|
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||||
|
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||||
|
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||||
|
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||||
|
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||||
|
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, input.content, scope);
|
await Promise.all([prompting, done]);
|
||||||
await done;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dispose();
|
dispose();
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -300,18 +307,22 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
|
|||||||
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||||
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||||
> {
|
> {
|
||||||
|
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||||
|
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||||
|
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||||
|
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||||
|
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||||
|
// no expectedAgent and skips the check.
|
||||||
|
const identityMatches = (candidate: AgentSession): boolean =>
|
||||||
|
expectedAgent === undefined ||
|
||||||
|
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||||
|
candidate.agentName === expectedAgent.instanceId);
|
||||||
|
|
||||||
let session = this.agentService.getSession(conversationId, scope);
|
let session = this.agentService.getSession(conversationId, scope);
|
||||||
if (session && expectedAgent) {
|
if (session && !identityMatches(session)) {
|
||||||
// A verified-Discord turn reuses an existing in-process session only when its configured
|
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||||
// identity still matches the reconciled agent record. A pre-existing same-scope session
|
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||||
// carrying a different configured agent (config id or resolved name) is rejected rather than
|
return CONVERSATION_UNAVAILABLE;
|
||||||
// executed under the verified label — never silently run a different prompt/model/tool policy.
|
|
||||||
if (
|
|
||||||
session.agentConfigId !== expectedAgent.agentConfigId ||
|
|
||||||
session.agentName !== expectedAgent.instanceId
|
|
||||||
) {
|
|
||||||
return CONVERSATION_UNAVAILABLE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
try {
|
try {
|
||||||
@@ -330,6 +341,12 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
|
|||||||
);
|
);
|
||||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
}
|
}
|
||||||
|
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||||
|
// createSession that returns a session under a different configured agent (misconfiguration
|
||||||
|
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||||
|
if (!identityMatches(session)) {
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, presentation: this.presentationForSession(session) };
|
return { ok: true, presentation: this.presentationForSession(session) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,6 +612,8 @@ describe('Discord ingress security', () => {
|
|||||||
const session = {
|
const session = {
|
||||||
provider: 'configured-provider',
|
provider: 'configured-provider',
|
||||||
modelId: 'configured-model',
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
piSession: {
|
piSession: {
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
@@ -697,6 +699,8 @@ describe('Discord ingress security', () => {
|
|||||||
const session = {
|
const session = {
|
||||||
provider: 'configured-provider',
|
provider: 'configured-provider',
|
||||||
modelId: 'configured-model',
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
piSession: {
|
piSession: {
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
@@ -796,6 +800,8 @@ describe('Discord ingress security', () => {
|
|||||||
const session = {
|
const session = {
|
||||||
provider: 'configured-provider',
|
provider: 'configured-provider',
|
||||||
modelId: 'configured-model',
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
piSession: {
|
piSession: {
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
@@ -963,6 +969,197 @@ describe('Discord ingress security', () => {
|
|||||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
|
||||||
|
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
|
||||||
|
// then admit the corrected record whose id matches the binding.
|
||||||
|
const findAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
|
||||||
|
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: findAgent },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-reconcile-id',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
const envelope = ingressEnvelope(
|
||||||
|
'verified once with aliased agent id',
|
||||||
|
'discord-reconcile-id-001',
|
||||||
|
{
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
|
||||||
|
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
|
||||||
|
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
|
||||||
|
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
|
||||||
|
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
|
||||||
|
// another agent's verified label. (The existing reuse test covers the getSession path; this
|
||||||
|
// covers the createSession path scrappy flagged as unvalidated.)
|
||||||
|
const mintedForeignSession = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
agentName: 'Orion',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-postcreate-mismatch',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The freshly minted session failed the post-create identity recheck: refused with a typed
|
||||||
|
// error, no prompt, no persist, no ack.
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
id: 'attachment-history',
|
id: 'attachment-history',
|
||||||
|
|||||||
Reference in New Issue
Block a user