fix(host): harden skill.invoke at the enforcement boundary

Review fixes: recheck isUserInvocable on the loaded definition (list and
get collect independently, so a provider change between them could swap in
a user-disabled body — the skill-tool execute template's second check);
thread the carrier signal through the lookup and refuse an abandoned
caller's turn as cancelled; fold lookup/loader failures into the
structured internal error the list face already uses; refuse cwd-less
sessions with the skill.list stance; and reject blank trailing text at the
wire schema instead of relying on client trimming.
This commit is contained in:
Yichen Jiang
2026-08-08 11:30:14 +08:00
parent 69bd00ae76
commit c4c2355b50
6 changed files with 155 additions and 30 deletions

View File

@@ -27,11 +27,14 @@ export const skillListValueSchema = z.object({
skills: z.array(skillEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
/** skill.invoke request payload. */
/**
* skill.invoke request payload. `text` is the user's trailing message; a
* blank one stays off the wire (the boundary, not client courtesy, refuses it).
*/
export const skillInvokeRequestSchema = z.object({
sessionId: sessionIdSchema,
name: z.string().min(1),
text: z.string().optional(),
text: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'skill.invoke'>>>
/** skill.invoke response value. */

View File

@@ -29,9 +29,13 @@ export interface SkillsApi {
* Injects one user-invocable skill into the addressed agent as a user-role
* message (the canonical `<skill_content>` rendering, with `text` appended
* when present) and starts a turn. The host enforces user-invocation policy
* here: a model-only or unknown name is refused regardless of what a client
* menu offered. Session-backed subagents reject with `agent-busy`.
* here — on the discovery summary and again on the loaded definition, so a
* catalog change between the two lookups cannot slip a user-disabled body
* through — a model-only or unknown name is refused regardless of what a
* client menu offered. The carrier's request signal aborts the skill
* lookup and refuses injection once the caller has given up (`cancelled`).
* Session-backed subagents reject with `agent-busy`.
*/
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>):
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal):
Promise<RpcResponse<{ accepted: true }>>
}