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
14 changes: 14 additions & 0 deletions docs/reference/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ hold the caller's connection open for the whole turn and, at the caller's reques
timeout, trigger a re-delivery storm. Turn failures are logged and emitted as
diagnostics by the daemon, not returned as this request's status.

The rendered `sessionKey` identifies the **conversation**, so repeat deliveries for
one subject continue the same conversation while different subjects stay isolated.
The turn's authority, however, is bound to the **mapping**, not to the payload: the
conversation's principal is derived from the mapping id, and the conversation
reference digests every identity field separately. A crafted payload therefore
cannot forge or widen a conversation's authority even when the template
interpolates request data — a rendered key carrying the `:` session-key separator
resolves to its own conversation, never to the one it imitates.

The session key you see in logs and `comis sessions` is the fully-qualified
resolved one; the rendered key appears inside it, so filtering by subject still
works. Sessions opened by a webhook carry a `webhook-`-prefixed principal, keeping
them distinguishable from gateway-opened ones.

**`"wake"`:** Triggers a daemon heartbeat. The `wakeMode` controls timing:
- `"now"` (default): Fire the heartbeat immediately
- `"next-heartbeat"`: Wait for the next scheduled heartbeat cycle
Expand Down
12 changes: 12 additions & 0 deletions packages/daemon/src/api/obs-handlers/obs-explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,18 @@ describe("bindObsExplainHandlers", () => {
).toBe(true);
// The empty session report is still well-formed (no leak, no crash).
expect(r.failures).toEqual([]);
// The verdict must name the PRE-EXECUTION ABORT possibility, not only "typo /
// expired". A turn that fail-closes before recording a session (an unresolved
// conversation authority, a rejected identity, tool assembly) never reaches the
// session index, so a dead trigger presents IDENTICALLY to a bad reference. A
// verdict that only offers "typo or expired" sends the operator to re-check the
// reference while a real failure goes unexamined — the live cost when an entire
// webhook-driven pipeline was down yet `explain` reported only a missing ref.
expect(r.likelyRootCause?.detail).toMatch(/abort|before .*record|pre-execution/i);
expect(
r.likelyRootCause?.suggestedNextSteps.some((s) => /daemon log|errorKind|step/i.test(s)),
"a next step must route to the log evidence a pre-execution abort DOES leave",
).toBe(true);
});

