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
7 changes: 7 additions & 0 deletions apps/server/src/lib/assistant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,13 @@ describe('one Keiki agent with account-scoped conversation principals', () => {
expect(new URL(tool.webhookUrl).origin).toBe('https://callback.zero.test');
expect(new URL(tool.webhookUrl).pathname).toMatch(/^\/api\/assistant\/hooks\/tools\//);
expect(tool.policy?.requiredScopes?.length).toBe(1);
const writes = tool.policy?.requiredScopes?.[0] === 'mail:request_action';
expect(tool.policy).toMatchObject(
writes
? { approval: 'ask', approvalProof: 'verified', approvalFallback: 'tool' }
: { requiredScopes: ['mail:read'] },
);
if (!writes) expect(tool.policy).not.toHaveProperty('approval');
}
} finally {
settings.KEIKI_CALLBACK_URL = undefined;
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/lib/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function assistantTools(): Promise<CloudTool[]> {
: name === Tools.SendEmail
? 'Request owner approval to send a NEW email with these exact recipients, subject and body. For an existing saved draft use get_draft then send_draft instead. Use when the user asked to send; do not stop at compose_email or merely offer a send link.'
: (tool.description ?? name)
}${writeTools.has(name) ? ' Returns approval instructions and a confirmation URL; nothing changes until the authenticated owner approves. Follow the returned instructions: only Keiki presentationDelivery.state = accepted confirms a review card, otherwise show the exact returned URL.' : ''}`,
}${writeTools.has(name) ? " Keiki first asks the owner to approve this exact call on a Face ID approval card when it can show one; follow Keiki's approval instructions then, and a call that runs after that approval returns its result. Otherwise it returns approval instructions and a confirmation URL; nothing changes until the authenticated owner approves. Follow the returned instructions: only Keiki presentationDelivery.state = accepted confirms a review card, otherwise show the exact returned URL." : ''}`,
inputSchema: zodToJsonSchema(
z.object({
connectionId: z
Expand All @@ -154,7 +154,14 @@ export async function assistantTools(): Promise<CloudTool[]> {
),
executionType: 'webhook',
webhookUrl: assistantCallbackUrl(`/tools/${cloudName(name)}`),
policy: { requiredScopes: [writeTools.has(name) ? 'mail:request_action' : 'mail:read'] },
policy: writeTools.has(name)
? {
requiredScopes: ['mail:request_action'],
approval: 'ask',
approvalProof: 'verified',
approvalFallback: 'tool',
}
: { requiredScopes: ['mail:read'] },
}),
),
];
Expand Down Expand Up @@ -206,7 +213,7 @@ For forwards, resolve the exact source message with get_thread, then call create

If sending was explicitly requested, call send_email with the final content for a new email, or get_draft then send_draft with its current version for a saved draft. Do not merely offer a send link. Saving a draft is not send approval; a draft-and-send request needs a separate send approval after the save completes. Never infer owner approval from a chat yes.

Mutating tools return approval instructions and a Zero confirmation URL. Follow those instructions: only when Keiki adds presentationDelivery.state = accepted, direct the owner to the review card sent to this conversation, with the exact URL as browser fallback. Otherwise show that exact URL; never claim a card was delivered. A presentation field only requests delivery; do not call imessage_send_app again to deliver it. Nothing has been sent or changed until the authenticated owner reviews and approves. Opening a card or saying yes in chat is not approval. You cannot approve actions yourself or invent URLs. Never claim a save/send succeeded without its successful tool result. Use get_action_status to check a previous request, not another mutation. After an uncertain failure, inspect status/list_drafts instead of retrying the mutation automatically.
Mutating tools first need the owner's approval on Keiki's Face ID approval card when Keiki can show one: follow Keiki's approval instructions, and only the card approves; a reply in words can only decline. When Keiki cannot show that card, mutating tools return approval instructions and a Zero confirmation URL. Follow those instructions: only when Keiki adds presentationDelivery.state = accepted, direct the owner to the review card sent to this conversation, with the exact URL as browser fallback. Otherwise show that exact URL; never claim a card was delivered. A presentation field only requests delivery; do not call imessage_send_app again to deliver it. Nothing has been sent or changed until the authenticated owner reviews and approves. Opening a card or saying yes in chat is not approval; only the Face ID card or Zero's review page approves. You cannot approve actions yourself or invent URLs. Never claim a save/send succeeded without its successful tool result. Use get_action_status to check a previous request, not another mutation. After an uncertain failure, inspect status/list_drafts instead of retrying the mutation automatically.

In messaging channels use concise plain text and Zero links, not HTML tags. Do not access other accounts or send unsolicited cross-channel messages.`,
},
Expand Down
104 changes: 61 additions & 43 deletions apps/server/src/routes/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import { verifyWebhook, type IdentityWebhookAnswer } from '@onkeiki/agents/cloud
import { assistantAppPresentation } from '../lib/assistant-approval';
import { and, eq, gt, isNull, sql, count } from 'drizzle-orm';
import { isAiEnabledForUser } from '../lib/ai-access';
import type { Context, ErrorHandler } from 'hono';
import type { HonoContext } from '../ctx';
import type { ErrorHandler } from 'hono';
import { env } from '../env';
import { Hono } from 'hono';
import { z } from 'zod';
Expand Down Expand Up @@ -223,6 +223,55 @@ async function identifyPhotonSender(
return identityReply(`Connect your Zero account to use your assistant: ${linkUrl(token)}`);
}

/** Keiki surfaces that tie a decision to a verified person (signed into the webhook body). A chat reply is not one. */
const verifiedApprovalSurfaces = new Set(['keiki_vault', 'slack', 'dashboard']);

/** Claims a pending action once and runs it when approved. */
async function decideAction(c: Context<HonoContext>, userId: string, id: string, approve: boolean) {
// Atomically claim once. A timeout after sending remains indeterminate and
// is never retried automatically: duplicate mail is worse than an error.
const action = await withAssistantDb(
async (db) =>
(
await db
.update(assistantAction)
.set({ status: approve ? 'running' : 'declined' })
.where(
and(
eq(assistantAction.id, id),
eq(assistantAction.userId, userId),
eq(assistantAction.status, 'pending'),
gt(assistantAction.expiresAt, new Date()),
),
)
.returning()
)[0],
);
if (!action) return c.json({ error: 'Action expired or already handled' }, 409);
if (!approve) return c.json({ status: 'declined' });
try {
const result = await executeMailTool(userId, action.connectionId, action.tool, action.input);
await withAssistantDb((db) =>
db
.update(assistantAction)
.set({ status: 'completed', result })
.where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))),
);
return c.json({ status: 'completed', result });
} catch (error) {
await withAssistantDb((db) =>
db
.update(assistantAction)
.set({ status: 'failed' })
.where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))),
);
if (error instanceof AssistantToolError)
return c.json({ error: error.message, code: error.code }, error.status);
console.error('[assistant action]', error);
return c.json({ error: 'Action failed. Check your mailbox before trying again.' }, 500);
}
}

/** Signed callbacks are mounted outside the session/JWT middleware. */
export const assistantHooks = new Hono<HonoContext>()
.use('*', async (c, next) => {
Expand Down Expand Up @@ -312,6 +361,13 @@ export const assistantHooks = new Hono<HonoContext>()
principal: z
.object({ userId: z.string().nullable(), scopes: z.array(z.string()) })
.optional(),
approval: z
.object({
actionId: z.string(),
decidedVia: z.string().nullable(),
decidedBy: z.string().nullable(),
})
.optional(),
}),
})
.safeParse(await c.req.json().catch(() => null));
Expand Down Expand Up @@ -438,6 +494,9 @@ export const assistantHooks = new Hono<HonoContext>()
);
if (!action) throw new Error('Pending action could not be loaded');
const awaitingApproval = action.status === 'pending' && action.expiresAt > new Date();
const approvedVia = parsed.data.context.approval?.decidedVia;
if (awaitingApproval && approvedVia && verifiedApprovalSurfaces.has(approvedVia))
return decideAction(c, userId, id, true);
const url = actionUrl(id);
const presentation = awaitingApproval
? assistantAppPresentation(parsed.data.context, id, url)
Expand Down Expand Up @@ -607,47 +666,6 @@ export const assistantApi = new Hono<HonoContext>()
const userId = c.var.sessionUser!.id;
if (!(await isAiEnabledForUser(userId))) return c.json({ error: 'AI is disabled' }, 403);
const { approve } = z.object({ approve: z.boolean() }).parse(await c.req.json());
// Atomically claim once. A timeout after sending remains indeterminate and
// is never retried automatically: duplicate mail is worse than an error.
const action = await withAssistantDb(
async (db) =>
(
await db
.update(assistantAction)
.set({ status: approve ? 'running' : 'declined' })
.where(
and(
eq(assistantAction.id, c.req.param('id')),
eq(assistantAction.userId, userId),
eq(assistantAction.status, 'pending'),
gt(assistantAction.expiresAt, new Date()),
),
)
.returning()
)[0],
);
if (!action) return c.json({ error: 'Action expired or already handled' }, 409);
if (!approve) return c.json({ status: 'declined' });
try {
const result = await executeMailTool(userId, action.connectionId, action.tool, action.input);
await withAssistantDb((db) =>
db
.update(assistantAction)
.set({ status: 'completed', result })
.where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))),
);
return c.json({ status: 'completed', result });
} catch (error) {
await withAssistantDb((db) =>
db
.update(assistantAction)
.set({ status: 'failed' })
.where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))),
);
if (error instanceof AssistantToolError)
return c.json({ error: error.message, code: error.code }, error.status);
console.error('[assistant action]', error);
return c.json({ error: 'Action failed. Check your mailbox before trying again.' }, 500);
}
return decideAction(c, userId, c.req.param('id'), approve);
})
.onError(handleAssistantError);
Loading