diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index f90af54f..cf099a04 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -80,6 +80,9 @@ YOKE is the vendor-agnostic interface layer. Each adapter implements the same co | `yoke/index.js` | Adapter factory, wraps createQuery with project instructions | | `yoke/interface.js` | YOKE interface contract definition | | `yoke/adapters/claude.js` | Claude adapter using `@anthropic-ai/claude-agent-sdk`. In-process + worker (OS user isolation) paths | +| `yoke/adapters/permission-relay.js` | The wire shape of a tool-permission ask between a Claude worker and the daemon, including the SDK's presentation hints (`suppressAlwaysAllowRule`, `defaultToNo`, `matchedAskRule`); both sides build and read it here | +| `yoke/adapters/effort-control.js` | Changing a running Claude session's effort through `applyFlagSettings({effortLevel})`; shared by the in-process handle and the worker | +| `yoke/adapters/claude-capabilities.js` | What a Claude session can do, sent to the client (including the `permissionModes` the mode picker offers) | | `yoke/adapters/codex.js` | Codex adapter using `codex app-server` JSON-RPC protocol. Handles approval events, skill injection, MCP bridge config | | `yoke/codex-app-server.js` | Codex `app-server` child process manager. JSON-RPC 2.0 over stdin/stdout, request ID tracking, event routing | | `yoke/mcp-bridge-server.js` | Stdio MCP server spawned by Codex. Proxies tool list/call to the daemon via HTTP at `/api/mcp-bridge` | @@ -144,7 +147,8 @@ Bootstraps UI, initializes store, wires remaining Tier 3 modules. All business l | `prompts.js` | The client's single owner of operator-prompt cards: every prompt message, of every kind and recorded shape, applies through `apply()`; a card is a function of the server's state for its requestId | | `prompt-state.js` | Per-requestId mirror of the server's prompt state (terminal states are final) and the mapping of older recorded message shapes onto the one prompt shape | | `prompt-card.js` | The card shell every kind shares: pending/sending/confirmed state machine, terminal outcome rendering | -| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels | +| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels. `permission-choices.js` decides which answers a tool-permission ask offers, and in what order, from the SDK's hints; the card and the banner both read it | +| `mode-options.js` | The permission modes the config chip offers: the three every session has, Don't ask and Auto only for a vendor that declares them, Full auto when the daemon allows it | | `app-skills-install.js` | Skill install dialog, requireSkills | | `app-favicon.js` | Dynamic favicon, IO blink, urgent blink, send button mode, activity indicator | | `app-header.js` | Session rename, session info popover, progressive history loading | diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index e6872721..0645c17c 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -155,6 +155,8 @@ graph LR For deeper Codex integration patterns and gotchas, see [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md). +**Mid-session controls.** The Claude SDK's `Query` has no `setEffort`; the Claude adapter changes a running session's effort through `applyFlagSettings({effortLevel})` (`yoke/adapters/effort-control.js`, shared by the in-process handle and the worker). A QueryHandle's `setEffort` settles on the real outcome and rejects when the change did not take effect, so the bridge announces a new effort only after the query has taken it and shows the operator the failure otherwise; Codex reads its effort on the next turn. Which permission modes a session accepts is a capability: the Claude adapter lists `permissionModes` (including `dontAsk` and `auto`, which are Claude Code modes) in the capabilities it sends with `session_switched`, the mode picker (`public/modules/mode-options.js`) offers those two only when the session's vendor declares them, and `set_permission_mode` refuses them for a session whose vendor does not. A vendor whose capabilities are not known is treated as declaring nothing. + ## Multi-User and OS-Level Isolation Clagentic:Console supports two modes: @@ -248,6 +250,18 @@ A prompt is `pending` until exactly one transition settles it — an answer (`re Ownership decides scope. A top-level prompt ends with its turn or its query. A prompt whose tool call belongs to a tracked sub-agent (a running Task, or one that reported completion while that prompt was still open) survives the parent's turn and query boundaries, so the operator can still answer a backgrounded sub-agent after its parent finished; it ends only on an answer, its own abort, or the session or daemon ending. The abort is the vendor's word that the callback is gone: the Claude SDK aborts every callback still waiting when it closes a query, the worker relay does the same when the worker's query ends or the worker cancels a callback, and the Codex adapter when the app-server clears a request or the query ends. The registry never decides on its own that a sub-agent is dead. "Allow for Session" grants are written and consulted only through the registry, keyed by `permissionGrantKey()`, flushed to the session file immediately, and granted only from the authenticated in-app card: the HTTP push route answers tool approvals with allow or deny only. +#### Presentation hints on a tool-permission ask + +The Claude Agent SDK may attach three hints to an ask, and the console holds to each wherever it carries, shows or answers one. They travel from the SDK's `canUseTool` options into the prompt record on both Claude paths: in process, and over the worker IPC (`lib/yoke/adapters/permission-relay.js` builds the message in the worker and the options in the daemon, so neither side can drop a field the other carries). Codex approvals arrive through the same callback with no hints and are unchanged. + +- `suppressAlwaysAllowRule`: the ask must not offer a persistent choice. The permission kind refuses an `allow_always` answer to it as `invalid` on every response path (the card and its older aliases, the notification banner), which settles nothing, writes no grant and re-offers the prompt to the responder; the HTTP push route answers allow or deny only. Leaving the button off the card and the banner is the presentation of that rule, not its enforcement. +- `defaultToNo`: the ask must not open on an approve choice. The card and the banner lead with the decline choice, mark it the default and mark no approve choice; there is no single-key approve shortcut. +- `matchedAskRule`: a rule the user wrote forced the prompt, so the user's stated intent is a human prompt. The sdk bridge's host-side auto-approvals (full auto, an autonomous loop, the read-only whitelist, an earlier Allow for session) never answer such an ask. They do not answer an ask marked `suppressAlwaysAllowRule` or `defaultToNo` with a standing approval either, since a standing approval grants more than the ask itself. The rule (bounded text, whatever shape it arrives in) is recorded on the prompt record and on the `prompt_resolved` event, and the card shows it. + +The hints are request fields, so the recorded `prompt_request`, the `prompt_pending` snapshot sent on connect and the history pages all carry them, and a replayed card renders the same restrictions. The banner reads them from the notification's `meta`. + +A browser-extension command (kind `extension`) is answered by the socket it was sent to and no other: `extension_result` is checked against that socket, and a `prompt_response` naming an extension command is no operator answer on any path. + The registry's state lives where readers already looked for it: one pending map per store (`session.pendingPermissions` for tool and plan approvals, `session.pendingAskUser`, `session.pendingElicitations`), `session.subagentToolOwners`, `session.activeTaskToolIds` (Task tracking), and the session manager's `permissionRequestIndex`. A requestId, message type, decision, cancel reason or kind is whatever a client sent, so every map keyed by one, on the server and in the client, is read through `own-key.js` (`ownValue()` / `hasOwnKey()`: own properties only, strings only) and written through `putOwn()`. A name every object inherits (`constructor`, `__proto__`, `toString`, ...) is no prompt and no table entry: on every answer path it gets the not-found reply (`test/prompt-hostile-request-id.test.js`). #### Supported elicitation schemas diff --git a/lib/project-notifications.js b/lib/project-notifications.js index 3722dbbd..d83a8e96 100644 --- a/lib/project-notifications.js +++ b/lib/project-notifications.js @@ -65,7 +65,15 @@ var formatters = { type: "permission_request", title: data.title || "Permission requested", body: data.body || "", - meta: { requestId: data.requestId, kind: data.kind || "permission", toolName: data.toolName, toolInput: data.toolInput || null }, + meta: { + requestId: data.requestId, + kind: data.kind || "permission", + toolName: data.toolName, + toolInput: data.toolInput || null, + // So the banner offers the answers the card does (see prompt-banner.js). + suppressAlwaysAllowRule: data.suppressAlwaysAllowRule === true ? true : undefined, + defaultToNo: data.defaultToNo === true ? true : undefined, + }, }; }, diff --git a/lib/project-prompt-responses.js b/lib/project-prompt-responses.js index fe45209d..7a0d89eb 100644 --- a/lib/project-prompt-responses.js +++ b/lib/project-prompt-responses.js @@ -12,6 +12,7 @@ // upgrade can still answer; each alias is declared in lib/ws-schema.js. var { promptsFor } = require("./prompt-registry"); +var promptKinds = require("./prompt-kinds"); var { mayAnswerPrompt } = require("./prompt-access"); var { ownValue } = require("./prompt-kinds/codecs"); @@ -170,8 +171,12 @@ function attachPromptResponses(ctx) { } }); + // A browser-extension command belongs to the socket it was sent to, which + // answers with extension_result (project-user-message.js): a prompt_response + // naming one is no answer from anyone who could give it. function answer(ws, msg, kinds, staleReplyType) { - var outcome = mayAnswer(ws, msg.requestId) + var answerable = kinds.every(promptKinds.operatorMayAnswer) && mayAnswer(ws, msg.requestId); + var outcome = answerable ? promptsFor(sm).respond(msg.requestId, msg, { session: getSessionForWs(ws), kinds: kinds, responder: ws }) : { status: "stale" }; if (outcome.status === "resolved") return; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 2779289e..be4dc8aa 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -77,6 +77,12 @@ var PROCESS_STATS_BASE_FIELD_KEYS = [ "memAvailableMB", ]; +// auto and dontAsk exist only in Claude Code. A session whose vendor does not +// declare them (Codex, or a vendor whose capabilities are not known yet) cannot +// run them, so a request for one is refused rather than handed to a vendor +// that would reject or ignore it. +var CAPABILITY_GATED_MODES = { auto: true, dontAsk: true }; + function attachSessions(ctx) { var cwd = ctx.cwd; var slug = ctx.slug; @@ -135,6 +141,13 @@ function attachSessions(ctx) { ensureProjectAccessForSession: ensureProjectAccessForSession, }); + function sessionAcceptsMode(session, mode) { + if (!ownValue(CAPABILITY_GATED_MODES, mode)) return true; + var vendor = session.vendor || sm.defaultVendor || "claude"; + var caps = ownValue(sm.capabilitiesByVendor || Object.create(null), vendor); + return !!caps && Array.isArray(caps.permissionModes) && caps.permissionModes.indexOf(mode) !== -1; + } + function handleSessionsMessage(ws, msg) { if (promptResponses.handlePromptMessage(ws, msg)) return true; @@ -954,6 +967,11 @@ function attachSessions(ctx) { if (msg.type === "set_permission_mode" && msg.mode) { var session = getSessionForWs(ws); + if (session && !sessionAcceptsMode(session, msg.mode)) { + sendTo(ws, { type: "error", text: "This session's agent does not support the " + String(msg.mode).slice(0, 40) + " permission mode" }); + sendTo(ws, { type: "config_state", model: sm.effectiveSessionModel(session), mode: session.permissionMode || sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + return true; + } if (session) { sdk.setPermissionMode(session, msg.mode).then(function () { // Targeted reply — only update the requesting client's chip. @@ -992,12 +1010,23 @@ function attachSessions(ctx) { } if (msg.type === "set_effort" && msg.effort) { - sm.currentEffort = msg.effort; var session = getSessionForWs(ws); - if (session) { - sdk.setEffort(session, msg.effort); + if (!session) { + sm.currentEffort = msg.effort; + send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + return true; } - send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + // The effort is recorded only once the running query has taken it + // (sdk.setEffort announces it then). A refused change is reported by the + // bridge as an error; the requester's picker is put back on the effort + // the session still runs at. + sdk.setEffort(session, msg.effort).then(function (result) { + var state = { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }; + if (result && result.ok === false) sendTo(ws, state); + else send(state); + }).catch(function (err) { + console.error("[project-sessions] set_effort failed:", (err && err.message) || err); + }); return true; } diff --git a/lib/prompt-kinds/codecs.js b/lib/prompt-kinds/codecs.js index 452bfe20..6f1a7217 100644 --- a/lib/prompt-kinds/codecs.js +++ b/lib/prompt-kinds/codecs.js @@ -35,7 +35,6 @@ function sourcePath(name) { return path.join(SOURCE_DIR, FILES[name]); } -/** The file: URL of a shared module, as import() resolves it. */ function sourceUrl(name) { return pathToFileURL(sourcePath(name)).href; } diff --git a/lib/prompt-kinds/extension.js b/lib/prompt-kinds/extension.js index f6a551a0..9b77801a 100644 --- a/lib/prompt-kinds/extension.js +++ b/lib/prompt-kinds/extension.js @@ -10,6 +10,7 @@ module.exports = { name: "extension", scope: "project", journal: false, + operatorAnswerable: false, defaultTimeoutMs: 3000, fields: function (req) { diff --git a/lib/prompt-kinds/index.js b/lib/prompt-kinds/index.js index 68f790d3..ca2ebff0 100644 --- a/lib/prompt-kinds/index.js +++ b/lib/prompt-kinds/index.js @@ -23,7 +23,17 @@ function toolApprovalKind(toolName) { return toolName === "ExitPlanMode" ? "plan" : "permission"; } -/** Session fields that hold pending prompts, one per store. */ +/** + * Whether an operator's answer may settle prompts of this kind. A browser + * extension's command is answered by the extension's own socket and no other + * (lib/extension-commands.js), so no operator-facing answer path reaches it. + * An unknown kind is not answerable. + */ +function operatorMayAnswer(kindName) { + var kind = ownValue(KINDS, kindName); + return !!kind && kind.operatorAnswerable !== false; +} + function sessionStores() { var seen = {}; var out = []; @@ -52,6 +62,7 @@ function kindOfRecord(record, store) { module.exports = { KINDS: KINDS, toolApprovalKind: toolApprovalKind, + operatorMayAnswer: operatorMayAnswer, sessionStores: sessionStores, kindOfRecord: kindOfRecord, }; diff --git a/lib/prompt-kinds/permission.js b/lib/prompt-kinds/permission.js index 59c5e0d2..5968758e 100644 --- a/lib/prompt-kinds/permission.js +++ b/lib/prompt-kinds/permission.js @@ -3,12 +3,51 @@ // Tool-call approval: allow this call, allow the tool for the rest of the // session, or deny. Codex command, file-change and MCP approvals arrive here // through the same canUseTool callback as Claude's. +// +// The Claude Agent SDK may attach presentation hints to an ask, and the +// console holds to them wherever it shows or answers one: +// suppressAlwaysAllowRule the ask must not offer a persistent choice, so +// an Allow-for-session answer is refused here, not +// just left off the card; +// defaultToNo the ask must not open on an approve choice; +// matchedAskRule a user-written ask rule forced the prompt, so no +// host-side auto-approval may answer it. +// An ask without hints (Codex approvals, older CLIs) is unchanged. var toolCall = require("./tool-call"); var { hasOwnKey } = require("./codecs"); var DECISIONS = { allow: true, allow_always: true, deny: true }; +// A rule's source and text are authored outside the console and shown to the +// operator, so they are held to a bound and to strings. +var RULE_TEXT_MAX = 200; + +function ruleText(value) { + return typeof value === "string" ? value.slice(0, RULE_TEXT_MAX) : ""; +} + +// A present rule that is not the expected shape is still a rule-forced ask: +// the hint fails closed. +function matchedRuleOf(raw) { + if (!raw) return undefined; + var rule = typeof raw === "object" ? raw : {}; + var out = { source: ruleText(rule.source), toolName: ruleText(rule.toolName) }; + if (typeof rule.ruleContent === "string") out.ruleContent = ruleText(rule.ruleContent); + return out; +} + +// The hints present on a request or a stored record, normalized; absent ones +// are left out so an ask without hints is described exactly as before. +function hintsOf(source) { + var hints = {}; + if (source.suppressAlwaysAllowRule === true) hints.suppressAlwaysAllowRule = true; + if (source.defaultToNo === true) hints.defaultToNo = true; + var rule = matchedRuleOf(source.matchedAskRule); + if (rule) hints.matchedAskRule = rule; + return hints; +} + module.exports = { name: "permission", scope: "session", @@ -16,28 +55,43 @@ module.exports = { journal: true, fields: function (req) { - return { + return Object.assign({ toolName: req.toolName, toolInput: req.toolInput, decisionReason: req.decisionReason || "", vendor: req.vendor, - }; + }, hintsOf(req)); }, requestFields: function (record) { - return { + return Object.assign({ toolName: record.toolName, toolInput: record.toolInput, decisionReason: record.decisionReason || "", vendor: record.vendor, + }, hintsOf(record)); + }, + + // What a banner needs to offer the same answers as the card. + notificationFields: function (record) { + var hints = hintsOf(record); + return { + suppressAlwaysAllowRule: hints.suppressAlwaysAllowRule, + defaultToNo: hints.defaultToNo, }; }, decisions: Object.keys(DECISIONS), - // Anything but a known decision fails closed. - parse: function (msg) { - return { decision: hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny" }; + // Anything but a known decision fails closed. An Allow-for-session answer to + // an ask that must not offer one settles nothing: the prompt stays pending + // and is offered again to the responder. + parse: function (msg, record) { + var decision = hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny"; + if (decision === "allow_always" && record && record.suppressAlwaysAllowRule === true) { + return { invalid: "always_allow_not_offered" }; + } + return { decision: decision }; }, outcome: function (record, response, opts) { @@ -49,8 +103,11 @@ module.exports = { return response.decision === "allow_always"; }, - resolvedFields: function (response) { - return { decision: response.decision }; + resolvedFields: function (response, record) { + var hints = record ? hintsOf(record) : {}; + var fields = { decision: response.decision }; + if (hints.matchedAskRule) fields.matchedAskRule = hints.matchedAskRule; + return fields; }, cancelled: toolCall.cancelled, diff --git a/lib/prompt-registry.js b/lib/prompt-registry.js index 1ba9c09a..41b995c4 100644 --- a/lib/prompt-registry.js +++ b/lib/prompt-registry.js @@ -45,7 +45,7 @@ var crypto = require("crypto"); var utils = require("./utils"); var promptKinds = require("./prompt-kinds"); var codecs = require("./prompt-kinds/codecs"); -var { ownValue, putOwn } = codecs; +var { ownValue, putOwn, hasOwnKey } = codecs; var KINDS = promptKinds.KINDS; var STORES = promptKinds.sessionStores(); @@ -88,6 +88,8 @@ function terminalOf(e) { case "ask_user_answered": if (!e.toolId) return null; if (e.answers && Object.keys(e.answers).length) return { id: e.toolId, state: { state: "resolved", answers: e.answers } }; + // A recorded skip is an answer, not an ending nobody chose. + if (e.skipped === true) return { id: e.toolId, state: { state: "resolved", skipped: true } }; return { id: e.toolId, state: { state: "cancelled", reason: null } }; default: return null; @@ -207,7 +209,11 @@ function createPromptRegistry(deps) { pruneToolOwners(session, taskId); } - // Forget every Task that no longer owns a pending prompt. + // Forget every Task that no longer owns a pending prompt. Only the owning + // query's end and a new query's start call this, and a closed query takes + // its CLI process, and every sub-agent in it, down: a Task tracked without + // a prompt then has nothing left to ask. A replacement query's Tasks are + // never touched (endQuery gates on ownsSession). function pruneTasks(session) { var tasks = trackedTasks(session); Object.keys(tasks).forEach(function (taskId) { @@ -251,8 +257,12 @@ function createPromptRegistry(deps) { }); } + // The request index is shared by every session of the project, so a + // tool-use id is reused as a request id only while no session holds a prompt + // under it; otherwise the index entry that routes the other session's + // answers would be overwritten. function newRequestId(session, kind, toolUseId) { - if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId)) return toolUseId; + if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId) && !hasOwnKey(index, toolUseId)) return toolUseId; return crypto.randomUUID(); } @@ -337,7 +347,8 @@ function createPromptRegistry(deps) { if (kind.journal && opts.notification) { var nm = getNotificationsModule(); if (nm && typeof nm.notify === "function") { - nm.notify("permission_request", Object.assign({}, opts.notification, { requestId: requestId, kind: kind.name })); + var kindFields = typeof kind.notificationFields === "function" ? kind.notificationFields(record) : {}; + nm.notify("permission_request", Object.assign({}, opts.notification, kindFields, { requestId: requestId, kind: kind.name })); } } return { requestId: requestId, answer: answer, pending: true }; @@ -418,7 +429,7 @@ function createPromptRegistry(deps) { } if (kind.grantsSession(response)) grant(found.session, found.record.toolName, found.record.toolInput); var event = kind.journal - ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response)) + ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response, found.record)) : null; var prompt = publicView(requestId, found); settle(found.session, requestId, found, kind.outcome(found.record, response, opts), event); diff --git a/lib/public/css/rewind.css b/lib/public/css/rewind.css index 18a01d2f..2462cf47 100644 --- a/lib/public/css/rewind.css +++ b/lib/public/css/rewind.css @@ -616,6 +616,23 @@ .permission-deny { background: transparent; border-color: var(--border); color: var(--text-muted); } .permission-deny:hover { background: var(--error-8); border-color: var(--error); color: var(--error); opacity: 1; } +/* The user's own ask rule that forced the prompt. */ +.permission-rule { font-size: 12px; color: var(--text-muted); margin-top: 6px; } + +/* An ask that must open on No: the approve choices lose their primary + styling and the decline choice leads. */ +.permission-container[data-default-to-no="true"] .permission-allow, +.permission-container[data-default-to-no="true"] .perm-allow, +.permission-container[data-default-to-no="true"] .perm-allow:hover { + background: transparent; + color: var(--text-muted); + border-color: var(--border); +} +.permission-container[data-default-to-no="true"] [data-default-choice="true"] { + border-color: var(--text-dimmer); + color: var(--text); +} + /* Resolved states */ .permission-container.resolved .permission-btn { display: none; } .permission-decision-label { font-size: 13px; font-weight: 500; } diff --git a/lib/public/modules/app-notifications.js b/lib/public/modules/app-notifications.js index e4212369..e1b98c40 100644 --- a/lib/public/modules/app-notifications.js +++ b/lib/public/modules/app-notifications.js @@ -12,23 +12,10 @@ import { switchProject } from './app-projects.js'; import { userAvatarUrl } from './avatar.js'; import { openTerminal } from './terminal.js'; import { askWording } from './prompt-kinds/index.js'; -import { ownValue } from './prompt-kinds/own-key.js'; -import { wirePromptBanner, restoreUnconfirmedBanners } from './prompt-banner.js'; - -// What a prompt banner offers, by prompt kind. Kinds without an entry offer -// only "Go to session". The server dismisses the banner once the prompt -// settles, however it was answered. -var BANNER_ACTIONS = { - permission: [ - { decision: "allow", label: "Sure", className: "notif-banner-allow" }, - { decision: "allow_always", label: "Allow for session", className: "notif-banner-always" }, - { decision: "deny", label: "No", className: "notif-banner-deny" }, - ], - plan: [ - { decision: "allow", label: "Approve", className: "notif-banner-allow" }, - { decision: "deny", label: "Reject", className: "notif-banner-deny" }, - ], -}; +import { wirePromptBanner, restoreUnconfirmedBanners, bannerActions } from './prompt-banner.js'; + +// The server dismisses a prompt's banner once the prompt settles, however it +// was answered. var notifications = []; var unreadCount = 0; var bannerContainer = null; @@ -139,7 +126,7 @@ function showBanner(notif, autoDismissMs) { if (isPermission) { actionsHtml = '
' + - (ownValue(BANNER_ACTIONS, promptKind) || []).map(function (a) { + bannerActions(promptKind, notif.meta).map(function (a) { return ''; }).join("") + '' + diff --git a/lib/public/modules/app-panels.js b/lib/public/modules/app-panels.js index a027893c..caf5444d 100644 --- a/lib/public/modules/app-panels.js +++ b/lib/public/modules/app-panels.js @@ -8,6 +8,7 @@ import { getWs } from './ws-ref.js'; import { VENDOR_NAMES } from './app-rendering.js'; import { splitModelsByTier } from './settings-defaults.js'; import { isSonnetModel } from './model-families.js'; +import { modeOptions } from './mode-options.js'; // --- Module-owned state (not in store) --- // sessionUsage.context = last-turn context fill (lastStreamInputTokens) — not cumulative, @@ -82,12 +83,6 @@ var contextMiniFill = null; var contextMiniLabel = null; // --- Constants --- -var MODE_OPTIONS = [ - { value: "default", label: "Default" }, - { value: "plan", label: "Plan" }, - { value: "acceptEdits", label: "Auto-accept edits" }, -]; -var MODE_FULL_AUTO = { value: "bypassPermissions", label: "Full auto" }; var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; var EFFORT_LEVELS_BY_VENDOR = { claude: ["low", "medium", "high", "xhigh", "max"], @@ -132,15 +127,6 @@ function modelDisplayName(value, models) { return value; } -function modeDisplayName(value) { - for (var i = 0; i < MODE_OPTIONS.length; i++) { - if (MODE_OPTIONS[i].value === value) return MODE_OPTIONS[i].label; - } - if (value === "bypassPermissions") return "Full auto"; - if (value === "dontAsk") return "Don\u2019t ask"; - return value; -} - function effortDisplayName(value) { if (!value) return ""; if (value === "xhigh") return "X-High"; @@ -266,10 +252,7 @@ function rebuildModelList() { function rebuildModeList() { if (!configModeList) return; configModeList.innerHTML = ""; - var options = MODE_OPTIONS.slice(); - if (store.get('skipPermsEnabled')) { - options.push(MODE_FULL_AUTO); - } + var options = modeOptions(store.get('vendorCapabilities'), store.get('skipPermsEnabled')); for (var i = 0; i < options.length; i++) { var opt = options[i]; var btn = document.createElement("button"); @@ -480,7 +463,7 @@ export function initPanels() { if (vendorBtnCodex) vendorBtnCodex.addEventListener("click", function() { onVendorClick("codex"); }); // --- Reactive UI sync --- - store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking'], function (state, prev) { + store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking', 'vendorCapabilities'], function (state, prev) { // Vendor toggle state if (state.availableVendors !== prev.availableVendors || state.installedVendors !== prev.installedVendors || @@ -513,6 +496,7 @@ export function initPanels() { state.currentBetas !== prev.currentBetas || state.currentThinking !== prev.currentThinking || state.currentVendor !== prev.currentVendor || + state.vendorCapabilities !== prev.vendorCapabilities || state.codexApproval !== prev.codexApproval || state.codexSandbox !== prev.codexSandbox || state.codexWebSearch !== prev.codexWebSearch) { diff --git a/lib/public/modules/mode-options.js b/lib/public/modules/mode-options.js new file mode 100644 index 00000000..dd2f7f88 --- /dev/null +++ b/lib/public/modules/mode-options.js @@ -0,0 +1,38 @@ +// mode-options.js - the permission modes the config chip offers, and their +// labels. Import-free so it can be exercised without the frontend module +// graph. +// +// Default, Plan and Auto-accept edits are offered for every session. Don't +// ask and Auto exist only in Claude Code: they are offered for a session whose +// vendor declares them in capabilities.permissionModes, so a vendor that does +// not (Codex, or one whose capabilities are not known yet) never shows them. +// Full auto is offered when the daemon allows it. Labels follow the Claude +// Code CLI's wording. + +var BASE_MODES = [ + { value: "default", label: "Default" }, + { value: "plan", label: "Plan" }, + { value: "acceptEdits", label: "Auto-accept edits" }, +]; + +var CAPABILITY_MODES = [ + { value: "dontAsk", label: "Don’t ask" }, + { value: "auto", label: "Auto" }, +]; + +var FULL_AUTO = { value: "bypassPermissions", label: "Full auto" }; + +/** + * @param {?object} capabilities - the session vendor's capabilities. + * @param {boolean} fullAutoEnabled + * @returns {{value: string, label: string}[]} + */ +export function modeOptions(capabilities, fullAutoEnabled) { + var declared = capabilities && Array.isArray(capabilities.permissionModes) ? capabilities.permissionModes : []; + var options = BASE_MODES.slice(); + CAPABILITY_MODES.forEach(function (m) { + if (declared.indexOf(m.value) !== -1) options.push(m); + }); + if (fullAutoEnabled) options.push(FULL_AUTO); + return options; +} diff --git a/lib/public/modules/prompt-banner.js b/lib/public/modules/prompt-banner.js index 7fb3bed8..c8116e66 100644 --- a/lib/public/modules/prompt-banner.js +++ b/lib/public/modules/prompt-banner.js @@ -10,10 +10,41 @@ // it can be exercised without the rest of the frontend module graph. import { definePromptSurface, submitPromptResponse, restoreUnconfirmedUnder } from './prompt-card.js'; +import { permissionChoices } from './prompt-kinds/permission-choices.js'; var DECISION_BUTTONS = "button[data-decision]"; var ACTIONS = ".notif-banner-actions"; +var PERMISSION_BUTTONS = { + allow: { label: "Sure", className: "notif-banner-allow" }, + allow_always: { label: "Allow for session", className: "notif-banner-always" }, + deny: { label: "No", className: "notif-banner-deny" }, +}; + +var PLAN_BUTTONS = [ + { decision: "allow", label: "Approve", className: "notif-banner-allow" }, + { decision: "deny", label: "Reject", className: "notif-banner-deny" }, +]; + +/** + * The answer buttons a prompt's banner offers; a kind with none offers only + * "Go to session". A tool-permission banner offers the card's answers, in the + * card's order: no Allow for session for an ask that may not offer it, and + * No first for an ask that must open on No. + * @param {string} kind + * @param {object} [meta] - the notification's meta (the ask's hints). + * @returns {{decision: string, label: string, className: string}[]} + */ +export function bannerActions(kind, meta) { + if (kind === "permission") { + return permissionChoices(meta).map(function (decision) { + return Object.assign({ decision: decision }, PERMISSION_BUTTONS[decision]); + }); + } + if (kind === "plan") return PLAN_BUTTONS.map(function (a) { return Object.assign({}, a); }); + return []; +} + /** * Wire a banner's decision buttons to answer its prompt. * @param {Element} banner diff --git a/lib/public/modules/prompt-card.js b/lib/public/modules/prompt-card.js index 21e3ac7f..a15bbd38 100644 --- a/lib/public/modules/prompt-card.js +++ b/lib/public/modules/prompt-card.js @@ -120,7 +120,15 @@ export function submitPromptResponse(container, trySend, opts) { if (isSettled(container) || isUnconfirmed(container)) return false; var sent = false; - try { sent = !!trySend(); } catch (e) { sent = false; } + try { + sent = !!trySend(); + } catch (e) { + // A failure of the send itself is not a dropped connection, and saying so + // would send the operator to reconnect for nothing. + console.error("[prompt-card] sending an answer failed:", e); + addNote(container, "Could not send the answer (" + ((e && e.message) || "unexpected error") + "). Choose again to retry.", true); + return false; + } if (!sent) { addNote(container, "Not connected. Reconnect, then choose again.", true); return false; diff --git a/lib/public/modules/prompt-kinds/permission-choices.js b/lib/public/modules/prompt-kinds/permission-choices.js new file mode 100644 index 00000000..dad74d7f --- /dev/null +++ b/lib/public/modules/prompt-kinds/permission-choices.js @@ -0,0 +1,31 @@ +// permission-choices.js - which answers a tool-permission ask offers, and in +// what order. The card (permission.js) and the notification banner +// (../prompt-banner.js) both read it, so they cannot offer different answers +// to one ask. +// +// The Claude Agent SDK may attach presentation hints to an ask: +// suppressAlwaysAllowRule no persistent "allow for session" choice; +// defaultToNo the ask opens on its decline choice, so a stray +// keystroke or tap cannot approve it. +// The server refuses an Allow-for-session answer to a suppressed ask whatever +// a client sends (lib/prompt-kinds/permission.js); omitting the choice here is +// the presentation of that rule, not its enforcement. +// +// Deliberately free of imports so the banner can load it without the rest of +// the frontend module graph. + +/** + * @param {object} [hints] - the ask's request fields. + * @returns {string[]} decisions in the order they are shown; the first is the + * default choice when the ask is defaultToNo. + */ +export function permissionChoices(hints) { + var h = hints || {}; + var approve = h.suppressAlwaysAllowRule === true ? ["allow"] : ["allow", "allow_always"]; + return h.defaultToNo === true ? ["deny"].concat(approve) : approve.concat(["deny"]); +} + +/** Whether an ask must open on its decline choice. */ +export function opensOnNo(hints) { + return !!hints && hints.defaultToNo === true; +} diff --git a/lib/public/modules/prompt-kinds/permission.js b/lib/public/modules/prompt-kinds/permission.js index dedc258c..9b660e96 100644 --- a/lib/public/modules/prompt-kinds/permission.js +++ b/lib/public/modules/prompt-kinds/permission.js @@ -1,9 +1,50 @@ // Tool-call approval card: Allow Once / Allow for Session / Deny, drawn as a // formal dialog (bubble layout) or a conversational "Can I ...?" (channel -// layout). The shell (prompts.js, prompt-card.js) owns its state. +// layout). The shell (prompts.js, prompt-card.js) owns its state. Which +// answers it offers, and which leads, follows the ask's SDK hints +// (permission-choices.js). import { escapeHtml } from '../utils.js'; import { iconHtml } from '../icons.js'; +import { permissionChoices, opensOnNo } from './permission-choices.js'; + +var FORMAL_CHOICES = { + allow: { className: "permission-btn permission-allow", label: "Allow Once" }, + allow_always: { className: "permission-btn permission-allow-session", label: "Allow for Session" }, + deny: { className: "permission-btn permission-deny", label: "Deny" }, +}; + +var CONVERSATIONAL_CHOICES = { + allow: { className: "perm-reply perm-allow", label: "Sure" }, + allow_always: { className: "perm-reply perm-always", label: "Allow for session" }, + deny: { className: "perm-reply perm-deny", label: "No" }, +}; + +// The buttons for an ask's answers, in the order the ask allows. An ask that +// must open on No leads with its decline button and marks it the default; +// no approve button is ever marked. +function choiceButtons(fields, table, container, host) { + var leadsWithNo = opensOnNo(fields); + return permissionChoices(fields).map(function (decision, i) { + var spec = table[decision]; + var btn = button(spec.className, spec.label, function () { + host.respond(container, { decision: decision }); + }); + if (leadsWithNo && i === 0) btn.dataset.defaultChoice = "true"; + return btn; + }); +} + +// The user's own ask rule that forced this prompt, when the SDK says so. +function ruleNote(fields) { + var rule = fields.matchedAskRule; + if (!rule || typeof rule !== "object") return null; + var rendered = (rule.toolName || "") + (rule.ruleContent ? "(" + rule.ruleContent + ")" : ""); + var note = document.createElement("div"); + note.className = "permission-rule"; + note.textContent = "Asked because of your rule" + (rendered ? " " + rendered : "") + (rule.source ? " (" + rule.source + ")" : ""); + return note; +} function shortPath(p) { if (!p) return ""; @@ -95,19 +136,14 @@ function drawFormal(fields, requestId, host) { reason.textContent = fields.decisionReason; body.appendChild(reason); } + var rule = ruleNote(fields); + if (rule) body.appendChild(rule); body.appendChild(detailsElement("permission-details", fields.toolInput)); var actions = document.createElement("div"); actions.className = "permission-actions"; - actions.appendChild(button("permission-btn permission-allow", "Allow Once", function () { - host.respond(container, { decision: "allow" }); - })); - actions.appendChild(button("permission-btn permission-allow-session", "Allow for Session", function () { - host.respond(container, { decision: "allow_always" }); - })); - actions.appendChild(button("permission-btn permission-deny", "Deny", function () { - host.respond(container, { decision: "deny" }); - })); + choiceButtons(fields, FORMAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); }); + if (opensOnNo(fields)) container.dataset.defaultToNo = "true"; container.appendChild(header); container.appendChild(body); @@ -143,19 +179,14 @@ function drawConversational(fields, requestId, host) { askEl.className = "perm-ask"; askEl.textContent = "Can I " + wording.verb + (wording.target ? " " + wording.target : "") + "?"; content.appendChild(askEl); + var rule = ruleNote(fields); + if (rule) content.appendChild(rule); content.appendChild(detailsElement("perm-details", fields.toolInput)); var actions = document.createElement("div"); actions.className = "permission-actions"; - actions.appendChild(button("perm-reply perm-allow", "Sure", function () { - host.respond(container, { decision: "allow" }); - })); - actions.appendChild(button("perm-reply perm-always", "Allow for session", function () { - host.respond(container, { decision: "allow_always" }); - })); - actions.appendChild(button("perm-reply perm-deny", "No", function () { - host.respond(container, { decision: "deny" }); - })); + choiceButtons(fields, CONVERSATIONAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); }); + if (opensOnNo(fields)) container.dataset.defaultToNo = "true"; content.appendChild(actions); container.appendChild(content); return container; diff --git a/lib/public/modules/prompt-state.js b/lib/public/modules/prompt-state.js index 951d9832..eb04b09f 100644 --- a/lib/public/modules/prompt-state.js +++ b/lib/public/modules/prompt-state.js @@ -128,6 +128,10 @@ export function normalizePromptMessage(msg) { if (msg.answers && Object.keys(msg.answers).length) { return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { answers: msg.answers }, serverState: null }; } + // A recorded skip is an answer, not an ending nobody chose. + if (msg.skipped === true) { + return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { skipped: true }, serverState: null }; + } return { phase: "cancel", requestId: msg.toolId, kind: "ask_user", fields: { reason: null }, serverState: null }; case "prompt_cancel": case "permission_cancel": diff --git a/lib/sdk-bridge.js b/lib/sdk-bridge.js index de113d68..1a3f58b5 100644 --- a/lib/sdk-bridge.js +++ b/lib/sdk-bridge.js @@ -1013,15 +1013,22 @@ function createSDKBridge(opts) { } function handleCanUseTool(session, toolName, input, opts) { + opts = opts || {}; + // An ask the user's own ask rule forced is a human prompt by the user's + // stated intent (the SDK's matchedAskRule): none of the host-side + // auto-approvals below - full auto, an autonomous loop, the read-only + // whitelist, an earlier Allow for session - may answer it. + var ruleForced = !!opts.matchedAskRule; + // Full-auto mode: auto-approve everything except AskUserQuestion // (which still needs to go through the user interaction flow). - if (sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") { + if (!ruleForced && sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") { return Promise.resolve({ behavior: "allow", updatedInput: input }); } // Ralph Loop execution: auto-approve all tools, deny interactive ones. // Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md. - if (session.loop && session.loop.active && session.loop.role !== "crafting") { + if (!ruleForced && session.loop && session.loop.active && session.loop.role !== "crafting") { if (toolName === "AskUserQuestion") { return Promise.resolve({ behavior: "deny", message: "Autonomous mode. Make your own decision." }); } @@ -1032,7 +1039,7 @@ function createSDKBridge(opts) { } // Check shared whitelist (read-only tools, safe browser tools, safe bash commands) - var whitelisted = checkToolWhitelist(toolName, input); + var whitelisted = ruleForced ? null : checkToolWhitelist(toolName, input); if (whitelisted) { return Promise.resolve(whitelisted); } @@ -1052,7 +1059,16 @@ function createSDKBridge(opts) { // Auto-approve if tool was previously allowed for session (keyed on // toolName + input discriminator, not bare toolName — see // permissionGrantKey's doc comment for why the Skill tool needs this). - if (kind === "permission" && prompts.isGranted(session, toolName, input)) { + // An Allow-for-session grant is a standing rule for every later call with + // the same key. The SDK forbids that for an ask marked + // suppressAlwaysAllowRule (a rule would grant more than this ask's own + // action), so such an ask is never answered by an earlier grant. An ask + // marked defaultToNo must open on No, which a silent approval would + // bypass; that is the stricter, safe reading of the hint. A rule-forced ask + // (matched_ask_rule) was put to the operator on purpose and is never + // auto-approved either. + var needsOwnAnswer = ruleForced || opts.suppressAlwaysAllowRule === true || opts.defaultToNo === true; + if (kind === "permission" && !needsOwnAnswer && prompts.isGranted(session, toolName, input)) { return Promise.resolve({ behavior: "allow", updatedInput: input }); } @@ -1074,6 +1090,9 @@ function createSDKBridge(opts) { toolInput: input, decisionReason: opts.decisionReason || "", vendor: session.vendor || (adapter && adapter.vendor) || "claude", + suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule, + defaultToNo: opts.defaultToNo, + matchedAskRule: opts.matchedAskRule, }, { toolUseId: opts.toolUseID, signal: opts.signal, @@ -2417,18 +2436,26 @@ function createSDKBridge(opts) { } } + // Returns { ok: true } or { ok: false, error } like setModel: the effort is + // recorded and announced only once the running query has taken it, and a + // failure is shown to the operator instead of reading as a change. async function setEffort(session, effort) { if (!session.queryInstance) { sm.currentEffort = effort; send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] }); - return; + return { ok: true }; } - // Route through QueryHandle (works for both in-process and worker paths) - if (typeof session.queryInstance.setEffort === "function") { + try { + // Route through QueryHandle (works for both in-process and worker paths) await session.queryInstance.setEffort(effort); + } catch (e) { + var errMsg = (e && e.message) || String(e); + send({ type: "error", text: "Failed to change effort: " + errMsg }); + return { ok: false, error: errMsg }; } sm.currentEffort = effort; send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] }); + return { ok: true }; } async function setPermissionMode(session, mode) { diff --git a/lib/ws-schema.js b/lib/ws-schema.js index 6ab7e171..209217a1 100644 --- a/lib/ws-schema.js +++ b/lib/ws-schema.js @@ -116,13 +116,14 @@ var schema = { // Operator prompts: one shape for every kind (lib/prompt-registry.js). // kind is permission, plan, ask_user or elicitation; the kind's own fields // ride flat on the message (permission/plan: toolName, toolInput, - // decisionReason, vendor; ask_user: input; elicitation: serverName, + // decisionReason, vendor, and for permission the SDK's hints when set: + // suppressAlwaysAllowRule, defaultToNo, matchedAskRule; ask_user: input; elicitation: serverName, // message, mode, url, elicitationId, requestedSchema). // ----------------------------------------------------------------------- "prompt_request": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt awaits the operator: {requestId, kind, toolUseId, ...kind fields}. On history replay it carries promptState {state: pending|resolved|cancelled, decision|answers|skipped|action, reason}, the server's authoritative state for the prompt at replay time" }, "prompt_pending": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt still awaiting the operator, re-presented on connect and session switch; same fields as prompt_request" }, - "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's" }, - "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}" }, + "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's. Decision allow_always to a permission prompt that carries suppressAlwaysAllowRule settles nothing: the prompt is sent again as prompt_pending. A prompt of the extension kind is never answered this way" }, + "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}; a permission prompt forced by a user ask rule also carries matchedAskRule" }, "prompt_cancel": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt ended without an answer: {requestId, kind, reason}; reason is aborted, expired, turn_ended, query_ended, rewind, cleared or shutdown. Reason \"stale\" is sent only to a client whose prompt_response named a prompt the server no longer holds, and is not recorded in history" }, // Aliases kept for compatibility: the s2c types appear in history recorded diff --git a/lib/yoke/adapters/claude-capabilities.js b/lib/yoke/adapters/claude-capabilities.js new file mode 100644 index 00000000..c2a17f1c --- /dev/null +++ b/lib/yoke/adapters/claude-capabilities.js @@ -0,0 +1,26 @@ +"use strict"; + +// What a Claude session can do, as the client sees it (sent with +// session_switched). permissionModes names the SDK permission modes the +// session accepts through setPermissionMode: the mode picker offers dontAsk +// and auto only for a vendor that lists them, so a vendor that cannot run one +// never shows it. + +var PERMISSION_MODES = ["default", "plan", "acceptEdits", "dontAsk", "auto"]; + +function claudeCapabilities() { + return { + thinking: true, + betas: true, + rewind: true, + sessionResume: true, + promptSuggestions: true, + elicitation: true, + fileCheckpointing: true, + contextCompacting: true, + toolPolicy: ["ask", "allow-all"], + permissionModes: PERMISSION_MODES.slice(), + }; +} + +module.exports = { claudeCapabilities: claudeCapabilities, PERMISSION_MODES: PERMISSION_MODES }; diff --git a/lib/yoke/adapters/claude-worker.js b/lib/yoke/adapters/claude-worker.js index f7157912..33017827 100644 --- a/lib/yoke/adapters/claude-worker.js +++ b/lib/yoke/adapters/claude-worker.js @@ -19,6 +19,8 @@ var path = require("path"); var fs = require("fs"); var os = require("os"); var parseDiagnosticLine = require("./diagnostic-patterns").parseDiagnosticLine; +var permissionRelay = require("./permission-relay"); +var effortControl = require("./effort-control"); var socketPath = process.argv[2]; if (!socketPath) { @@ -305,14 +307,7 @@ function handleMessage(msg) { // --- canUseTool: delegates to daemon via IPC --- function canUseTool(toolName, input, opts) { var requestId = crypto.randomUUID(); - sendToDaemon({ - type: "permission_request", - requestId: requestId, - toolName: toolName, - input: input, - toolUseId: opts.toolUseID || "", - decisionReason: opts.decisionReason || "", - }); + sendToDaemon(permissionRelay.toRequestMessage(requestId, toolName, input, opts)); return new Promise(function(resolve) { var timeoutId = setTimeout(function() { if (pendingPermissions[requestId]) { @@ -643,13 +638,18 @@ async function handleSetModel(msg) { } } +// requestId is echoed on both outcomes so the daemon's pending setEffort() +// resolves or rejects on the real result. async function handleSetEffort(msg) { - if (!queryInstance) return; + if (!queryInstance) { + sendToDaemon({ type: "worker_error", error: "Failed to set effort: no running query", requestId: msg.requestId }); + return; + } try { - await queryInstance.setEffort(msg.effort); - sendToDaemon({ type: "effort_changed", effort: msg.effort }); + await effortControl.applyEffort(queryInstance, msg.effort); + sendToDaemon({ type: "effort_changed", effort: msg.effort, requestId: msg.requestId }); } catch (e) { - sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e) }); + sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e), requestId: msg.requestId }); } } diff --git a/lib/yoke/adapters/claude.js b/lib/yoke/adapters/claude.js index 2c3594fd..097a66d4 100644 --- a/lib/yoke/adapters/claude.js +++ b/lib/yoke/adapters/claude.js @@ -13,6 +13,9 @@ var { spawn } = require("child_process"); var { resolveOsUserInfo } = require("../../os-users"); var modelFamilies = require("../../model-families"); var modelCatalog = require("../../model-catalog"); +var permissionRelay = require("./permission-relay"); +var effortControl = require("./effort-control"); +var claudeCapabilities = require("./claude-capabilities").claudeCapabilities; // --- SDK loading --- // Async loader (ESM dynamic import, same pattern as current project.js getSDK) @@ -380,10 +383,11 @@ function createQueryHandle(rawQuery, messageQueue, abortController) { return Promise.resolve(); }, + // The SDK's Query has no setEffort; applyFlagSettings is the live control. + // Rejects when the change did not take effect, so the caller never + // reports an effort the session is not running at. setEffort: function(effort) { - // Claude SDK has no setEffort on active query. - // Stored at the console level for next query. - return Promise.resolve(); + return effortControl.applyEffort(rawQuery, effort); }, setToolPolicy: function(policy) { @@ -806,6 +810,27 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // then project-sessions.js) to surface to the user. var pendingSetModel = Object.create(null); + // Same correlation for setEffort: the returned promise settles on the + // worker's real outcome, or rejects when the worker never answers. + var pendingSetEffort = Object.create(null); + var SET_EFFORT_TIMEOUT_MS = 15000; + + function settleSetEffort(requestId, error) { + var pending = pendingSetEffort[requestId]; + if (!pending) return; + delete pendingSetEffort[requestId]; + clearTimeout(pending.timer); + if (error) pending.reject(error); + else pending.resolve(); + } + + // The worker's query is over: no pending effort change can still land. + function failPendingEfforts() { + Object.keys(pendingSetEffort).forEach(function (requestId) { + settleSetEffort(requestId, new Error("The Claude query ended before the effort change took effect")); + }); + } + // ":" -> AbortController whose signal is handed to // the daemon-side callback, so the daemon-side prompt ends when the worker // stops waiting for it: its own timeout, the SDK cancelling the callback, @@ -886,11 +911,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) case "permission_request": if (canUseTool) { relayPrompt("permission:" + msg.requestId, function (signal) { - return canUseTool(msg.toolName, msg.input, { - toolUseID: msg.toolUseId, - decisionReason: msg.decisionReason, - signal: signal, - }); + return canUseTool(msg.toolName, msg.input, permissionRelay.toCanUseToolOptions(msg, signal)); }, function (result) { worker.send({ type: "permission_response", requestId: msg.requestId, result: result }); }, "permission_response"); @@ -952,12 +973,16 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) break; case "context_usage": - case "effort_changed": case "permission_mode_changed": // Yield these as _worker_meta events so processQueryStream can handle them pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); break; + case "effort_changed": + pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); + settleSetEffort(msg.requestId, null); + break; + case "model_changed": // lr-f22787: still yielded as a _worker_meta event (unchanged // consumer contract for sdk-bridge.js), AND resolves the pending @@ -973,11 +998,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) break; case "worker_error": + // A failure of an in-flight set_effort request is reported by the + // rejection of setEffort() alone: also yielding it as a _worker_meta + // event would show the operator the same error twice. + if (msg.requestId && pendingSetEffort[msg.requestId]) { + settleSetEffort(msg.requestId, new Error(msg.error || "Failed to set effort")); + break; + } // Yielded as a _worker_meta event (unchanged consumer contract), AND // rejects the pending setModel() promise when this worker_error - // belongs to an in-flight set_model request (lr-f22787). worker_error - // is also used for effort/permission-mode failures, which carry no - // requestId and so never match a pendingSetModel entry. + // belongs to an in-flight set_model request (lr-f22787). A + // permission-mode failure carries no requestId and so never matches + // a pendingSetModel entry. pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); if (msg.requestId && pendingSetModel[msg.requestId]) { var smErr = pendingSetModel[msg.requestId]; @@ -1001,6 +1033,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) worker._queryEnded = true; // The worker's query is over; nothing in it waits for an answer. abortAllPrompts(); + failPendingEfforts(); endIter(); break; @@ -1010,6 +1043,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) if (msg._fallback && worker._queryEnded) break; worker._queryEnded = true; abortAllPrompts(); + failPendingEfforts(); var err = new Error(msg.error || "Worker query error"); err.exitCode = msg.exitCode; err.stderr = msg.stderr; @@ -1095,8 +1129,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) }, setEffort: function(effort) { - worker.send({ type: "set_effort", effort: effort }); - return Promise.resolve(); + var requestId = crypto.randomUUID(); + return new Promise(function(resolve, reject) { + var timer = setTimeout(function() { + settleSetEffort(requestId, new Error("The Claude worker did not confirm the effort change")); + }, SET_EFFORT_TIMEOUT_MS); + pendingSetEffort[requestId] = { resolve: resolve, reject: reject, timer: timer }; + try { + worker.send({ type: "set_effort", requestId: requestId, effort: effort }); + } catch (e) { + settleSetEffort(requestId, e); + } + }); }, setToolPolicy: function(policy) { @@ -1199,7 +1243,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // vendor-reported list always wins outright when present (see // enrichClaudeModel below) — this table exists purely to cover the case // where the vendor reports no levels at all, not as the primary source. -var CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +var CLAUDE_EFFORT_LEVELS = effortControl.EFFORT_LEVELS; // Display name, thinking/effort capability heuristics, and family/version // parsing all live in ../../model-families.js (lr-d91ecf) — the single @@ -1825,17 +1869,7 @@ function createClaudeAdapter(opts) { skills: [], slashCommands: [], fastModeState: null, - capabilities: { - thinking: true, - betas: true, - rewind: true, - sessionResume: true, - promptSuggestions: true, - elicitation: true, - fileCheckpointing: true, - contextCompacting: true, - toolPolicy: ["ask", "allow-all"], - }, + capabilities: claudeCapabilities(), }; try { @@ -1915,17 +1949,7 @@ function createClaudeAdapter(opts) { skills: r.skills || [], slashCommands: r.slashCommands || [], fastModeState: r.fastModeState || null, - capabilities: { - thinking: true, - betas: true, - rewind: true, - sessionResume: true, - promptSuggestions: true, - elicitation: true, - fileCheckpointing: true, - contextCompacting: true, - toolPolicy: ["ask", "allow-all"], - }, + capabilities: claudeCapabilities(), }); worker.kill(); } else if (msg.type === "warmup_error" && !warmupDone) { @@ -1962,7 +1986,9 @@ module.exports = { // alias untouched, not a substituted concrete ID.) _test_mergeStaticCatalog: mergeStaticCatalog, _test_enrichClaudeModelsWithCatalog: enrichClaudeModelsWithCatalog, - // Test seam: drives the worker IPC prompt relay over a fake worker. + // Test seams: drive the in-process query handle over a fake SDK Query and + // the worker IPC relay over a fake worker. + _test_createQueryHandle: createQueryHandle, _test_createWorkerQueryHandle: createWorkerQueryHandle, }; diff --git a/lib/yoke/adapters/effort-control.js b/lib/yoke/adapters/effort-control.js new file mode 100644 index 00000000..3060c045 --- /dev/null +++ b/lib/yoke/adapters/effort-control.js @@ -0,0 +1,34 @@ +"use strict"; + +// Changing the reasoning effort of a Claude session that is already running. +// The SDK's Query has no setEffort; the live control is applyFlagSettings +// with an effortLevel, which takes effect for the rest of the session. +// 'max' is session-only and runs as 'high' on a model without it, which the +// SDK decides, so the console passes it through. +// +// Shared by the in-process query handle and the worker process, so the two +// paths cannot disagree about what a valid level is or what a failure is. + +var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; + +/** + * @param {object} query - the SDK Query. + * @param {string} effort - one of EFFORT_LEVELS. + * @returns {Promise} rejects with a message fit to show the operator. + */ +function applyEffort(query, effort) { + if (EFFORT_LEVELS.indexOf(effort) === -1) { + return Promise.reject(new Error("Unknown effort level: " + String(effort).slice(0, 40))); + } + if (!query || typeof query.applyFlagSettings !== "function") { + return Promise.reject(new Error("This Claude Code version cannot change effort in a running session")); + } + return Promise.resolve().then(function () { + return query.applyFlagSettings({ effortLevel: effort }); + }); +} + +module.exports = { + EFFORT_LEVELS: EFFORT_LEVELS, + applyEffort: applyEffort, +}; diff --git a/lib/yoke/adapters/permission-relay.js b/lib/yoke/adapters/permission-relay.js new file mode 100644 index 00000000..1ff2d45b --- /dev/null +++ b/lib/yoke/adapters/permission-relay.js @@ -0,0 +1,58 @@ +"use strict"; + +// The wire shape of a tool-permission ask between a Claude worker process and +// the daemon. The worker builds the message from the options the SDK handed +// its canUseTool callback; the daemon turns it back into the options its own +// canUseTool callback takes. Both sides use these two functions so a field the +// SDK adds to an ask cannot be carried by one and dropped by the other. +// +// The presentation hints travel with the ask: suppressAlwaysAllowRule (no +// persistent "don't ask again" choice), defaultToNo (the ask must not open on +// an approve choice) and matchedAskRule (a user-written ask rule forced the +// prompt, so no host-side auto-approval may answer it). Only a literal true +// restricts, and a rule is data, not a flag: anything present but not an object +// is still a rule-forced ask. + +/** + * @param {string} requestId - the worker's id for this ask. + * @param {string} toolName + * @param {object} input + * @param {object} opts - the SDK's canUseTool options. + * @returns {object} the IPC message. + */ +function toRequestMessage(requestId, toolName, input, opts) { + var msg = { + type: "permission_request", + requestId: requestId, + toolName: toolName, + input: input, + toolUseId: opts.toolUseID || "", + decisionReason: opts.decisionReason || "", + }; + if (opts.suppressAlwaysAllowRule === true) msg.suppressAlwaysAllowRule = true; + if (opts.defaultToNo === true) msg.defaultToNo = true; + if (opts.matchedAskRule) msg.matchedAskRule = opts.matchedAskRule; + return msg; +} + +/** + * @param {object} msg - a permission_request IPC message from a worker. + * @param {AbortSignal} signal - ends the ask when the worker stops waiting. + * @returns {object} canUseTool options for the daemon-side callback. + */ +function toCanUseToolOptions(msg, signal) { + var opts = { + toolUseID: msg.toolUseId, + decisionReason: msg.decisionReason, + signal: signal, + }; + if (msg.suppressAlwaysAllowRule === true) opts.suppressAlwaysAllowRule = true; + if (msg.defaultToNo === true) opts.defaultToNo = true; + if (msg.matchedAskRule) opts.matchedAskRule = typeof msg.matchedAskRule === "object" ? msg.matchedAskRule : {}; + return opts; +} + +module.exports = { + toRequestMessage: toRequestMessage, + toCanUseToolOptions: toCanUseToolOptions, +}; diff --git a/lib/yoke/interface.js b/lib/yoke/interface.js index ae13470e..c6b60e64 100644 --- a/lib/yoke/interface.js +++ b/lib/yoke/interface.js @@ -56,7 +56,12 @@ var TOOL_POLICIES = ["ask", "allow-all"]; * [Symbol.asyncIterator]() - yields SDK events (raw in Phase 3, normalized later) * .pushMessage(text, images) * .setModel(model) - * .setEffort(effort) + * .setEffort(effort) - Promise; settles once the running query has + * taken the change and rejects (with a message + * fit to show the operator) when it did not. The + * Claude SDK's Query has no setEffort: the Claude + * adapter applies it through applyFlagSettings + * ({effortLevel}); Codex reads it on the next turn. * .setToolPolicy(policy) - "ask" | "allow-all" * .stopTask(taskId) * .getContextUsage() - Promise diff --git a/test/claude-effort-control.test.js b/test/claude-effort-control.test.js new file mode 100644 index 00000000..35873595 --- /dev/null +++ b/test/claude-effort-control.test.js @@ -0,0 +1,235 @@ +"use strict"; +// Changing a Claude session's effort while it runs. The SDK's Query has no +// setEffort; the live control is applyFlagSettings({effortLevel}). Every path +// that takes an effort change - the in-process query handle, the OS-isolated +// worker, the bridge and the WS handler - must act on that and must say so +// when the change did not take effect, instead of announcing an effort the +// session is not running at. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var world = require("./prompt-harness-world"); +var effortControl = require("../lib/yoke/adapters/effort-control"); +var claudeAdapter = require("../lib/yoke/adapters/claude"); + +// The shape of the real Query for this purpose: applyFlagSettings, no setEffort. +function fakeQuery(calls, failWith) { + return { + applyFlagSettings: function (settings) { + calls.push(settings); + return failWith ? Promise.reject(failWith) : Promise.resolve(); + }, + }; +} + +function inbox(client) { return client.inbox; } + +var client = world.client; +var withServer = world.withServer; + +test("every effort level the SDK names is applied through applyFlagSettings", async function () { + assert.deepEqual(effortControl.EFFORT_LEVELS, ["low", "medium", "high", "xhigh", "max"]); + for (var i = 0; i < effortControl.EFFORT_LEVELS.length; i++) { + var calls = []; + await effortControl.applyEffort(fakeQuery(calls), effortControl.EFFORT_LEVELS[i]); + assert.deepEqual(calls, [{ effortLevel: effortControl.EFFORT_LEVELS[i] }]); + } +}); + +test("a level the SDK does not name, or a query that cannot change effort, is a rejection and changes nothing", async function () { + var hostile = ["minimal", "", undefined, null, 3, {}, ["high"], "__proto__", "HIGH"]; + for (var i = 0; i < hostile.length; i++) { + var calls = []; + await assert.rejects(effortControl.applyEffort(fakeQuery(calls), hostile[i]), /Unknown effort level/, JSON.stringify(hostile[i])); + assert.deepEqual(calls, [], "nothing was applied for " + JSON.stringify(hostile[i])); + } + await assert.rejects(effortControl.applyEffort({}, "high"), /cannot change effort/); + await assert.rejects(effortControl.applyEffort(null, "high"), /cannot change effort/); + var boom = new Error("organization effort limit"); + await assert.rejects(effortControl.applyEffort(fakeQuery([], boom), "max"), /organization effort limit/, "the SDK's refusal reaches the caller"); + await assert.rejects(effortControl.applyEffort({ applyFlagSettings: function () { throw new Error("sync throw"); } }, "low"), /sync throw/, "a throw is a rejection too"); +}); + +test("the in-process query handle changes effort through the query, and rejects when it cannot", async function () { + var calls = []; + var queue = { push: function () {}, end: function () {} }; + var handle = claudeAdapter._test_createQueryHandle(fakeQuery(calls), queue, null); + await handle.setEffort("xhigh"); + assert.deepEqual(calls, [{ effortLevel: "xhigh" }]); + + var bare = claudeAdapter._test_createQueryHandle({}, queue, null); + await assert.rejects(bare.setEffort("high"), /cannot change effort/); + await assert.rejects(handle.setEffort("minimal"), /Unknown effort level/); +}); + +// --- the worker's daemon-side handle --------------------------------------- + +function fakeWorker() { + var worker = { sent: [], handler: null, process: null, exitPromise: null }; + worker.onMessage = function (fn) { worker.handler = fn; }; + worker.send = function (msg) { worker.sent.push(msg); }; + return worker; +} + +async function drain(handle, worker) { + worker.handler({ type: "query_done" }); + var seen = []; + var iter = handle[Symbol.asyncIterator](); + for (;;) { + var r = await iter.next(); + if (r.done) return seen; + seen.push(r.value); + } +} + +test("a worker effort change settles on the worker's own answer", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var change = handle.setEffort("high"); + var state = world.isSettled(change); + + assert.equal(worker.sent.length, 1); + assert.equal(worker.sent[0].type, "set_effort"); + assert.equal(worker.sent[0].effort, "high"); + assert.equal(typeof worker.sent[0].requestId, "string"); + await Promise.resolve(); + assert.equal(state.settled, false, "not settled until the worker answers"); + + worker.handler({ type: "effort_changed", effort: "high", requestId: worker.sent[0].requestId }); + await change; + var seen = await drain(handle, worker); + assert.ok(seen.some(function (e) { return e.type === "_worker_meta" && e.subtype === "effort_changed"; }), "the bridge still hears effort_changed"); +}); + +test("a worker effort change the worker refuses is a rejection, reported once", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var change = handle.setEffort("max"); + worker.handler({ type: "worker_error", error: "Failed to set effort: organization effort limit", requestId: worker.sent[0].requestId }); + await assert.rejects(change, /organization effort limit/); + var seen = await drain(handle, worker); + assert.equal(seen.filter(function (e) { return e.type === "_worker_meta" && e.subtype === "worker_error"; }).length, 0, + "the rejection is the report: no second copy as a stream event"); +}); + +test("an answer for another request, or none, does not settle a worker effort change", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var outcome = null; + var change = handle.setEffort("low").then(function () { outcome = "resolved"; }, function (e) { outcome = e.message; }); + + worker.handler({ type: "effort_changed", effort: "low", requestId: "someone-else" }); + worker.handler({ type: "worker_error", error: "other", requestId: "__proto__" }); + worker.handler({ type: "effort_changed", effort: "low" }); + await Promise.resolve(); + assert.equal(outcome, null); + + t.mock.timers.tick(15000); + await change; + assert.match(outcome, /did not confirm the effort change/); +}); + +test("a worker query that ends rejects an effort change still waiting", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var change = handle.setEffort("medium"); + worker.handler({ type: "query_error", error: "worker died" }); + await assert.rejects(change, /ended before the effort change/); + var iter = handle[Symbol.asyncIterator](); + await assert.rejects(iter.next()); +}); + +// --- the bridge and the WS handler ----------------------------------------- + +test("the bridge announces an effort only once the running query has taken it", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setEffort = function (effort) { applied.push(effort); return Promise.resolve(); }; + server.sm.currentEffort = "medium"; + inbox(c).length = 0; + + var ok = await server.bridge.setEffort(server.session, "high"); + assert.deepEqual(ok, { ok: true }); + assert.deepEqual(applied, ["high"]); + assert.equal(server.sm.currentEffort, "high"); + var states = inbox(c).filter(function (m) { return m.type === "config_state"; }); + assert.equal(states[states.length - 1].effort, "high"); + + server.session.queryInstance.setEffort = function () { return Promise.reject(new Error("organization effort limit")); }; + inbox(c).length = 0; + var failed = await server.bridge.setEffort(server.session, "max"); + assert.deepEqual(failed, { ok: false, error: "organization effort limit" }); + assert.equal(server.sm.currentEffort, "high", "the effort the session runs at is unchanged"); + assert.equal(inbox(c).filter(function (m) { return m.type === "config_state"; }).length, 0, "no change is announced"); + var errors = inbox(c).filter(function (m) { return m.type === "error"; }); + assert.equal(errors.length, 1); + assert.match(errors[0].text, /Failed to change effort: organization effort limit/); + }); +}); + +test("a session with no running query records the effort for its next query", async function () { + await withServer(async function (server) { + await server.endQuery(); + assert.equal(server.session.queryInstance, null); + var result = await server.bridge.setEffort(server.session, "low"); + assert.deepEqual(result, { ok: true }); + assert.equal(server.sm.currentEffort, "low"); + await server.startQuery(); + assert.equal(server.queryOptions[server.queryOptions.length - 1].effort, "low", "the next query starts at that effort"); + }); +}); + +test("set_effort over the socket: a refused change tells the requester and puts its picker back; an accepted one is broadcast", async function () { + await withServer(async function (server) { + var asker = client(); + var other = client(); + server.connect(asker.ws); + server.connect(other.ws); + server.sm.currentEffort = "medium"; + + // Mid-turn, as well as between turns: the path does not depend on it. + server.session.isProcessing = true; + server.session.queryInstance.setEffort = function (effort) { + return effort === "max" ? Promise.reject(new Error("organization effort limit")) : Promise.resolve(); + }; + + inbox(asker).length = 0; + inbox(other).length = 0; + server.handlers.handleSessionsMessage(asker.ws, { type: "set_effort", effort: "max" }); + await world.flush(); + var askerStates = inbox(asker).filter(function (m) { return m.type === "config_state"; }); + assert.equal(askerStates.length, 1, "the requester's picker is put back"); + assert.equal(askerStates[0].effort, "medium"); + assert.equal(inbox(asker).filter(function (m) { return m.type === "error"; }).length, 1); + assert.equal(inbox(other).filter(function (m) { return m.type === "config_state"; }).length, 0, "nobody else is told of a change that did not happen"); + + inbox(asker).length = 0; + inbox(other).length = 0; + server.handlers.handleSessionsMessage(asker.ws, { type: "set_effort", effort: "high" }); + await world.flush(); + var otherStates = inbox(other).filter(function (m) { return m.type === "config_state"; }); + assert.ok(otherStates.length >= 1); + assert.equal(otherStates[otherStates.length - 1].effort, "high"); + assert.equal(server.sm.currentEffort, "high"); + }); +}); + +test("a restored session starts its query at the persisted permission mode and effort", async function () { + await withServer(async function (server) { + await server.endQuery(); + server.session.permissionMode = "dontAsk"; + server.sm.currentEffort = "xhigh"; + await server.startQuery(); + var opts = server.queryOptions[server.queryOptions.length - 1]; + assert.equal(opts.effort, "xhigh"); + assert.equal(opts.adapterOptions.CLAUDE.permissionMode, "dontAsk"); + + await server.endQuery(); + server.session.permissionMode = "auto"; + await server.startQuery(); + assert.equal(server.queryOptions[server.queryOptions.length - 1].adapterOptions.CLAUDE.permissionMode, "auto"); + }); +}); diff --git a/test/claude-worker-process.test.js b/test/claude-worker-process.test.js new file mode 100644 index 00000000..e4e61d6b --- /dev/null +++ b/test/claude-worker-process.test.js @@ -0,0 +1,155 @@ +"use strict"; +// A real claude-worker.js process, over a real unix socket, with the Claude +// Agent SDK swapped for a stand-in (test/fixtures/fake-agent-sdk) whose Query +// has applyFlagSettings and setPermissionMode and no setEffort, as the real +// 0.3.x Query. What the worker puts on the socket is what the daemon-side +// relay (lib/yoke/adapters/claude.js) receives in OS-isolated multi-user mode. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var os = require("os"); +var net = require("net"); +var path = require("path"); +var { spawn } = require("child_process"); +var { pathToFileURL } = require("url"); + +var WORKER = path.join(__dirname, "..", "lib", "yoke", "adapters", "claude-worker.js"); +var REGISTER = pathToFileURL(path.join(__dirname, "fixtures", "fake-agent-sdk", "register.mjs")).href; +var WAIT_MS = 20000; + +// Starts the worker against a fresh socket; returns helpers to talk to it. +async function startWorker() { + var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-worker-test-")); + var socketPath = path.join(dir, "w.sock"); + var received = []; + var waiters = []; + var connection = null; + var buffer = ""; + + function deliver() { + waiters = waiters.filter(function (w) { + var index = received.findIndex(w.predicate); + if (index === -1) return true; + w.resolve(received.splice(index, 1)[0]); + return false; + }); + } + + var server = net.createServer(function (conn) { + connection = conn; + conn.on("data", function (chunk) { + buffer += chunk.toString(); + var lines = buffer.split("\n"); + buffer = lines.pop(); + lines.filter(Boolean).forEach(function (line) { received.push(JSON.parse(line)); }); + deliver(); + }); + conn.on("error", function () {}); + }); + await new Promise(function (resolve) { server.listen(socketPath, resolve); }); + + var child = spawn(process.execPath, ["--import", REGISTER, WORKER, socketPath], { + env: { PATH: process.env.PATH, HOME: dir }, + cwd: dir, + stdio: ["ignore", "ignore", "ignore"], + }); + + var worker = { + next: function (predicate, label) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { reject(new Error("timed out waiting for " + label)); }, WAIT_MS); + waiters.push({ predicate: predicate, resolve: function (m) { clearTimeout(timer); resolve(m); } }); + deliver(); + }); + }, + probe: function (call, label) { + return worker.next(function (m) { return m.type === "sdk_event" && m.event.type === "fake_probe" && m.event.call === call; }, label || call); + }, + send: function (msg) { connection.write(JSON.stringify(msg) + "\n"); }, + stop: async function () { + try { child.kill("SIGKILL"); } catch (e) { /* already gone */ } + await new Promise(function (resolve) { server.close(resolve); if (connection) connection.destroy(); }); + fs.rmSync(dir, { recursive: true, force: true }); + }, + }; + await worker.next(function (m) { return m.type === "ready"; }, "ready"); + return worker; +} + +async function withQuery(fn) { + var worker = await startWorker(); + try { + worker.send({ type: "query_start", prompt: null, options: { cwd: os.tmpdir() }, singleTurn: false }); + await worker.next(function (m) { return m.type === "sdk_event" && m.event.subtype === "init"; }, "the query's first event"); + await fn(worker); + } finally { + await worker.stop(); + } +} + +test("a tool-permission ask in the worker reaches the daemon with its presentation hints, and the answer comes back", async function () { + await withQuery(async function (worker) { + worker.send({ type: "push_message", content: { type: "user", message: { role: "user", content: [{ type: "text", text: "ask" }] } } }); + var request = await worker.next(function (m) { return m.type === "permission_request"; }, "permission_request"); + + assert.equal(request.toolName, "Bash"); + assert.deepEqual(request.input, { command: "make" }); + assert.equal(request.toolUseId, "tu-fake"); + assert.equal(request.decisionReason, "a rule asks"); + assert.equal(request.suppressAlwaysAllowRule, true, "no persistent choice"); + assert.equal(request.defaultToNo, true, "opens on No"); + assert.deepEqual(request.matchedAskRule, { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }); + + worker.send({ type: "permission_response", requestId: request.requestId, result: { behavior: "deny", message: "no" } }); + var answered = await worker.probe("canUseTool"); + assert.deepEqual(answered.event.result, { behavior: "deny", message: "no" }); + }); +}); + +test("an effort change reaches the running query through applyFlagSettings and is confirmed with its requestId", async function () { + await withQuery(async function (worker) { + worker.send({ type: "set_effort", requestId: "e-1", effort: "high" }); + var confirmed = await worker.next(function (m) { return m.type === "effort_changed"; }, "effort_changed"); + assert.equal(confirmed.requestId, "e-1"); + assert.equal(confirmed.effort, "high"); + var probe = await worker.probe("applyFlagSettings"); + assert.deepEqual(probe.event.args, { effortLevel: "high" }); + }); +}); + +test("an effort change the SDK refuses, or the console does not know, is reported as a failure with its requestId", async function () { + await withQuery(async function (worker) { + worker.send({ type: "set_effort", requestId: "e-2", effort: "max" }); + var refused = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-2"; }, "worker_error for e-2"); + assert.match(refused.error, /organization effort limit/); + + worker.send({ type: "set_effort", requestId: "e-3", effort: "minimal" }); + var unknown = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-3"; }, "worker_error for e-3"); + assert.match(unknown.error, /Unknown effort level/); + }); +}); + +test("an effort change with no running query is a failure, not silence", async function () { + var worker = await startWorker(); + try { + worker.send({ type: "set_effort", requestId: "e-4", effort: "low" }); + var failure = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-4"; }, "worker_error for e-4"); + assert.match(failure.error, /no running query/); + } finally { + await worker.stop(); + } +}); + +test("the new permission modes are passed to the running query as the SDK names them", async function () { + await withQuery(async function (worker) { + var modes = ["auto", "dontAsk"]; + for (var i = 0; i < modes.length; i++) { + worker.send({ type: "set_permission_mode", mode: modes[i] }); + var probe = await worker.probe("setPermissionMode"); + assert.equal(probe.event.args, modes[i]); + var confirmed = await worker.next(function (m) { return m.type === "permission_mode_changed"; }, "permission_mode_changed"); + assert.equal(confirmed.mode, modes[i]); + } + }); +}); diff --git a/test/fake-dom-prompt-cards.js b/test/fake-dom-prompt-cards.js index 2a7d9fdd..d6893614 100644 --- a/test/fake-dom-prompt-cards.js +++ b/test/fake-dom-prompt-cards.js @@ -101,7 +101,6 @@ function matches(el, sel) { }); } -/** What typing text into a field does: set its value, then fire "input". */ function typeInto(el, text) { el.value = text; (el._listeners.input || []).slice().forEach(function (fn) { fn({}); }); diff --git a/test/fixtures/fake-agent-sdk/register.mjs b/test/fixtures/fake-agent-sdk/register.mjs new file mode 100644 index 00000000..0b6fbcf8 --- /dev/null +++ b/test/fixtures/fake-agent-sdk/register.mjs @@ -0,0 +1,5 @@ +// Preloaded with --import: installs the resolve hook that swaps in the +// stand-in SDK for the process it is loaded into. +import { register } from "node:module"; + +register("./resolve-hook.mjs", import.meta.url); diff --git a/test/fixtures/fake-agent-sdk/resolve-hook.mjs b/test/fixtures/fake-agent-sdk/resolve-hook.mjs new file mode 100644 index 00000000..9050711a --- /dev/null +++ b/test/fixtures/fake-agent-sdk/resolve-hook.mjs @@ -0,0 +1,7 @@ +// Resolves the Claude Agent SDK's package name to the stand-in beside this file. +const FAKE = new URL("./sdk.mjs", import.meta.url).href; + +export async function resolve(specifier, context, nextResolve) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return { url: FAKE, shortCircuit: true }; + return nextResolve(specifier, context); +} diff --git a/test/fixtures/fake-agent-sdk/sdk.mjs b/test/fixtures/fake-agent-sdk/sdk.mjs new file mode 100644 index 00000000..3d4f8b3f --- /dev/null +++ b/test/fixtures/fake-agent-sdk/sdk.mjs @@ -0,0 +1,64 @@ +// A stand-in for @anthropic-ai/claude-agent-sdk, loaded by a real +// claude-worker.js process in test/claude-worker-process.test.js (see +// register.mjs). Its Query has the control surface the real 0.3.x Query has +// and none it lacks: applyFlagSettings and setPermissionMode, and no +// setEffort. Every control call is reported back as a fake_probe event on the +// query's stream, which the worker forwards to the test over its socket. +// +// A user message whose text is "ask" makes the query ask its canUseTool +// callback for permission, carrying the presentation hints an SDK ask can +// carry, and report the answer it got. + +export function query({ prompt, options }) { + const events = []; + let wake = null; + function emit(event) { + events.push(event); + if (wake) { + const w = wake; + wake = null; + w(); + } + } + + (async function readPrompt() { + for await (const message of prompt) { + const parts = message && message.message && message.message.content; + const text = Array.isArray(parts) && parts[0] ? parts[0].text : ""; + if (text !== "ask") continue; + const abort = new AbortController(); + const result = await options.canUseTool("Bash", { command: "make" }, { + toolUseID: "tu-fake", + decisionReason: "a rule asks", + signal: abort.signal, + suppressAlwaysAllowRule: true, + defaultToNo: true, + matchedAskRule: { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }, + }); + emit({ type: "fake_probe", call: "canUseTool", result }); + } + })(); + + emit({ type: "system", subtype: "init" }); + + return { + async applyFlagSettings(settings) { + emit({ type: "fake_probe", call: "applyFlagSettings", args: settings }); + if (settings.effortLevel === "max") throw new Error("organization effort limit"); + }, + async setPermissionMode(mode) { + emit({ type: "fake_probe", call: "setPermissionMode", args: mode }); + }, + async getContextUsage() { + return null; + }, + [Symbol.asyncIterator]() { + return { + async next() { + while (!events.length) await new Promise((resolve) => { wake = resolve; }); + return { value: events.shift(), done: false }; + }, + }; + }, + }; +} diff --git a/test/permission-hints.test.js b/test/permission-hints.test.js new file mode 100644 index 00000000..752e1d0f --- /dev/null +++ b/test/permission-hints.test.js @@ -0,0 +1,375 @@ +"use strict"; +// The Claude Agent SDK tells the host how an ask may be presented: a +// persistent "don't ask again" choice must not be offered (suppressAlwaysAllowRule), +// the ask must not open on an approve choice (defaultToNo), and a user-written +// ask rule forced it so no host-side auto-approval may answer it (matchedAskRule). +// These tests hold the console to that on every path that carries, shows or +// answers a tool-permission prompt, on the real server modules +// (prompt-harness-world.js) and the real client card code. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var world = require("./prompt-harness-world"); +var dom = require("./fake-dom-prompt-cards"); +var { promptsFor } = require("../lib/prompt-registry"); + +var RULE = { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }; +var TOOL_INPUT = { command: "make" }; + +var client = world.client; +var withServer = world.withServer; +var isSettled = world.isSettled; + +// An in-process canUseTool call, as the SDK makes it, carrying the hints. +function ask(server, hints, toolName, input) { + return server.ask(toolName || "Bash", input || TOOL_INPUT, hints); +} + +function isSettled(promise) { + var state = { settled: false }; + promise.then(function () { state.settled = true; }); + return state; +} + +test("the settle detector sees a rejecting call as settled, and a call still waiting as pending", async function () { + var rejecting = isSettled(Promise.reject(new Error("handler failed"))); + var fulfilling = isSettled(Promise.resolve("ok")); + var waiting = isSettled(new Promise(function () {})); + await world.flush(); + assert.equal(rejecting.settled, true, "a rejection ends the call"); + assert.equal(rejecting.rejected, true); + assert.equal(fulfilling.settled, true); + assert.equal(fulfilling.rejected, false); + assert.equal(waiting.settled, false); +}); + +// Every WS shape that carries an Allow-for-session answer: the card, the +// notification banner (the card's message plus the project it belongs to), and +// the older per-kind alias. +var WS_SHAPES = [ + { name: "card prompt_response", build: function (id) { return { type: "prompt_response", requestId: id, kind: "permission", decision: "allow_always" }; } }, + { name: "banner prompt_response", build: function (id) { return { type: "prompt_response", requestId: id, kind: "permission", decision: "allow_always", targetSlug: "harness" }; } }, + { name: "permission_response alias", build: function (id) { return { type: "permission_response", requestId: id, decision: "allow_always" }; } }, +]; + +test("a suppressed ask refuses allow_always on every answer path, stays pending and grants nothing", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var opened = ask(server, { suppressAlwaysAllowRule: true }); + var state = isSettled(opened.promise); + assert.equal(opened.opened.length, 1, "the ask opened one prompt"); + + for (var i = 0; i < WS_SHAPES.length; i++) { + c.inbox.length = 0; + server.handlers.handleSessionsMessage(c.ws, WS_SHAPES[i].build(opened.id)); + await world.flush(); + var where = WS_SHAPES[i].name; + assert.equal(state.settled, false, where + ": the call is still waiting"); + assert.deepEqual(server.heldPromptIds(), [opened.id], where + ": the prompt is still pending"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), [], where + ": nothing was granted for the session"); + var reoffer = c.inbox.filter(function (m) { return m.type === "prompt_pending" && m.requestId === opened.id; }); + assert.equal(reoffer.length, 1, where + ": the prompt is offered again to the responder"); + assert.equal(reoffer[0].suppressAlwaysAllowRule, true, where + ": and still carries the restriction"); + assert.equal(c.inbox.filter(function (m) { return m.type === "prompt_resolved" || m.type === "prompt_cancel"; }).length, 0, where + ": nothing was announced as settled"); + } + + assert.equal(await server.httpRespond(opened.id, "allow_always"), 400, "the push route offers allow and deny only"); + assert.deepEqual(server.heldPromptIds(), [opened.id]); + assert.equal(state.settled, false); + + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: opened.id, kind: "permission", decision: "allow" }); + var outcome = await opened.promise; + assert.equal(outcome.behavior, "allow", "Allow once still works"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), [], "and grants nothing"); + + var again = ask(server, { suppressAlwaysAllowRule: true }); + assert.equal(again.opened.length, 1, "the same call asks again: no standing approval was written"); + }); +}); + +test("an ask without hints keeps offering and honouring Allow for session (Claude without hints, Codex approvals)", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var plain = ask(server, {}); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: plain.id, kind: "permission", decision: "allow_always" }); + assert.equal((await plain.promise).behavior, "allow"); + assert.equal(Object.keys(server.session.allowedTools).length, 1, "the grant was written"); + var second = ask(server, {}); + assert.equal(second.opened.length, 0, "the granted call is approved without a prompt"); + assert.equal((await second.promise).behavior, "allow"); + + // Codex approvals arrive through the same callback with no hint fields. + server.session.vendor = "codex"; + server.session.allowedTools = Object.create(null); + var codexAsk = ask(server, {}, "Bash", { command: "make codex" }); + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, undefined, "no restriction is invented"); + assert.equal(pending[0].defaultToNo, undefined); + assert.equal(pending[0].matchedAskRule, undefined); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: codexAsk.id, kind: "permission", decision: "allow_always" }); + assert.equal((await codexAsk.promise).behavior, "allow", "Allow for session works for an ask with no hints"); + }); +}); + +test("the hints reach the prompt record, the recorded request, the connect snapshot and the notification", async function () { + await withServer(async function (server) { + var opened = ask(server, { suppressAlwaysAllowRule: true, defaultToNo: true, matchedAskRule: RULE }); + + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, true); + assert.equal(pending[0].defaultToNo, true); + assert.deepEqual(pending[0].matchedAskRule, RULE); + + var recorded = server.session.history.filter(function (m) { return m.type === "prompt_request" && m.requestId === opened.id; }); + assert.equal(recorded.length, 1); + assert.equal(recorded[0].suppressAlwaysAllowRule, true); + assert.equal(recorded[0].defaultToNo, true); + assert.deepEqual(recorded[0].matchedAskRule, RULE, "history paging replays the same restrictions"); + + // A client connecting now gets the snapshot (replayed request and the + // pending re-send), each carrying the hints. + var late = client(); + server.connect(late.ws); + var seen = late.inbox.filter(function (m) { return (m.type === "prompt_pending" || m.type === "prompt_request") && m.requestId === opened.id; }); + assert.ok(seen.some(function (m) { return m.type === "prompt_pending"; }), "the pending snapshot is sent"); + seen.forEach(function (m) { + assert.equal(m.suppressAlwaysAllowRule, true, m.type + " carries suppressAlwaysAllowRule"); + assert.equal(m.defaultToNo, true, m.type + " carries defaultToNo"); + assert.deepEqual(m.matchedAskRule, RULE, m.type + " carries matchedAskRule"); + }); + + var state = null; + server.notifications.sendConnectionState({}, function (ws, msg) { state = msg; }); + var notif = state.notifications.filter(function (n) { return n.type === "permission_request" && n.meta.requestId === opened.id; })[0]; + assert.ok(notif, "the banner notification exists"); + assert.equal(notif.meta.suppressAlwaysAllowRule, true, "the banner is told not to offer Allow for session"); + assert.equal(notif.meta.defaultToNo, true); + }); +}); + +test("matchedAskRule is recorded on the resolved event; a malformed one still counts as rule-forced", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var opened = ask(server, { matchedAskRule: RULE }); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: opened.id, kind: "permission", decision: "deny" }); + await opened.promise; + var resolved = server.session.history.filter(function (m) { return m.type === "prompt_resolved" && m.requestId === opened.id; }); + assert.equal(resolved.length, 1); + assert.equal(resolved[0].decision, "deny"); + assert.deepEqual(resolved[0].matchedAskRule, RULE); + + var long = "x".repeat(5000); + var hostile = ask(server, { matchedAskRule: { source: long, toolName: 42, ruleContent: { nested: true } }, suppressAlwaysAllowRule: "yes", defaultToNo: 1 }); + var held = promptsFor(server.sm).pendingMessages(server.session).filter(function (m) { return m.requestId === hostile.id; })[0]; + assert.equal(typeof held.matchedAskRule, "object", "an object that is not the expected shape is still a rule-forced ask"); + assert.ok(held.matchedAskRule.source.length <= 200, "text is bounded"); + assert.equal(typeof held.matchedAskRule.toolName, "string"); + assert.equal(held.suppressAlwaysAllowRule, undefined, "only a real boolean true restricts: a string is not one"); + assert.equal(held.defaultToNo, undefined); + }); +}); + +test("a rule-forced ask is never answered by a host-side auto-approval", async function () { + await withServer(async function (server) { + var prompts = promptsFor(server.sm); + + // Read-only whitelist. + var control = ask(server, {}, "Read", { file_path: "/srv/app/a.txt" }); + assert.equal(control.opened.length, 0); + assert.equal((await control.promise).behavior, "allow", "control: a read-only tool is approved without a prompt"); + var forcedRead = ask(server, { matchedAskRule: RULE }, "Read", { file_path: "/srv/app/a.txt" }); + assert.equal(forcedRead.opened.length, 1, "whitelist: a rule-forced Read asks the operator"); + prompts.respond(forcedRead.id, { decision: "deny" }); + await forcedRead.promise; + + // Full-auto mode. + server.sm.currentPermissionMode = "bypassPermissions"; + var controlBypass = ask(server, {}, "Write", { file_path: "/srv/app/b.txt" }); + assert.equal(controlBypass.opened.length, 0); + assert.equal((await controlBypass.promise).behavior, "allow", "control: full auto approves"); + var forcedBypass = ask(server, { matchedAskRule: RULE }, "Write", { file_path: "/srv/app/b.txt" }); + assert.equal(forcedBypass.opened.length, 1, "full auto: a rule-forced ask asks the operator"); + prompts.respond(forcedBypass.id, { decision: "deny" }); + await forcedBypass.promise; + server.sm.currentPermissionMode = "default"; + + // Autonomous loop execution. + server.session.loop = { active: true, role: "worker" }; + var controlLoop = ask(server, {}, "Edit", { file_path: "/srv/app/c.txt" }); + assert.equal(controlLoop.opened.length, 0); + assert.equal((await controlLoop.promise).behavior, "allow", "control: a loop approves"); + var forcedLoop = ask(server, { matchedAskRule: RULE }, "Edit", { file_path: "/srv/app/c.txt" }); + assert.equal(forcedLoop.opened.length, 1, "loop: a rule-forced ask asks the operator"); + prompts.respond(forcedLoop.id, { decision: "deny" }); + await forcedLoop.promise; + server.session.loop = null; + + // An earlier Allow for session. + prompts.grant(server.session, "Bash", TOOL_INPUT); + var controlGrant = ask(server, {}); + assert.equal(controlGrant.opened.length, 0); + assert.equal((await controlGrant.promise).behavior, "allow", "control: a granted call is approved"); + var forcedGrant = ask(server, { matchedAskRule: RULE }); + assert.equal(forcedGrant.opened.length, 1, "grant: a rule-forced ask asks the operator"); + var suppressedGrant = ask(server, { suppressAlwaysAllowRule: true }); + assert.equal(suppressedGrant.opened.length, 1, "grant: an ask the SDK marked as broader than a standing approval asks the operator"); + var declinedGrant = ask(server, { defaultToNo: true }); + assert.equal(declinedGrant.opened.length, 1, "grant: an ask that must open on No asks the operator"); + }); +}); + +test("a tool-permission ask relayed from a worker carries the hints through to the prompt", async function () { + await withServer(async function (server) { + var claudeAdapter = require("../lib/yoke/adapters/claude"); + var relay = require("../lib/yoke/adapters/permission-relay"); + var sent = []; + var worker = { sent: sent, handler: null, process: null, exitPromise: null, onMessage: function (fn) { worker.handler = fn; }, send: function (m) { sent.push(m); } }; + claudeAdapter._test_createWorkerQueryHandle(worker, function (toolName, input, opts) { + return server.bridge.handleCanUseTool(server.session, toolName, input, opts); + }, null, null); + + // What the worker process puts on the socket for an SDK ask carrying the hints. + var wire = JSON.parse(JSON.stringify(relay.toRequestMessage("w-1", "Bash", TOOL_INPUT, { + toolUseID: "tu-worker", decisionReason: "rule", suppressAlwaysAllowRule: true, defaultToNo: true, matchedAskRule: RULE, + }))); + worker.handler(wire); + await world.flush(); + + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, true); + assert.equal(pending[0].defaultToNo, true); + assert.deepEqual(pending[0].matchedAskRule, RULE); + + var c = client(); + server.connect(c.ws); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: pending[0].requestId, kind: "permission", decision: "allow_always" }); + await world.flush(); + assert.equal(sent.length, 0, "the worker was not answered: Allow for session was refused"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), []); + + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: pending[0].requestId, kind: "permission", decision: "allow" }); + await world.flush(); + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "permission_response"); + assert.equal(sent[0].requestId, "w-1"); + assert.equal(sent[0].result.behavior, "allow"); + }); +}); + +test("a request relayed from a worker with no hints is a plain ask", async function () { + var relay = require("../lib/yoke/adapters/permission-relay"); + var wire = JSON.parse(JSON.stringify(relay.toRequestMessage("w-2", "Bash", TOOL_INPUT, { toolUseID: "tu", decisionReason: "" }))); + var opts = relay.toCanUseToolOptions(wire, new AbortController().signal); + assert.equal(opts.suppressAlwaysAllowRule, undefined); + assert.equal(opts.defaultToNo, undefined); + assert.equal(opts.matchedAskRule, undefined); + assert.equal(opts.toolUseID, "tu"); +}); + +test("the hints a worker sends are taken as data: only a real true restricts, a rule is a bounded object", function () { + var relay = require("../lib/yoke/adapters/permission-relay"); + var opts = relay.toCanUseToolOptions({ toolUseId: "t", suppressAlwaysAllowRule: "true", defaultToNo: 1, matchedAskRule: "yes" }, new AbortController().signal); + assert.equal(opts.suppressAlwaysAllowRule, undefined); + assert.equal(opts.defaultToNo, undefined); + assert.deepEqual(opts.matchedAskRule, {}, "a rule that is present but not an object is still a rule-forced ask"); +}); + +test("a prompt_response of the extension kind never settles a browser-extension command", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var prompts = promptsFor(server.sm); + var command = prompts.open(null, "extension", { command: "tab_list", args: {} }, { timeoutMs: 60000 }); + var state = isSettled(command.answer); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: command.requestId, kind: "extension", result: { forged: true } }); + await world.flush(); + assert.equal(state.settled, false, "another client's prompt_response does not answer the extension's command"); + assert.equal(c.inbox.filter(function (m) { return m.type === "prompt_cancel" && m.reason === "stale"; }).length, 1, "and is answered as not found"); + assert.equal(prompts.respond(command.requestId, { result: { ok: true } }, { kinds: ["extension"] }).status, "resolved", "the extension path still answers it"); + assert.deepEqual(await command.answer, { ok: true }); + }); +}); + +// --- the client ------------------------------------------------------------ + +function request(id, extra) { + return Object.assign({ type: "prompt_request", requestId: id, kind: "permission", toolName: "Bash", toolInput: TOOL_INPUT, decisionReason: "" }, extra || {}); +} + +async function clientFor(t, layout) { + var env = await dom.setupToolsEnv(t); + global.localStorage.setItem("clagentic-wide-view", layout); + return env; +} + +function approveControls(card) { + return card.querySelectorAll("button").filter(function (b) { + return /permission-allow|perm-allow|perm-always/.test(b.className); + }); +} + +["bubble", "channel"].forEach(function (layout) { + test("the " + layout + " card omits Allow for session for a suppressed ask, in a live request and in a replayed snapshot", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("c1")); + assert.ok(dom.cardFor(env, "c1").querySelector(".permission-allow-session, .perm-always"), "control: an ordinary ask offers it"); + + env.tools.applyPromptMessage(request("c2", { suppressAlwaysAllowRule: true })); + assert.equal(dom.cardFor(env, "c2").querySelector(".permission-allow-session, .perm-always"), null, "live request"); + assert.ok(dom.cardFor(env, "c2").querySelector(".permission-allow, .perm-allow"), "Allow once stays"); + assert.ok(dom.cardFor(env, "c2").querySelector(".permission-deny, .perm-deny"), "Deny stays"); + + env.tools.applyPromptMessage(Object.assign(request("c3", { suppressAlwaysAllowRule: true }), { type: "prompt_pending" })); + assert.equal(dom.cardFor(env, "c3").querySelector(".permission-allow-session, .perm-always"), null, "pending snapshot"); + + // The older recorded shape. + env.tools.applyPromptMessage({ type: "permission_request", requestId: "c4", toolName: "Bash", toolInput: TOOL_INPUT, suppressAlwaysAllowRule: true }); + assert.equal(dom.cardFor(env, "c4").querySelector(".permission-allow-session, .perm-always"), null, "older permission_request shape"); + }); + + test("the " + layout + " card for a defaultToNo ask opens on No: Deny first, no approve choice marked or focused", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("d1", { defaultToNo: true })); + var card = dom.cardFor(env, "d1"); + var buttons = card.querySelectorAll(".permission-actions button"); + assert.match(buttons[0].className, /permission-deny|perm-deny/, "the decline option comes first"); + assert.equal(buttons[0].dataset.defaultChoice, "true", "and is the default choice"); + approveControls(card).forEach(function (b) { + assert.equal(b.dataset.defaultChoice, undefined, "no approve option is the default"); + }); + assert.equal(card.dataset.defaultToNo, "true"); + + env.tools.applyPromptMessage(request("d2")); + var plain = dom.cardFor(env, "d2"); + assert.equal(plain.dataset.defaultToNo, undefined, "an ordinary ask is drawn as before"); + assert.match(plain.querySelectorAll(".permission-actions button")[0].className, /permission-allow|perm-allow/); + }); + + test("the " + layout + " card shows the rule that forced the ask", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("m1", { matchedAskRule: RULE })); + var text = dom.cardFor(env, "m1").textContent; + assert.match(text, /make:\*/, "the operator can see which of their rules asked"); + }); +}); + +test("a banner offers Allow for session only for an ask that allows it, and Deny stays first for defaultToNo", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + dom.setupGlobals(); + var banner = await import(dom.moduleUrl("prompt-banner.js")); + var decisions = function (meta) { return banner.bannerActions("permission", meta).map(function (a) { return a.decision; }); }; + + assert.deepEqual(decisions({}), ["allow", "allow_always", "deny"], "control: an ordinary ask"); + assert.deepEqual(decisions({ suppressAlwaysAllowRule: true }), ["allow", "deny"]); + assert.deepEqual(decisions({ defaultToNo: true }), ["deny", "allow", "allow_always"], "No first when the ask must open on No"); + assert.deepEqual(decisions({ defaultToNo: true, suppressAlwaysAllowRule: true }), ["deny", "allow"]); + assert.deepEqual(banner.bannerActions("plan", { suppressAlwaysAllowRule: true }).map(function (a) { return a.decision; }), ["allow", "deny"], "plan banners are unchanged"); + assert.deepEqual(banner.bannerActions("nonsense", {}), [], "a kind with no entry offers nothing"); + assert.deepEqual(banner.bannerActions("constructor", {}), [], "an inherited name is no kind"); +}); diff --git a/test/permission-mode-picker.test.js b/test/permission-mode-picker.test.js new file mode 100644 index 00000000..0171cbca --- /dev/null +++ b/test/permission-mode-picker.test.js @@ -0,0 +1,103 @@ +"use strict"; +// The mode picker offers Don't ask and Auto only for a session whose vendor +// declares them (capabilities.permissionModes), and the server refuses the +// two for a session that does not: a Codex session must never be handed a +// Claude Code mode. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var path = require("path"); +var { pathToFileURL } = require("url"); +var world = require("./prompt-harness-world"); +var { claudeCapabilities } = require("../lib/yoke/adapters/claude-capabilities"); + +function modeOptionsModule() { + return import(pathToFileURL(path.join(__dirname, "..", "lib", "public", "modules", "mode-options.js")).href); +} + +function values(options) { return options.map(function (o) { return o.value; }); } + +test("the picker offers the three base modes always, and Don't ask and Auto only when the vendor declares them", async function () { + var { modeOptions } = await modeOptionsModule(); + var BASE = ["default", "plan", "acceptEdits"]; + + assert.deepEqual(values(modeOptions(undefined, false)), BASE, "no capabilities known"); + assert.deepEqual(values(modeOptions({}, false)), BASE, "a vendor that declares none (Codex)"); + assert.deepEqual(values(modeOptions({ rewind: false, permissionModes: "auto" }, false)), BASE, "a malformed declaration offers nothing extra"); + assert.deepEqual(values(modeOptions({ permissionModes: ["default", "auto"] }, false)), BASE.concat("auto"), "only what is declared"); + assert.deepEqual(values(modeOptions(claudeCapabilities(), false)), BASE.concat(["dontAsk", "auto"])); + assert.deepEqual(values(modeOptions(claudeCapabilities(), true)), BASE.concat(["dontAsk", "auto", "bypassPermissions"]), "Full auto stays last, when the daemon allows it"); + assert.deepEqual(values(modeOptions({}, true)), BASE.concat("bypassPermissions")); + + var labels = {}; + modeOptions(claudeCapabilities(), true).forEach(function (o) { labels[o.value] = o.label; }); + assert.equal(labels.dontAsk, "Don’t ask"); + assert.equal(labels.auto, "Auto"); + assert.equal(labels.bypassPermissions, "Full auto"); +}); + +test("a Claude session declares every mode the picker can offer", function () { + var caps = claudeCapabilities(); + assert.deepEqual(caps.permissionModes, ["default", "plan", "acceptEdits", "dontAsk", "auto"]); + caps.permissionModes.push("mutated"); + assert.equal(claudeCapabilities().permissionModes.length, 5, "each call gets its own list"); +}); + +var client = world.client; +var withServer = world.withServer; + +test("set_permission_mode: auto and dontAsk reach a session whose vendor declares them, and only that session", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setPermissionMode = function (mode) { applied.push(mode); return Promise.resolve(); }; + + // Capabilities are recorded per vendor; Claude's are known, Codex's are not. + server.sm.capabilitiesByVendor = { claude: claudeCapabilities() }; + + ["auto", "dontAsk"].forEach(function (mode) { + server.session.vendor = "claude"; + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, ["auto", "dontAsk"]); + assert.equal(server.session.permissionMode, "dontAsk"); + + applied.length = 0; + c.inbox.length = 0; + server.session.vendor = "codex"; + server.session.permissionMode = "default"; + ["auto", "dontAsk"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, [], "a Codex session is never handed a Claude Code mode"); + assert.equal(server.session.permissionMode, "default"); + assert.equal(c.inbox.filter(function (m) { return m.type === "error"; }).length, 2, "and the requester is told"); + var states = c.inbox.filter(function (m) { return m.type === "config_state"; }); + assert.equal(states[states.length - 1].mode, "default", "its picker is put back"); + + // The modes every vendor has are not gated. + ["plan", "acceptEdits", "default"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, ["plan", "acceptEdits", "default"]); + }); +}); + +test("set_permission_mode: with no capabilities known, auto and dontAsk are refused rather than guessed", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setPermissionMode = function (mode) { applied.push(mode); return Promise.resolve(); }; + server.sm.capabilitiesByVendor = null; + ["auto", "dontAsk"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, []); + }); +}); diff --git a/test/prompt-harness-world.js b/test/prompt-harness-world.js index d03660d2..091947f5 100644 --- a/test/prompt-harness-world.js +++ b/test/prompt-harness-world.js @@ -75,7 +75,6 @@ function makeQueryHandle() { }; } -/** requestIds of every prompt the session currently holds. */ function heldPromptIds(session) { var ids = []; PROMPT_STORES.forEach(function (store) { @@ -126,9 +125,13 @@ async function createServer(opts) { }); var handles = []; + // The options each query was created with, for tests that check what the + // bridge asks the adapter to start (permission mode, effort). + var queryOptions = []; var adapter = { vendor: "claude", - createQuery: function () { + createQuery: function (queryOpts) { + queryOptions.push(queryOpts); var h = makeQueryHandle(); handles.push(h); return Promise.resolve(h); @@ -185,6 +188,7 @@ async function createServer(opts) { sm.saveSessionFile(session); } + var askSeq = 0; var server = { home: home, sm: sm, @@ -209,6 +213,23 @@ async function createServer(opts) { return session.queryInstance.issueSignal(); }, + queryOptions: queryOptions, + + /** + * A canUseTool call as the SDK makes it, in process. hints carries the + * SDK's presentation hints (suppressAlwaysAllowRule, defaultToNo, + * matchedAskRule); the vendor's own options (toolUseID, signal) are the + * harness's. Returns the call's promise, the id of the prompt it opened + * (null when it was answered without one) and every id it opened. + */ + ask: function (toolName, input, hints) { + var before = heldPromptIds(session); + var opts = Object.assign({ toolUseID: "tu-ask-" + (++askSeq), signal: server.issueSignal().signal }, hints || {}); + var promise = bridge.handleCanUseTool(session, toolName, input, opts); + var opened = heldPromptIds(session).filter(function (id) { return before.indexOf(id) === -1; }); + return { promise: promise, id: opened[0] || null, opened: opened }; + }, + // Closes the live query; its processQueryStream finally runs. A stream // that rejects fails the step that ended it. endQuery: async function () { @@ -285,7 +306,38 @@ function removeHome(home) { fs.rmSync(home, { recursive: true, force: true }); } +/** An in-memory WS client: the socket to connect and the messages it received. */ +function client() { + var inbox = []; + return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; +} + +/** Runs fn against a server with a live query, then tears it down. */ +async function withServer(fn) { + var server = await createServer(); + try { + await server.startQuery(); + await fn(server); + } finally { + server.shutdown(); + removeHome(server.home); + } +} + +/** + * Tracks whether a promise has ended. A rejection ends it too: a detector that + * only watched fulfilment would read a failed call as one still waiting. + */ +function isSettled(promise) { + var state = { settled: false, rejected: false }; + promise.then(function () { state.settled = true; }, function () { state.settled = true; state.rejected = true; }); + return state; +} + module.exports = { + client: client, + withServer: withServer, + isSettled: isSettled, LIB_ROOT: LIB_ROOT, overridesLib: !!process.env.CLAGENTIC_CONSOLE_PROMPT_HARNESS_LIB, createServer: createServer, diff --git a/test/prompt-hostile-request-id.test.js b/test/prompt-hostile-request-id.test.js index 448afc85..9f71ba19 100644 --- a/test/prompt-hostile-request-id.test.js +++ b/test/prompt-hostile-request-id.test.js @@ -36,10 +36,7 @@ var WS_ANSWERS = [ { path: "elicitation_response", stale: "prompt_cancel", build: function (id) { return { type: "elicitation_response", requestId: id, action: "accept", content: { token: "t" } }; } }, ]; -function client() { - var inbox = []; - return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; -} +var client = world.client; // One prompt of every kind, pending, so every store and the index hold a // real entry while the hostile answers arrive. @@ -56,7 +53,9 @@ function openOneOfEach(server) { var settled = {}; Object.keys(opened).forEach(function (kind) { var answer = kind === "extension" ? opened[kind].answer : opened[kind]; - answer.then(function () { settled[kind] = true; }); + // A callback that fails is settled too, and must not read as pending. + var mark = function () { settled[kind] = true; }; + answer.then(mark, mark); }); var ids = { extension: opened.extension.requestId }; prompts.pendingMessages(session).forEach(function (m) { ids[m.kind] = m.requestId; }); @@ -69,16 +68,7 @@ function assertPrototypesClean() { assert.equal(typeof Object.prototype.hasOwnProperty, "function"); } -async function withServer(fn) { - var server = await world.createServer(); - try { - await server.startQuery(); - await fn(server); - } finally { - server.shutdown(); - world.removeHome(server.home); - } -} +var withServer = world.withServer; test("an inherited name is no prompt on any WS answer path: a stale reply, no throw, nothing settled", async function () { await withServer(async function (server) { diff --git a/test/prompt-invariants.test.js b/test/prompt-invariants.test.js index f3ad19f0..8a39c688 100644 --- a/test/prompt-invariants.test.js +++ b/test/prompt-invariants.test.js @@ -27,6 +27,11 @@ // I7 Allow for Session auto-approves the same grant key afterwards, across // turns and restart, and nothing else // I8 every prompt that ends records exactly one terminal event +// I9 an ask carrying the SDK's hints is held to them: Allow for session on +// a suppressAlwaysAllowRule ask is refused on every path (the card and +// its aliases, the banner) and writes no grant, and no standing approval +// answers an ask marked suppressAlwaysAllowRule, defaultToNo or +// matchedAskRule // // Set CLAGENTIC_CONSOLE_PROMPT_HARNESS_LIB to another checkout's lib/ to run the // wire-level checks against it; the client render layer always exercises @@ -55,7 +60,25 @@ var PROMPTS = [ key: "Elicit", kind: "elicitation", request: { serverName: "deploy", message: "Token?", mode: "form", requestedSchema: { type: "object", properties: { token: { type: "string" } } } }, }, + // Asks carrying the SDK's presentation hints. Each uses a tool of its own, + // so no Allow for session on another entry can stand in for them. + { key: "Edit!", kind: "permission", toolName: "Edit", input: { file_path: "/srv/app/main.js" }, hints: { suppressAlwaysAllowRule: true } }, + { + key: "Drop!", kind: "permission", toolName: "mcp__db__drop", input: { table: "users" }, + hints: { defaultToNo: true, matchedAskRule: { source: "projectSettings", toolName: "mcp__db__drop", ruleContent: "*" } }, + }, ]; + +// An ask the SDK marked as not offering a persistent choice. +function suppressesAlways(prompt) { + return !!(prompt.hints && prompt.hints.suppressAlwaysAllowRule); +} + +// An ask a standing approval may not answer: a grant for its tool is broader +// than what the SDK says the ask itself allows. +function needsOwnAnswer(prompt) { + return !!(prompt.hints && (prompt.hints.suppressAlwaysAllowRule || prompt.hints.defaultToNo || prompt.hints.matchedAskRule)); +} var BY_KIND = {}; PROMPTS.forEach(function (p) { if (!BY_KIND[p.kind]) BY_KIND[p.kind] = p; }); @@ -168,6 +191,10 @@ function Harness(label) { this.queryGen = 0; this.clients = []; this.terminalEvents = {}; + this.unexpressible = 0; + // Whether the lib under test lacks prompt_response. Set only by a foreign + // lib, except in the test that proves a lost step fails a run. + this.predatesPromptResponse = world.overridesLib; } Harness.prototype.fail = function (msg) { @@ -309,10 +336,11 @@ Harness.prototype.request = async function (prompt, ownerTask) { var abort = this.server.issueSignal(); var promise = prompt.kind === "elicitation" ? this.server.bridge.handleElicitation(session, prompt.request, { signal: abort.signal }) - : this.server.bridge.handleCanUseTool(session, prompt.toolName, prompt.input, { toolUseID: toolUseId, signal: abort.signal }); + : this.server.bridge.handleCanUseTool(session, prompt.toolName, prompt.input, Object.assign({ toolUseID: toolUseId, signal: abort.signal }, prompt.hints || {})); var opened = this.server.heldPromptIds().filter(function (id) { return before.indexOf(id) === -1; }); - var grantable = prompt.kind === "permission"; + // A standing approval never answers an ask that needs its own answer (I9). + var grantable = prompt.kind === "permission" && !needsOwnAnswer(prompt); if (grantable && this.granted[prompt.key] && opened.length === 0) { var auto = await promise; if (!allows(auto)) this.fail(prompt.key + " was granted for the session but was not auto-approved (I7)"); @@ -403,14 +431,30 @@ Harness.prototype.respond = async function (client, r, answer) { var clicked = false; if (client.view && client.connected) clicked = this.clickAnswer(client, r, answer); if (!clicked) { - var legacy = world.overridesLib || this.toolSeq % 2 === 0; + var legacy = this.predatesPromptResponse || this.toolSeq % 2 === 0; var msg = responseMessage(r.kind, r.id, answer, legacy); - if (msg.type !== "prompt_response" || !world.overridesLib) { - this.server.handlers.handleSessionsMessage(client.ws, msg); + // A lib that predates prompt_response has no message that can carry an + // answer only prompt_response expresses (an AskUserQuestion skip), so the + // step cannot be made there. It is counted, and a run against this + // checkout fails if any step was lost this way, instead of judging a + // message that was never sent. + if (msg.type === "prompt_response" && this.predatesPromptResponse) { + this.unexpressible++; + return; } + this.server.handlers.handleSessionsMessage(client.ws, msg); } var replies = client.inbox.filter(function (m) { return isEnding(m) && promptIdOf(m) === r.id; }); + var reoffered = client.inbox.some(function (m) { return PENDING[m.type] && promptIdOf(m) === r.id; }); await this.settle(); + if (wasPending && answer === "allow_always" && suppressesAlways(r.prompt)) { + // I9: an ask that may not offer Allow for session is refused it whatever + // the client sent: nothing settles, nothing is granted, and the responder + // is shown the prompt again. + if (r.settled) this.fail(r.id + " was settled by an Allow for session its ask may not offer (I9)"); + else if (client.connected && !reoffered) this.fail(client.name + "'s refused Allow for session on " + r.id + " was not offered again (I9)"); + return; + } if (client.connected) { var stale = replies.some(function (m) { return m.reason === "stale"; }); var resolved = replies.some(function (m) { return m.type !== "prompt_cancel" && m.type !== "permission_cancel"; }); @@ -599,6 +643,7 @@ Harness.prototype.close = function () { Harness.prototype.assertClean = function () { assert.deepEqual(this.violations, [], "ops: " + this.log.join(" | ")); + if (!world.overridesLib) assert.equal(this.unexpressible, 0, "every answer the run chose could be sent to this checkout"); }; // --- random interleavings ------------------------------------------------- @@ -720,6 +765,29 @@ async function scenario(t, label, clientCount, body) { } } +// The guard that keeps a run honest: a step the lib cannot be sent is counted, +// and a run against this checkout fails on it instead of passing on a message +// that was never delivered. +test("a run against this checkout fails when a step it chose could not be sent", async function (t) { + if (world.overridesLib) { t.skip("only meaningful against this checkout"); return; } + if (RENDER) t.mock.timers.enable({ apis: ["setTimeout"] }); + var h = new Harness("unexpressible-guard"); + await h.init(1); + try { + var r = null; + // A skip is expressible only as prompt_response, whatever the parity. + await h.step(async function () { r = await h.request(BY_KIND.ask_user, null); }); + h.predatesPromptResponse = true; + h.clients[0].view = null; + await h.step(function () { return h.respond(h.clients[0], r, "skip"); }); + assert.equal(h.unexpressible, 1, "the lost step was counted"); + assert.deepEqual(h.violations, [], "and is no invariant violation by itself"); + assert.throws(function () { h.assertClean(); }, /every answer the run chose could be sent/); + } finally { + h.close(); + } +}); + test("a sub-agent's request survives its parent's turn result", async function (t) { await scenario(t, "subagent-vs-parent-result", 1, async function (h) { var task = null; diff --git a/test/prompt-kind-roundtrip.test.js b/test/prompt-kind-roundtrip.test.js index f65ff2b6..4180ab40 100644 --- a/test/prompt-kind-roundtrip.test.js +++ b/test/prompt-kind-roundtrip.test.js @@ -55,6 +55,41 @@ function click(card, selector) { el.click(); } +// --- the SDK's presentation hints ------------------------------------------- + +var RULES = [ + { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }, + { source: "userSettings", toolName: "Write" }, +]; + +// Hints on a request, generated for every kind: a kind that does not use them +// must be unmoved by them, and the permission kind must hold to them. +function hintsFor(p) { + var hints = {}; + if (p.chance(0.4)) hints.suppressAlwaysAllowRule = true; + if (p.chance(0.4)) hints.defaultToNo = true; + if (p.chance(0.4)) hints.matchedAskRule = p.one(RULES); + return hints; +} + +var HINT_KEYS = ["suppressAlwaysAllowRule", "defaultToNo", "matchedAskRule"]; + +// What the permission card shows for an ask with these hints. +function assertHintsShown(card, req) { + var session = card.querySelector(".permission-allow-session, .perm-always"); + assert.equal(!!session, !req.suppressAlwaysAllowRule, "Allow for session is offered exactly when the ask may offer it"); + var buttons = card.querySelectorAll(".permission-actions button"); + var approvals = buttons.filter(function (b) { return /permission-allow|perm-allow|perm-always/.test(b.className); }); + approvals.forEach(function (b) { assert.equal(b.dataset.defaultChoice, undefined, "no approve choice is the default"); }); + if (req.defaultToNo) { + assert.match(buttons[0].className, /permission-deny|perm-deny/, "the ask opens on No"); + assert.equal(buttons[0].dataset.defaultChoice, "true"); + } else { + assert.match(buttons[buttons.length - 1].className, /permission-deny|perm-deny/, "an ordinary ask keeps its order"); + } + assert.equal(/Asked because of your rule/.test(card.textContent), !!req.matchedAskRule, "the rule that forced the ask is shown"); +} + // --- per-kind generators and readers -------------------------------------- var LABEL_POOL = ["staging", "prod", "staging, eu", "qa", "eu", "a, b", "b", "Other", "x"]; @@ -69,14 +104,19 @@ var FIELD_SHAPES = [ { prop: { type: "string" }, defaults: ["preset", 5] }, { prop: { type: "string", maxLength: 8 }, defaults: ["short", "far too long a default"] }, { prop: { type: "string", minLength: 2, title: "Code", description: "two or more" }, defaults: ["ok", "x"] }, + { prop: { type: "string", minLength: 2, maxLength: 5 }, defaults: ["abc", "abcdef"] }, { prop: { type: "string", format: "email" }, defaults: ["ops@example.test", "not an address"] }, { prop: { type: "string", format: "uri" }, defaults: ["https://example.test/x", "relative/x"] }, { prop: { type: "string", format: "date" }, defaults: ["2026-02-28", "2026-02-30"] }, { prop: { type: "string", format: "date-time" }, defaults: ["2026-10-06T12:30:00Z", "2026-10-06 12:30"] }, { prop: { type: "integer" }, defaults: [7, 1.5, "7"] }, { prop: { type: "integer", minimum: 0, maximum: 10 }, defaults: [3, 11] }, + { prop: { type: "integer", minimum: 1 }, defaults: [5, 0] }, + { prop: { type: "integer", maximum: 9 }, defaults: [3, 10] }, { prop: { type: "number" }, defaults: [2.25, "2.25"] }, { prop: { type: "number", minimum: -2.5, maximum: 2.5 }, defaults: [0.5, 3] }, + { prop: { type: "number", minimum: 0.5 }, defaults: [1.5, 0] }, + { prop: { type: "number", maximum: 0.5 }, defaults: [0, 1] }, { prop: { type: "boolean" }, defaults: [true, false, "yes"] }, { prop: { type: "string", enum: ["eu", "us", "apac"] }, defaults: ["us", "mars"] }, { prop: { type: "string", enum: ["s", "m", "l"], enumNames: ["Small", "Medium", "Large"] }, defaults: ["m", "xl"] }, @@ -184,12 +224,18 @@ var SPECS = { req: { toolName: p.one(["Bash", "Write"]), toolInput: { command: "make " + p.int(0, 9) }, decisionReason: "", vendor: "claude" }, }; }, - answer: function (card, p) { - var choice = p.one([ + // The hints (generated for every kind, see hintsFor) decide what the card + // offers: no Allow for session when suppressed, Deny first when the ask + // must open on No. + answer: function (card, p, req) { + var choices = [ { selector: ".permission-allow, .perm-allow", label: "Allowed", allows: true }, { selector: ".permission-allow-session, .perm-always", label: "Allowed for session", allows: true }, { selector: ".permission-deny, .perm-deny", label: "Denied", allows: false }, - ]); + ]; + assertHintsShown(card, req); + if (req.suppressAlwaysAllowRule) choices.splice(1, 1); + var choice = p.one(choices); click(card, choice.selector); return choice; }, @@ -474,13 +520,27 @@ async function roundTrip(kind, seed) { var where = kind + " seed " + seed; var server = makeServer(); var opened = spec.request(p); + Object.assign(opened.req, hintsFor(p)); var prompt = server.registry.open(server.session, kind, opened.req, { toolUseId: "tu-" + seed }); var live = await dom.createClient("roundtrip-live"); var requestEvent = JSON.parse(JSON.stringify(server.history[0])); + HINT_KEYS.forEach(function (key) { + if (kind === "permission") assert.deepEqual(requestEvent[key], opened.req[key], where + ": the request carries " + key); + else assert.equal(requestEvent[key], undefined, where + ": " + kind + " does not carry " + key); + }); + var pendingMessage = JSON.parse(JSON.stringify(server.registry.pendingMessages(server.session)[0])); live.tools.applyPromptMessage(requestEvent); var card = dom.cardFor(live, prompt.requestId); assert.ok(card, where + ": the request draws a card"); + + // The connect snapshot of a prompt that is still pending draws the same + // card, with the same restrictions. + if (kind === "permission") { + var snapshot = await dom.createClient("roundtrip-snapshot"); + snapshot.tools.applyPromptMessage(pendingMessage); + assertHintsShown(dom.cardFor(snapshot, prompt.requestId), opened.req); + } var choice = spec.answer(card, p, opened.req, live.sent); assert.equal(live.sent.length, 1, where + ": one answer is sent"); var sent = JSON.parse(JSON.stringify(live.sent[0])); @@ -493,6 +553,8 @@ async function roundTrip(kind, seed) { var stored = JSON.parse(JSON.stringify(server.history)); var resolution = stored.filter(function (m) { return m.type === "prompt_resolved"; }); assert.equal(resolution.length, 1, where + ": one outcome is recorded"); + assert.deepEqual(resolution[0].matchedAskRule, kind === "permission" ? opened.req.matchedAskRule : undefined, + where + ": the rule that forced an ask is recorded with its outcome"); live.tools.applyPromptMessage(resolution[0]); var want = spec.expectedRead(choice, opened.req); @@ -567,6 +629,39 @@ test("elicitation answers that bypass the card are held to the requested schema" } }); +// An answer that did not come from the card is held to the ask's hints too: +// Allow for session to an ask that may not offer it settles nothing and +// grants nothing, and any other answer behaves as it always did. +test("permission answers that bypass the card are held to the ask's hints", async function () { + for (var seed = 1; seed <= RUNS_PER_KIND * 2; seed++) { + var p = picker(mulberry32(2000 + seed)); + var where = "permission seed " + seed; + var server = makeServer(); + var req = SPECS.permission.request(p).req; + Object.assign(req, hintsFor(p)); + var prompt = server.registry.open(server.session, "permission", req, { toolUseId: "tu-" + seed }); + var decision = p.one(["allow", "allow_always", "deny", "bogus", undefined]); + + var outcome = server.registry.respond(prompt.requestId, { decision: decision }); + var grants = Object.keys(server.session.allowedTools || {}).length; + if (decision === "allow_always" && req.suppressAlwaysAllowRule) { + assert.equal(outcome.status, "invalid", where + ": refused"); + assert.equal(outcome.pending.type, "prompt_pending", where + ": and offered again"); + assert.equal(outcome.pending.suppressAlwaysAllowRule, true, where + ": with the restriction"); + assert.equal(grants, 0, where + ": nothing was granted"); + assert.equal(server.history.filter(function (m) { return m.type === "prompt_resolved"; }).length, 0, where + ": nothing settled"); + assert.equal(server.registry.respond(prompt.requestId, { decision: "allow" }).status, "resolved", where + ": still answerable"); + assert.equal((await prompt.answer).behavior, "allow", where); + assert.equal(Object.keys(server.session.allowedTools || {}).length, 0, where + ": Allow once grants nothing"); + } else { + assert.equal(outcome.status, "resolved", where); + assert.equal(grants, decision === "allow_always" ? 1 : 0, where + ": a grant is written exactly for an accepted Allow for session"); + var expected = decision === "allow" || decision === "allow_always" ? "allow" : "deny"; + assert.equal((await prompt.answer).behavior, expected, where + ": an unknown answer fails closed"); + } + } +}); + Object.keys(SPECS).forEach(function (kind) { test("round trip, " + kind + ": the operator's answer survives answer, settle, serialize, replay and render", async function (t) { await dom.setupToolsEnv(t); diff --git a/test/prompt-path-followups.test.js b/test/prompt-path-followups.test.js new file mode 100644 index 00000000..56698949 --- /dev/null +++ b/test/prompt-path-followups.test.js @@ -0,0 +1,109 @@ +"use strict"; +// Three corners of the prompt path found in review of the prompt registry: +// a tool-use id that two sessions share must not take over the other +// session's request index entry; a recorded AskUserQuestion skip is an answer, +// not an ending nobody chose; and a send that throws is not "Not connected". + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var dom = require("./fake-dom-prompt-cards"); +var { createPromptRegistry } = require("../lib/prompt-registry"); + +var QUESTIONS = { questions: [{ question: "Which?", options: [{ label: "a" }, { label: "b" }] }] }; + +function twoSessionRegistry() { + var sessions = { 1: { localId: 1, queryInstance: null }, 2: { localId: 2, queryInstance: null } }; + var index = Object.create(null); + var registry = createPromptRegistry({ + index: index, + getSession: function (localId) { return sessions[localId] || null; }, + sendAndRecord: function () {}, + saveSessionFile: function () {}, + }); + return { sessions: sessions, index: index, registry: registry }; +} + +test("a tool-use id two sessions share is the request id of only the first; the other session's prompt gets its own", async function () { + var w = twoSessionRegistry(); + var a = w.registry.open(w.sessions[1], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-same" }); + var b = w.registry.open(w.sessions[2], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-same" }); + + assert.equal(a.requestId, "tu-same", "the first session's AskUserQuestion card and prompt are one request"); + assert.notEqual(b.requestId, "tu-same", "the second session's does not take that id over"); + assert.equal(w.index["tu-same"], 1, "the index still routes the id to the session that holds it"); + + var wrong = w.registry.respond("tu-same", { decision: "skip" }); + assert.equal(wrong.status, "resolved"); + assert.equal(wrong.session.localId, 1, "an answer for the shared id reaches the first session's prompt"); + assert.equal(w.registry.lookup(b.requestId).session.localId, 2, "the second session's prompt is still pending, under its own id"); + + var other = w.registry.respond(b.requestId, { decision: "skip" }); + assert.equal(other.status, "resolved"); + assert.equal(other.session.localId, 2); + assert.equal((await a.answer).behavior, "deny"); + assert.equal((await b.answer).behavior, "deny"); +}); + +test("once the first session's prompt has settled, its tool-use id is free to name another session's", async function () { + var w = twoSessionRegistry(); + var a = w.registry.open(w.sessions[1], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-reuse" }); + w.registry.respond(a.requestId, { decision: "skip" }); + var b = w.registry.open(w.sessions[2], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-reuse" }); + assert.equal(b.requestId, "tu-reuse"); +}); + +function askHistory(ended) { + return [{ type: "tool_executing", name: "AskUserQuestion", id: "q1", input: QUESTIONS }, ended]; +} + +test("a recorded AskUserQuestion skip replays as skipped, an ending with no answer as cancelled, on the server", function () { + var w = twoSessionRegistry(); + var session = w.sessions[1]; + var skipped = askHistory({ type: "ask_user_answered", toolId: "q1", answers: {}, skipped: true }); + assert.deepEqual(w.registry.replayAnnotator(session, skipped, 0)(skipped[0]).promptState, { state: "resolved", skipped: true }); + + var answered = askHistory({ type: "ask_user_answered", toolId: "q1", answers: { 0: "a" } }); + assert.deepEqual(w.registry.replayAnnotator(session, answered, 0)(answered[0]).promptState, { state: "resolved", answers: { 0: "a" } }); + + var ended = askHistory({ type: "ask_user_answered", toolId: "q1" }); + assert.deepEqual(w.registry.replayAnnotator(session, ended, 0)(ended[0]).promptState, { state: "cancelled", reason: null }); +}); + +test("the client reads the same three recorded endings the same way", async function () { + dom.setupGlobals(); + var { normalizePromptMessage } = await import(dom.moduleUrl("prompt-state.js")); + var skipped = normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", answers: {}, skipped: true }); + assert.equal(skipped.phase, "resolved"); + assert.deepEqual(skipped.fields, { skipped: true }); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", answers: { 0: "a" } }).phase, "resolved"); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1" }).phase, "cancel"); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", skipped: "yes" }).phase, "cancel", "only a real true is a skip"); +}); + +test("a send that throws is reported as what it is, not as a dropped connection, and the card stays answerable", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + dom.setupGlobals(); + var card = await import(dom.moduleUrl("prompt-card.js")); + var logged = []; + var original = console.error; + console.error = function () { logged.push(Array.prototype.join.call(arguments, " ")); }; + try { + var container = new dom.FakeElement("div"); + var actions = container.appendChild(new dom.FakeElement("div")); + actions.className = "permission-actions"; + var button = actions.appendChild(new dom.FakeElement("button")); + + var sent = card.submitPromptResponse(container, function () { throw new Error("socket write failed"); }); + assert.equal(sent, false); + assert.match(container.textContent, /Could not send the answer \(socket write failed\)/); + assert.doesNotMatch(container.textContent, /Not connected/); + assert.equal(container.classList.contains("sending"), false); + assert.equal(button.disabled, false, "the operator can choose again"); + assert.equal(logged.length, 1, "and the failure is logged, not swallowed"); + + assert.equal(card.submitPromptResponse(container, function () { return false; }), false); + assert.match(container.textContent, /Not connected/, "a socket that is not usable still says so"); + } finally { + console.error = original; + } +}); diff --git a/test/shared-codecs.test.js b/test/shared-codecs.test.js index 39fa1935..d3e9818a 100644 --- a/test/shared-codecs.test.js +++ b/test/shared-codecs.test.js @@ -17,7 +17,6 @@ var PUBLIC_DIR = path.join(LIB, "public"); var MODULES_DIR = path.join(PUBLIC_DIR, "modules"); // accessor on lib/prompt-kinds/codecs.js -> the file the browser imports var SHARED = { ownKey: "own-key.js", askUser: "ask-user-codec.js", elicitation: "elicitation-codec.js" }; -// The codecs that bound typed text. var ANSWER_CODECS = ["askUser", "elicitation"]; function browserPath(file) { return path.join(MODULES_DIR, "prompt-kinds", file); }