it("a lossy ref that misroutes to a traceId-miss STILL seeds the 'did you mean' scan with the ORIGINAL ref (not the resolved empty key)", async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/daemon/src/api/obs-handlers/obs-explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,10 +416,11 @@ export async function assembleIncidentReportFromSources(
}
: {
code: "session_not_found",
detail: `${missedRefField} did not resolve to any session in the index (today/yesterday); it may be a typo, expired, or older than the 2-day resolution horizon`,
detail: `${missedRefField} did not resolve to any session in the index (today/yesterday). Either the reference is wrong or older than the 2-day resolution horizon, OR the turn aborted BEFORE it recorded a session — a pre-execution fail-closed (e.g. unresolved conversation authority, a rejected identity, or tool assembly) never reaches the index, so a real failure can look like a missing reference`,
suggestedNextSteps: [
`verify the ${missedRefField}, or query by sessionKey directly`,
"confirm the session ended within the last two days (the session-index lookup window)",
`if the trigger is known to have fired, treat this as a possible pre-execution abort: grep the daemon log for this ${missedRefField} — a turn that failed before recording emits an ERROR carrying its step and errorKind but no session`,
],
};
report.truncations.push({
Expand Down
191 changes: 163 additions & 28 deletions packages/daemon/src/wiring/setup-gateway-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ import {
createResponsesRoute,
} from "@comis/gateway";
import {
BackgroundTaskOriginSchema,
createConversationLocator,
createConversationRef,
formatSessionKey,
generateStrongToken,
runWithContext,
Expand Down Expand Up @@ -203,35 +205,175 @@ describe("mountGatewayRoutes", () => {
expect(observedContexts[0]).not.toBe(ambientContext);
expect(observedContexts[1]).toBe(observedContexts[0]);
expect(observedContexts[2]).toBe(observedContexts[0]);
// The turn's identity is the RESOLVED webhook identity, not a hand-built key: the
// principal is the hashed mapping (payload data can never forge it) and the
// rendered session key stays embedded in the conversation, so per-subject
// isolation and log greppability both hold.
expect(observedContexts[0]).toMatchObject({
tenantId: "test",
userId: "webhook",
sessionKey: formatSessionKey({
tenantId: "test",
agentId: "agent-b",
userId: "webhook",
channelId: "hook-session",
}),
agentId: "agent-b",
channelType: "webhook",
trustLevel: "guest",
deliveryOrigin: {
tenantId: "test",
userId: "webhook",
channelType: "webhook",
channelId: "hook-session",
},
});
expect(observedContexts[0]?.userId).toMatch(/^webhook-[a-f0-9]{64}$/);
const resolvedSessionKey = observedContexts[0]?.sessionKey as string;
expect(resolvedSessionKey).toContain("hook-session");
expect(resolvedSessionKey.startsWith("test:agent:agent-b:")).toBe(true);
expect(observedContexts[0]?.traceId).not.toBe(ambientContext.traceId);
// The reap probe must address the SAME conversation the turn ran in — a
// divergence here would reap the wrong (or no) drive.
expect(reapNeverTaskedDrives).toHaveBeenCalledWith("agent-b", {
agentId: "agent-b",
sessionKey: formatSessionKey({
tenantId: "test",
agentId: "agent-b",
userId: "webhook",
channelId: "hook-session",
}),
sessionKey: resolvedSessionKey,
});
});

it("resolves a canonical turn scope for the webhook turn (the executor's context-authority precondition)", async () => {
// The executor fail-closes when the ambient context carries no canonical turn
// scope: pi-executor requires BOTH `turnScope !== undefined` AND a constructible
// conversation ref, else it aborts with step:"context-authority" /
// errorKind:"precondition" and never runs the turn. The webhook action builds its
// own context and dispatches straight to the executor (it bypasses the orchestrator
// inbound pipeline that resolves identity for real channels), so it must resolve the
// turn scope itself — exactly as the gateway/cron/heartbeat turn initiators do.
// Pre-patch this context had no turnScope at all, so EVERY webhook-triggered agent
// action aborted before reaching the drive.
const observedContexts: Array<ReturnType<typeof tryGetContext>> = [];
const execute = vi.fn(async () => {
observedContexts.push(tryGetContext());
return { finishReason: "stop" };
});
const deps = createMockDeps({
webhooksConfig: {
enabled: true,
mappings: [{ id: "m1", name: "test", agentId: "agent-b" }],
} as any,
getExecutor: vi.fn(() => ({ execute })) as any,
});
mountGatewayRoutes(deps);
const config = vi.mocked(createMappedWebhookEndpoint).mock.calls.at(-1)![0] as any;

await config.onAgentAction(
{ id: "m1", name: "test", agentId: "agent-b" },
"perform task",
"azdo:12816",
);

expect(execute).toHaveBeenCalledTimes(1);
const turnScope = observedContexts[0]?.turnScope;
expect(turnScope, "the webhook turn must carry a canonical turn scope").toBeDefined();
if (turnScope === undefined) return;
// The precondition is the ref being CONSTRUCTIBLE, not merely the scope being present.
const ref = createConversationRef(turnScope.conversation);
expect(ref.ok, "the turn scope must yield a constructible conversation ref").toBe(true);
// Per-mapping conversation isolation: the rendered session key identifies the
// conversation, so two work items never share one partition.
expect(turnScope.endpoint.conversationId).toContain("azdo:12816");
expect(turnScope.conversation.agentId).toBe("agent-b");
});

it("isolates webhook conversations per rendered session key", async () => {
// Two different rendered session keys (two work items) must resolve to two
// DISTINCT conversations — otherwise concurrent drives would collide in one
// partition and read each other's history.
const observedContexts: Array<ReturnType<typeof tryGetContext>> = [];
const execute = vi.fn(async () => {
observedContexts.push(tryGetContext());
return { finishReason: "stop" };
});
const deps = createMockDeps({
webhooksConfig: {
enabled: true,
mappings: [{ id: "m1", name: "test", agentId: "agent-b" }],
} as any,
getExecutor: vi.fn(() => ({ execute })) as any,
});
mountGatewayRoutes(deps);
const config = vi.mocked(createMappedWebhookEndpoint).mock.calls.at(-1)![0] as any;
const mapping = { id: "m1", name: "test", agentId: "agent-b" };

await config.onAgentAction(mapping, "task a", "azdo:12816");
await config.onAgentAction(mapping, "task b", "azdo:12817");

expect(observedContexts).toHaveLength(2);
const first = observedContexts[0]?.turnScope;
const second = observedContexts[1]?.turnScope;
expect(first).toBeDefined();
expect(second).toBeDefined();
if (first === undefined || second === undefined) return;
expect(first.endpoint.conversationId).not.toBe(second.endpoint.conversationId);
});

it("binds the webhook delivery origin to the SAME endpoint its turn scope resolved", async () => {
// The runtime treats the delivery origin and the turn scope as one authority
// and rejects the pair wherever they disagree: BackgroundTaskOriginSchema
// ("delivery origin must agree with the resolved turn authority") gates
// background-task promotion and task extraction, the capability lease
// principal gates a jailed session.spawn, and the autonomy wiring drops the
// durable principal. A webhook turn whose origin says one channel while its
// scope says another therefore starts, then fail-closes at every one of those
// seams. Cron and durable resume derive the origin FROM the resolved endpoint
// for exactly this reason; so must the webhook path.
const observedContexts: Array<ReturnType<typeof tryGetContext>> = [];
const execute = vi.fn(async () => {
observedContexts.push(tryGetContext());
return { finishReason: "stop" };
});
const deps = createMockDeps({
webhooksConfig: {
enabled: true,
mappings: [{ id: "m1", name: "test", agentId: "agent-b" }],
} as any,
getExecutor: vi.fn(() => ({ execute })) as any,
});
mountGatewayRoutes(deps);
const config = vi.mocked(createMappedWebhookEndpoint).mock.calls.at(-1)![0] as any;

await config.onAgentAction(
{ id: "m1", name: "test", agentId: "agent-b" },
"perform task",
"azdo:12816",
);

const context = observedContexts[0];
const turnScope = context?.turnScope;
const deliveryOrigin = context?.deliveryOrigin;
expect(turnScope).toBeDefined();
expect(deliveryOrigin).toBeDefined();
if (turnScope === undefined || deliveryOrigin === undefined) return;
expect(deliveryOrigin.channelType).toBe(turnScope.endpoint.channelType);
expect(deliveryOrigin.channelId).toBe(turnScope.endpoint.conversationId);
expect(deliveryOrigin.userId).toBe(turnScope.principal.principalId);
expect(deliveryOrigin.threadId).toBe(turnScope.endpoint.threadId);
// The pair must satisfy the schema the background-task manager parses with,
// not merely look consistent field by field.
const conversationRef = createConversationRef(turnScope.conversation);
expect(conversationRef.ok).toBe(true);
if (!conversationRef.ok) return;
const origin = BackgroundTaskOriginSchema.safeParse({
turnScope,
conversationRef: conversationRef.value,
deliveryOrigin,
traceId: context?.traceId ?? null,
responseLocalePolicy: { source: "unset", enforceLocale: false },
backgroundHopCount: 0,
});
expect(
origin.success,
`a webhook turn must be able to promote a background task: ${JSON.stringify(
origin.success ? [] : origin.error.issues.map((issue) => issue.message),
)}`,
).toBe(true);
// The webhook surface stays the channel of record end to end — an operator
// reading the scope, the origin or the session key sees "webhook", not an
// internal origin kind standing in for it.
expect(turnScope.endpoint.channelType).toBe("webhook");
expect(turnScope.endpoint.conversationId).toBe("azdo:12816");
});

it("frames rendered webhook content with the resolved session delimiter before execution", async () => {
Expand Down Expand Up @@ -286,24 +428,19 @@ describe("mountGatewayRoutes", () => {
expect(replay?.delimiter).toBe(first?.delimiter);
expect(observed[0]!.context).toMatchObject({
tenantId: "test",
userId: "webhook",
sessionKey: formatSessionKey({
tenantId: "test",
agentId: "agent-b",
userId: "webhook",
channelId: "hook-session",
}),
agentId: "agent-b",
channelType: "webhook",
trustLevel: "guest",
});
expect(observed[0]!.context?.userId).toMatch(/^webhook-[a-f0-9]{64}$/);
expect(observed[0]!.context?.sessionKey as string).toContain("hook-session");
expect(observed[0]!.message).toMatchObject({
channelId: "hook-session",
channelType: "webhook",
senderId: "webhook",
attachments: [],
metadata: { webhookMappingId: "m1" },
});
expect(observed[0]!.message.senderId).toMatch(/^webhook-[a-f0-9]{64}$/);
});

it("records a terminal webhook executor error as a failed action", async () => {
Expand Down Expand Up @@ -453,12 +590,10 @@ describe("mountGatewayRoutes", () => {
await cfg.onAgentAction({ id: "m1", name: "devtask" }, "build is_prime", "hook:devtask:x");
expect(reapNeverTaskedDrives).toHaveBeenCalledWith("default", {
agentId: "default",
sessionKey: formatSessionKey({
tenantId: "test",
agentId: "default",
userId: "webhook",
channelId: "hook:devtask:x",
}),
// The reap targets the resolved conversation; a template-rendered key
// containing delimiters stays intact inside it (JSON-encoded, so ":" in
// "hook:devtask:x" can never split the conversation identity).
sessionKey: expect.stringContaining("hook:devtask:x"),
});
const delivered = (deps.container.eventBus.emit as any).mock.calls.find((c: unknown[]) => c[0] === "diagnostic:webhook_delivered");
expect(delivered).toBeDefined();
Expand Down
53 changes: 46 additions & 7 deletions packages/daemon/src/wiring/setup-gateway-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
classifyExecutionFinishReason,
} from "@comis/orchestrator";
import { bindApiExecutionCancellation } from "./api-execution-cancellation.js";
import { resolveWebhookTurnIdentity } from "./setup-gateway/gateway-session-principal.js";

interface OpenaiApiEnv extends Env {
Variables: { clientScopes: readonly string[] };
Expand Down Expand Up @@ -316,17 +317,54 @@ export function mountGatewayRoutes(deps: GatewayRouteDeps): void {
onAgentAction: async (_mapping, renderedMessage, renderedSessionKey) => {
const execAgentId = _mapping.agentId ?? defaultAgentId;
const routeChannelId = renderedSessionKey || "webhook";
const sk: SessionKey = {
// Resolve the canonical turn scope BEFORE building the context. A webhook
// action dispatches straight to the executor, so nothing upstream resolves
// identity for it — and the executor fail-closes on a turn with no
// conversation authority. The resolved identity is the SINGLE authority for
// this turn: its display session key and its turn scope describe the same
// conversation, so the session view and the memory/context authority can
// never diverge.
const identity = resolveWebhookTurnIdentity({
tenantId: container.config.tenantId,
agentId: execAgentId,
userId: "webhook",
channelId: routeChannelId,
};
...(_mapping.id === undefined ? {} : { mappingId: _mapping.id }),
renderedSessionKey: routeChannelId,
});
if (!identity.ok) {
gatewayLogger.error(
{
webhookId: _mapping.id ?? "unknown",
agentId: execAgentId,
hint: "Verify the webhook mapping's agentId and sessionKey template resolve to a valid conversation before re-firing",
errorKind: "validation" as const,
},
"Webhook agent action rejected because its turn identity could not be resolved",
);
emitObservationalEventSafely({ eventBus: container.eventBus, logger: gatewayLogger }, "diagnostic:webhook_delivered", {
webhookId: _mapping.id ?? "unknown",
source: _mapping.name ?? "webhook",
event: "agent_action",
statusCode: 500,
success: false,
failureReason: "handler_error",
durationMs: 0,
timestamp: systemNowMs(),
});
throw identity.error;
}
const sk: SessionKey = identity.value.displaySessionKey;
// Derive the delivery origin FROM the resolved endpoint, as the cron and
// durable-resume initiators do. The two are one authority downstream:
// background-task promotion, the capability lease principal and the
// durable principal all reject a turn whose origin names a different
// channel or conversation than its scope, so a hand-built origin would
// let the turn start and then fail-close at each of those seams.
const endpoint = identity.value.turnScope.endpoint;
const deliveryOrigin = createDeliveryOrigin({
tenantId: sk.tenantId,
userId: sk.userId,
channelType: "webhook",
channelId: routeChannelId,
userId: identity.value.turnScope.principal.principalId,
channelType: endpoint.channelType,
channelId: endpoint.conversationId,
});
return runWithContext({
traceId: randomUUID(),
Expand All @@ -346,6 +384,7 @@ export function mountGatewayRoutes(deps: GatewayRouteDeps): void {
agentId: execAgentId,
trustLevel: "guest",
deliveryOrigin,
turnScope: identity.value.turnScope,
});
if (!resolvedContext.ok) throw resolvedContext.error;
const msg: NormalizedMessage = {
Expand Down
Loading
Loading