clay-server 4.2.0-beta.1 → 4.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/project-connection.js +5 -2
- package/lib/project-full-access.js +69 -56
- package/lib/project-sessions.js +126 -26
- package/lib/project-user-message.js +5 -2
- package/lib/project.js +1 -0
- package/lib/public/app.js +6 -0
- package/lib/public/css/filebrowser.css +20 -0
- package/lib/public/css/messages.css +27 -0
- package/lib/public/css/overlays.css +60 -0
- package/lib/public/css/pane.css +21 -4
- package/lib/public/css/pending-message-queue.css +5 -0
- package/lib/public/index.html +8 -3
- package/lib/public/modules/app-connection.js +4 -0
- package/lib/public/modules/app-header.js +18 -25
- package/lib/public/modules/app-messages.js +17 -7
- package/lib/public/modules/app-panels.js +22 -11
- package/lib/public/modules/clay-file-links.js +7 -0
- package/lib/public/modules/clay-log-links.js +4 -0
- package/lib/public/modules/github-links.js +115 -0
- package/lib/public/modules/markdown.js +9 -2
- package/lib/public/modules/mcp-ui.js +154 -17
- package/lib/public/modules/pane-bridge.js +2 -0
- package/lib/public/modules/pane-file-bridge.js +69 -0
- package/lib/public/modules/pending-message-queue-model.js +6 -0
- package/lib/public/modules/pending-message-queue-recovery.js +73 -0
- package/lib/public/modules/pending-message-queue.js +67 -64
- package/lib/public/modules/permission-control.js +187 -0
- package/lib/public/modules/split-view.js +29 -29
- package/lib/sdk-bridge.js +95 -40
- package/lib/session-permission-mode.js +42 -0
- package/lib/sessions.js +14 -1
- package/lib/ws-schema.js +2 -0
- package/lib/yoke/adapters/claude-worker-policy.js +67 -0
- package/lib/yoke/adapters/claude-worker.js +47 -7
- package/lib/yoke/adapters/claude.js +114 -6
- package/package.json +1 -1
|
@@ -8,13 +8,15 @@ import { detachTuiView } from './session-tui-view.js';
|
|
|
8
8
|
import { formatTokens } from './app-panels.js';
|
|
9
9
|
import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
|
|
10
10
|
import { groupedSessionIds, findSplitGroup, isConfiguredWorker } from './split-group-helpers.js';
|
|
11
|
-
import { showConfirm } from './app-misc.js';
|
|
12
11
|
import { syncPairChrome } from './split-pair-ui.js';
|
|
13
12
|
import { presentMarkdownEdit } from './filebrowser.js';
|
|
14
13
|
import { autoStartLoginIfNeeded } from './vendor-login.js';
|
|
15
14
|
import { isCurrentProjectSessionReady } from './split-session-boundary.js';
|
|
16
15
|
import { splitPanesMatchProject, hasCurrentSplitGroup, reconcileRestoredSplit } from './project-activation.js';
|
|
17
16
|
import { handlePaneIssueMessage } from './pane-issue-bridge.js';
|
|
17
|
+
import { handlePaneFileMessage, revealFilesPanel } from './pane-file-bridge.js';
|
|
18
|
+
import { openFile } from './filebrowser.js';
|
|
19
|
+
import { bindPermissionControl, createPermissionControl, renderPermissionControl } from './permission-control.js';
|
|
18
20
|
|
|
19
21
|
var host = null;
|
|
20
22
|
var nativeApp = null;
|
|
@@ -161,25 +163,28 @@ export function syncPaneTitles() {
|
|
|
161
163
|
syncPairChrome(host, split);
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
function
|
|
165
|
-
if (!button) return;
|
|
166
|
+
function panePermissionControlState(session) {
|
|
166
167
|
var mode = session && (session.runtimeMode || session.mode || "gui");
|
|
167
168
|
var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
return {
|
|
170
|
+
projectSlug: store.get('currentSlug'),
|
|
171
|
+
sessionId: session && session.id,
|
|
172
|
+
vendor: session && session.vendor || store.get('currentVendor') || "claude",
|
|
173
|
+
permissionMode: session && session.permissionMode || "default",
|
|
174
|
+
effectivePermissionMode: session && session.effectivePermissionMode || null,
|
|
175
|
+
permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
|
|
176
|
+
connected: store.get('connected'),
|
|
177
|
+
globalPermissionModeForced: store.get('skipPermsEnabled') === true,
|
|
178
|
+
visible: !!session && !worker && mode === "gui",
|
|
179
|
+
locked: worker,
|
|
180
|
+
};
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
function updatePaneFullAccessButton(button, session) {
|
|
184
|
+
if (!button) return;
|
|
185
|
+
// Permission policy belongs to the Driver. A configured Worker inherits it
|
|
186
|
+
// server-side and must not present a second, misleading control.
|
|
187
|
+
renderPermissionControl(button, panePermissionControlState(session));
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
function createPane(pane, index) {
|
|
@@ -208,6 +213,8 @@ function createPane(pane, index) {
|
|
|
208
213
|
|
|
209
214
|
var frame = document.createElement("iframe");
|
|
210
215
|
frame.className = "split-pane-frame";
|
|
216
|
+
frame.dataset.projectSlug = pane.slug;
|
|
217
|
+
frame.dataset.sessionId = String(pane.sessionId);
|
|
211
218
|
frame.src = paneUrl(pane);
|
|
212
219
|
frame.title = pane.title;
|
|
213
220
|
|
|
@@ -225,21 +232,11 @@ function createPane(pane, index) {
|
|
|
225
232
|
});
|
|
226
233
|
header.appendChild(ctxChip);
|
|
227
234
|
|
|
228
|
-
var fullAccess =
|
|
229
|
-
fullAccess.type = "button";
|
|
230
|
-
fullAccess.className = "session-full-access split-pane-full-access hidden";
|
|
231
|
-
fullAccess.setAttribute("role", "switch");
|
|
232
|
-
fullAccess.innerHTML = '<span class="session-full-access-label">Skip Permissions</span><span class="session-full-access-track" aria-hidden="true"><span></span></span>';
|
|
235
|
+
var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
|
|
233
236
|
updatePaneFullAccessButton(fullAccess, session);
|
|
234
|
-
fullAccess
|
|
237
|
+
bindPermissionControl(fullAccess, function() {
|
|
235
238
|
var current = sessionById(pane.sessionId);
|
|
236
|
-
|
|
237
|
-
setPaneFullAccess(pane.sessionId, false);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
showConfirm("Skip permission prompts for this session? Tool requests will be approved automatically.", function () {
|
|
241
|
-
setPaneFullAccess(pane.sessionId, true);
|
|
242
|
-
}, "Skip Permissions", true);
|
|
239
|
+
return panePermissionControlState(current);
|
|
243
240
|
});
|
|
244
241
|
header.appendChild(fullAccess);
|
|
245
242
|
// Permission state belongs to the session identity on the left. Keep it
|
|
@@ -293,6 +290,7 @@ function handlePaneMessage(event) {
|
|
|
293
290
|
return;
|
|
294
291
|
}
|
|
295
292
|
if (handlePaneIssueMessage(event, host)) return;
|
|
293
|
+
if (handlePaneFileMessage(event, host, openFile, revealFilesPanel)) return;
|
|
296
294
|
if (msg.type !== "clay-pane-context") return;
|
|
297
295
|
var frames = host.querySelectorAll(".split-pane-frame");
|
|
298
296
|
for (var i = 0; i < frames.length; i++) {
|
|
@@ -527,6 +525,8 @@ export function initSplitView() {
|
|
|
527
525
|
document.addEventListener("click", handleSidebarSessionClick, true);
|
|
528
526
|
store.subscribe(function (state, prev) {
|
|
529
527
|
if (state.splitPanes !== prev.splitPanes) renderSplit(state.splitPanes);
|
|
528
|
+
if (state.permissionModePendingByTarget !== prev.permissionModePendingByTarget || state.connected !== prev.connected ||
|
|
529
|
+
state.skipPermsEnabled !== prev.skipPermsEnabled) syncPaneTitles();
|
|
530
530
|
// Switching to a session outside the open split (new_session, palette,
|
|
531
531
|
// notification click) closes the split UI; the group itself persists.
|
|
532
532
|
if (state.activeSessionId !== prev.activeSessionId && state.splitPanes && state.splitPanes.panes) {
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -788,61 +788,61 @@ function createSDKBridge(opts) {
|
|
|
788
788
|
return null; // Not whitelisted
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
-
function
|
|
791
|
+
function resolveSessionPermissionMode(session) {
|
|
792
|
+
var mode = session.permissionMode || sm.currentPermissionMode;
|
|
793
|
+
var router = getWorkerPermissionRouter();
|
|
794
|
+
if (router && typeof router.inheritedPermissionMode === "function") {
|
|
795
|
+
var inherited = router.inheritedPermissionMode(session);
|
|
796
|
+
if (inherited) mode = inherited;
|
|
797
|
+
}
|
|
798
|
+
return mode;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function handlePreToolUsePolicy(session, toolName, input) {
|
|
792
802
|
var debateDecision = homeDebateToolPolicy.initialToolDecision(session, toolName, input);
|
|
793
|
-
if (debateDecision) return
|
|
803
|
+
if (debateDecision) return debateDecision;
|
|
794
804
|
// The dynamic proposal tool is bound to this exact query session. It only
|
|
795
805
|
// posts the inline proposal; starting the debate still requires the user.
|
|
796
806
|
if (session.homeDebatePlanning === true && toolName === "propose_debate") {
|
|
797
|
-
return
|
|
807
|
+
return { behavior: "allow", updatedInput: input };
|
|
798
808
|
}
|
|
799
809
|
// Mate creation proposals only present the exact-session approval card;
|
|
800
810
|
// creating the Mate still requires the user's explicit action there.
|
|
801
811
|
if (session.mateCreationMode === true && toolName === "propose_mate") {
|
|
802
|
-
return
|
|
803
|
-
}
|
|
804
|
-
// Full-auto mode: auto-approve everything except AskUserQuestion
|
|
805
|
-
// (which still needs to go through the user interaction flow).
|
|
806
|
-
var clayPermissionMode = session.permissionMode || sm.currentPermissionMode;
|
|
807
|
-
// A configured Split Worker is Driver-operated, so it runs under its
|
|
808
|
-
// Driver's permission mode, re-resolved from the live pair on every single
|
|
809
|
-
// decision. Nothing is copied at creation and the Worker's own persisted
|
|
810
|
-
// setting is never written, so toggling skip permissions on the Driver
|
|
811
|
-
// applies to the Worker's next call in either direction, mid-turn included.
|
|
812
|
-
var _wpMode = getWorkerPermissionRouter();
|
|
813
|
-
if (_wpMode && typeof _wpMode.inheritedPermissionMode === "function") {
|
|
814
|
-
var inherited = _wpMode.inheritedPermissionMode(session);
|
|
815
|
-
if (inherited) clayPermissionMode = inherited;
|
|
816
|
-
}
|
|
817
|
-
// AskUserQuestion is genuine user input, not an authorization, so it is
|
|
818
|
-
// excluded here exactly as it always was: a Driver in skip mode still
|
|
819
|
-
// cannot silently answer a question addressed to the person.
|
|
820
|
-
if (clayPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
|
|
821
|
-
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
812
|
+
return { behavior: "allow", updatedInput: input };
|
|
822
813
|
}
|
|
823
|
-
|
|
824
814
|
// Ralph Loop execution: auto-approve all tools, deny interactive ones.
|
|
825
815
|
// Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md.
|
|
826
816
|
if (session.loop && session.loop.active && session.loop.role !== "crafting") {
|
|
827
817
|
if (toolName === "AskUserQuestion") {
|
|
828
|
-
return
|
|
818
|
+
return { behavior: "deny", message: "Autonomous mode. Make your own decision." };
|
|
829
819
|
}
|
|
830
820
|
if (toolName === "EnterPlanMode") {
|
|
831
|
-
return
|
|
821
|
+
return { behavior: "deny", message: "Do not enter plan mode. Execute directly." };
|
|
832
822
|
}
|
|
833
|
-
return
|
|
823
|
+
return { behavior: "allow", updatedInput: input };
|
|
834
824
|
}
|
|
835
825
|
|
|
836
826
|
// Check shared whitelist (read-only tools, safe browser tools, safe bash commands)
|
|
837
827
|
var whitelisted = checkToolWhitelist(toolName, input);
|
|
838
828
|
if (whitelisted) {
|
|
839
|
-
return
|
|
829
|
+
return whitelisted;
|
|
840
830
|
}
|
|
841
831
|
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function handleCanUseTool(session, toolName, input, opts) {
|
|
836
|
+
var initialDecision = handlePreToolUsePolicy(session, toolName, input);
|
|
837
|
+
if (initialDecision) return Promise.resolve(initialDecision);
|
|
838
|
+
// Skip permissions removes only the remaining Clay prompt. The SDK's
|
|
839
|
+
// deny/ask rules and Auto classifier have already run before this callback.
|
|
840
|
+
var skipRemainingPermissions = resolveSessionPermissionMode(session) === "bypassPermissions" && toolName !== "AskUserQuestion";
|
|
841
|
+
|
|
842
|
+
if (skipRemainingPermissions) return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
843
|
+
|
|
842
844
|
// Auto-approve if tool was previously allowed for session
|
|
843
|
-
if (session.allowedTools && session.allowedTools[toolName]) {
|
|
844
|
-
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
845
|
-
}
|
|
845
|
+
if (session.allowedTools && session.allowedTools[toolName]) return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
846
846
|
|
|
847
847
|
// A visible Split Worker's remaining prompts belong to its paired Driver,
|
|
848
848
|
// not to the Worker pane. This sits below every auto-decision above, so
|
|
@@ -988,12 +988,32 @@ function createSDKBridge(opts) {
|
|
|
988
988
|
}
|
|
989
989
|
}
|
|
990
990
|
|
|
991
|
+
function ownsQueryRuntime(session, queryInstance, queryGeneration) {
|
|
992
|
+
return session.queryInstance === queryInstance
|
|
993
|
+
&& Number(session._sdkQueryGeneration || 0) === Number(queryGeneration)
|
|
994
|
+
&& (!sm.sessions || typeof sm.sessions.get !== "function" || sm.sessions.get(session.localId) === session);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function recordRuntimeEffectivePermissionMode(session, queryInstance, queryGeneration, message) {
|
|
998
|
+
if (!message || (message.yokeType !== "init" && message.yokeType !== "status") || !message.permissionMode) return false;
|
|
999
|
+
if (!ownsQueryRuntime(session, queryInstance, queryGeneration)) return false;
|
|
1000
|
+
session.effectivePermissionMode = message.permissionMode;
|
|
1001
|
+
session.permissionModeFallbackReason = null;
|
|
1002
|
+
sm.saveSessionFile(session);
|
|
1003
|
+
sendToSession(session, { type: "config_state", sessionId: session.localId, vendor: session.vendor || "claude",
|
|
1004
|
+
mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: message.permissionMode,
|
|
1005
|
+
permissionCapabilities: session.permissionCapabilities || { auto: false, mcpOverride: false },
|
|
1006
|
+
mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {} });
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
991
1010
|
async function processQueryStream(session, codexCatalogCandidate) {
|
|
992
1011
|
// Capture references at start so we only clean up OUR resources in finally,
|
|
993
1012
|
// not resources from a newer query that may have been created after an abort.
|
|
994
1013
|
var myQueryInstance = session.queryInstance;
|
|
995
1014
|
var myAbortController = session.abortController;
|
|
996
1015
|
var myMessageQueue = session.messageQueue;
|
|
1016
|
+
var myQueryGeneration = Number(session._sdkQueryGeneration || 0);
|
|
997
1017
|
console.log("[sdk-bridge] processQueryStream: starting for-await loop, vendor=" + (session.vendor || adapter.vendor));
|
|
998
1018
|
try {
|
|
999
1019
|
for await (var msg of myQueryInstance) {
|
|
@@ -1023,8 +1043,10 @@ function createSDKBridge(opts) {
|
|
|
1023
1043
|
sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort, betas: sm.currentBetas || [] });
|
|
1024
1044
|
break;
|
|
1025
1045
|
case "permission_mode_changed":
|
|
1026
|
-
|
|
1027
|
-
|
|
1046
|
+
// Setter notifications are acknowledgements, not a new project
|
|
1047
|
+
// default and not proof of the runtime-effective mode. Only an
|
|
1048
|
+
// init/status event below may confirm effectivePermissionMode.
|
|
1049
|
+
sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: session.effectivePermissionMode || null, permissionCapabilities: session.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {}, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1028
1050
|
break;
|
|
1029
1051
|
case "worker_error":
|
|
1030
1052
|
send({ type: "error", text: metaData.error });
|
|
@@ -1033,6 +1055,10 @@ function createSDKBridge(opts) {
|
|
|
1033
1055
|
continue;
|
|
1034
1056
|
}
|
|
1035
1057
|
processSDKMessage(session, msg);
|
|
1058
|
+
// SDK init/status events are the only authority for runtime-effective
|
|
1059
|
+
// permission state. Setter acknowledgement deliberately does not
|
|
1060
|
+
// change this field.
|
|
1061
|
+
recordRuntimeEffectivePermissionMode(session, myQueryInstance, myQueryGeneration, msg);
|
|
1036
1062
|
if (codexCatalogCandidate && msg && msg.yokeType === "session_started" && msg.sessionId
|
|
1037
1063
|
&& session.queryInstance === myQueryInstance
|
|
1038
1064
|
&& Number(session._sdkQueryGeneration || 0) === codexCatalogCandidate.generation
|
|
@@ -1843,12 +1869,20 @@ function createSDKBridge(opts) {
|
|
|
1843
1869
|
abortController: linuxUser ? undefined : session.abortController,
|
|
1844
1870
|
canUseTool: function(toolName, input, toolOpts) {
|
|
1845
1871
|
var nativeQuestionError = toolName === "AskUserQuestion" ? scheduleQuestionError(input) : null;
|
|
1846
|
-
if (nativeQuestionError) return
|
|
1872
|
+
if (nativeQuestionError) return { behavior: "deny", message: nativeQuestionError };
|
|
1847
1873
|
var permissionName = toolName;
|
|
1848
1874
|
var permissionToolDef = findSessionToolDef(toolName);
|
|
1849
1875
|
if (permissionToolDef && permissionToolDef.permissionName) permissionName = permissionToolDef.permissionName;
|
|
1850
1876
|
return handleCanUseTool(session, permissionName, input, toolOpts);
|
|
1851
1877
|
},
|
|
1878
|
+
preToolUsePolicy: function(toolName, input) {
|
|
1879
|
+
var nativeQuestionError = toolName === "AskUserQuestion" ? scheduleQuestionError(input) : null;
|
|
1880
|
+
if (nativeQuestionError) return Promise.resolve({ behavior: "deny", message: nativeQuestionError });
|
|
1881
|
+
var permissionName = toolName;
|
|
1882
|
+
var permissionToolDef = findSessionToolDef(toolName);
|
|
1883
|
+
if (permissionToolDef && permissionToolDef.permissionName) permissionName = permissionToolDef.permissionName;
|
|
1884
|
+
return handlePreToolUsePolicy(session, permissionName, input);
|
|
1885
|
+
},
|
|
1852
1886
|
onUserInputRequest: function(request, respond) {
|
|
1853
1887
|
return sessionUserInputHandler(request, respond);
|
|
1854
1888
|
},
|
|
@@ -1948,6 +1982,24 @@ function createSDKBridge(opts) {
|
|
|
1948
1982
|
|
|
1949
1983
|
// Store QueryHandle on session for iteration and control.
|
|
1950
1984
|
session.queryInstance = handle;
|
|
1985
|
+
// Capabilities come from the installed query handle, never a model-name
|
|
1986
|
+
// table. `acceptEdits` and MCP overrides are Claude SDK semantics; a
|
|
1987
|
+
// vendor may expose the generic setter without implementing either.
|
|
1988
|
+
session.permissionCapabilities = require("./session-permission-mode").capabilities(session, sm.defaultVendor);
|
|
1989
|
+
session.permissionCapabilities.mcpOverride = sessionAdapter.vendor === "claude" && typeof handle.setMcpPermissionModeOverride === "function";
|
|
1990
|
+
session.effectivePermissionMode = null;
|
|
1991
|
+
sm.saveSessionFile(session);
|
|
1992
|
+
sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: null, permissionCapabilities: session.permissionCapabilities, mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {}, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1993
|
+
|
|
1994
|
+
if (session.permissionMode === "auto" && session.permissionCapabilities.mcpOverride) {
|
|
1995
|
+
var overrideNames = Object.keys(session.mcpPermissionModeOverrides || {});
|
|
1996
|
+
for (var oi = 0; oi < overrideNames.length; oi++) {
|
|
1997
|
+
try { await handle.setMcpPermissionModeOverride(overrideNames[oi], session.mcpPermissionModeOverrides[overrideNames[oi]]); }
|
|
1998
|
+
catch (overrideError) {
|
|
1999
|
+
sendToSession(session, { type: "error", text: "Failed to restore the AI review policy for " + overrideNames[oi] + ": " + (overrideError.message || overrideError) });
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
1951
2003
|
|
|
1952
2004
|
if (typeof beforePush === "function" && beforePush() !== true) {
|
|
1953
2005
|
try { handle.close(); } catch (e) {}
|
|
@@ -2358,17 +2410,15 @@ function createSDKBridge(opts) {
|
|
|
2358
2410
|
async function setPermissionMode(session, mode) {
|
|
2359
2411
|
if (!session.queryInstance) {
|
|
2360
2412
|
// No active query — just store the mode for next startQuery
|
|
2361
|
-
|
|
2362
|
-
sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
2363
|
-
return;
|
|
2413
|
+
return { ok: true, effectiveMode: mode };
|
|
2364
2414
|
}
|
|
2365
2415
|
try {
|
|
2366
2416
|
// Route through QueryHandle (works for both in-process and worker paths)
|
|
2367
2417
|
await session.queryInstance.setPermissionMode(mode);
|
|
2368
|
-
|
|
2369
|
-
sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
2418
|
+
return { ok: true, effectiveMode: mode };
|
|
2370
2419
|
} catch (e) {
|
|
2371
2420
|
sendToSession(session, { type: "error", text: "Failed to set permission mode: " + (e.message || e) });
|
|
2421
|
+
return { ok: false, error: e.message || String(e) };
|
|
2372
2422
|
}
|
|
2373
2423
|
}
|
|
2374
2424
|
|
|
@@ -2404,12 +2454,14 @@ function createSDKBridge(opts) {
|
|
|
2404
2454
|
|
|
2405
2455
|
// Override the permission mode for one MCP server on the active query.
|
|
2406
2456
|
async function setMcpPermissionModeOverride(session, serverName, mode) {
|
|
2407
|
-
if (!session || !session.queryInstance) return;
|
|
2457
|
+
if (!session || !session.queryInstance || typeof session.queryInstance.setMcpPermissionModeOverride !== "function") return { ok: false, error: "MCP permission controls are unavailable for this session." };
|
|
2408
2458
|
try {
|
|
2409
2459
|
// Route through QueryHandle (works for both in-process and worker paths)
|
|
2410
2460
|
await session.queryInstance.setMcpPermissionModeOverride(serverName, mode);
|
|
2461
|
+
return { ok: true };
|
|
2411
2462
|
} catch (e) {
|
|
2412
2463
|
send({ type: "error", text: "Failed to set MCP permission mode: " + (e.message || e) });
|
|
2464
|
+
return { ok: false, error: e.message || String(e) };
|
|
2413
2465
|
}
|
|
2414
2466
|
}
|
|
2415
2467
|
|
|
@@ -2583,6 +2635,9 @@ function createSDKBridge(opts) {
|
|
|
2583
2635
|
createMessageQueue: createMessageQueue,
|
|
2584
2636
|
processSDKMessage: processSDKMessage,
|
|
2585
2637
|
checkToolWhitelist: checkToolWhitelist,
|
|
2638
|
+
handlePreToolUsePolicy: handlePreToolUsePolicy,
|
|
2639
|
+
ownsQueryRuntime: ownsQueryRuntime,
|
|
2640
|
+
recordRuntimeEffectivePermissionMode: recordRuntimeEffectivePermissionMode,
|
|
2586
2641
|
handleCanUseTool: handleCanUseTool,
|
|
2587
2642
|
handleElicitation: handleElicitation,
|
|
2588
2643
|
processQueryStream: processQueryStream,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Canonical, server-owned permission selection validation. The requested mode
|
|
2
|
+
// is persisted separately from the SDK mode because Clay keeps the final
|
|
3
|
+
// canUseTool decision for GUI sessions.
|
|
4
|
+
|
|
5
|
+
function normalize(mode) {
|
|
6
|
+
if (mode === "default" || mode === "auto" || mode === "plan" || mode === "acceptEdits" || mode === "bypassPermissions") return mode;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function capabilities(session, defaultVendor) {
|
|
11
|
+
var vendor = session && session.vendor || defaultVendor || "claude";
|
|
12
|
+
return {
|
|
13
|
+
auto: vendor === "claude",
|
|
14
|
+
mcpOverride: vendor === "claude" && !!(session && session.queryInstance &&
|
|
15
|
+
typeof session.queryInstance.setMcpPermissionModeOverride === "function"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function supportsAuto(session, defaultVendor) {
|
|
20
|
+
return capabilities(session, defaultVendor).auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validate(session, mode, defaultVendor) {
|
|
24
|
+
var requested = normalize(mode);
|
|
25
|
+
if (!requested) return { ok: false, error: "Unknown permission mode." };
|
|
26
|
+
if (requested === "auto" && !supportsAuto(session, defaultVendor)) {
|
|
27
|
+
return { ok: false, error: "Auto permissions are not available for this session." };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, mode: requested };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clientState(session, fallback, defaultVendor) {
|
|
33
|
+
var requested = session && session.permissionMode || fallback || "default";
|
|
34
|
+
return {
|
|
35
|
+
requestedPermissionMode: requested,
|
|
36
|
+
effectivePermissionMode: session && session.effectivePermissionMode || null,
|
|
37
|
+
permissionCapabilities: capabilities(session, defaultVendor),
|
|
38
|
+
mcpPermissionModeOverrides: session && session.mcpPermissionModeOverrides || {},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { validate: validate, clientState: clientState, supportsAuto: supportsAuto, capabilities: capabilities };
|
package/lib/sessions.js
CHANGED
|
@@ -177,6 +177,7 @@ function createSessionManager(opts) {
|
|
|
177
177
|
// so lazy-resume (`claude --resume`) re-spawns with the same flag.
|
|
178
178
|
if (session.dangerouslySkipPermissions) metaObj.dangerouslySkipPermissions = true;
|
|
179
179
|
if (session.permissionMode) metaObj.permissionMode = session.permissionMode;
|
|
180
|
+
if (session.mcpPermissionModeOverrides) metaObj.mcpPermissionModeOverrides = session.mcpPermissionModeOverrides;
|
|
180
181
|
if (session.permissionModeBeforeFullAccess) metaObj.permissionModeBeforeFullAccess = session.permissionModeBeforeFullAccess;
|
|
181
182
|
if (session.autonomousRun) metaObj.autonomousRun = session.autonomousRun;
|
|
182
183
|
if (session.loopInterviewBrief) metaObj.loopInterviewBrief = session.loopInterviewBrief;
|
|
@@ -348,6 +349,10 @@ function createSessionManager(opts) {
|
|
|
348
349
|
// Clay's own per-session permission mode.
|
|
349
350
|
session.dangerouslySkipPermissions = session.mode === "tui" && !!m.dangerouslySkipPermissions;
|
|
350
351
|
session.permissionMode = m.permissionMode || (session.mode === "gui" && m.dangerouslySkipPermissions ? "bypassPermissions" : null);
|
|
352
|
+
// Runtime-effective state is observed from the live SDK only. A daemon
|
|
353
|
+
// restore has no active runtime to confirm the old value.
|
|
354
|
+
session.effectivePermissionMode = null;
|
|
355
|
+
session.mcpPermissionModeOverrides = m.mcpPermissionModeOverrides && typeof m.mcpPermissionModeOverrides === "object" ? m.mcpPermissionModeOverrides : {};
|
|
351
356
|
session.permissionModeBeforeFullAccess = m.permissionModeBeforeFullAccess || null;
|
|
352
357
|
session.autonomousRun = restoreStoredRun(m.autonomousRun);
|
|
353
358
|
session.loopInterviewBrief = m.loopInterviewBrief && typeof m.loopInterviewBrief === "object" ? m.loopInterviewBrief : null;
|
|
@@ -587,6 +592,9 @@ function createSessionManager(opts) {
|
|
|
587
592
|
runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null,
|
|
588
593
|
dangerouslySkipPermissions: !!s.dangerouslySkipPermissions,
|
|
589
594
|
permissionMode: s.permissionMode || null,
|
|
595
|
+
effectivePermissionMode: s.effectivePermissionMode || null,
|
|
596
|
+
permissionCapabilities: require("./session-permission-mode").capabilities(s, defaultVendor),
|
|
597
|
+
mcpPermissionModeOverrides: s.mcpPermissionModeOverrides || {},
|
|
590
598
|
autonomousRun: s.autonomousRun ? {
|
|
591
599
|
id: s.autonomousRun.id,
|
|
592
600
|
state: s.autonomousRun.state,
|
|
@@ -676,6 +684,8 @@ function createSessionManager(opts) {
|
|
|
676
684
|
mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
|
|
677
685
|
dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
|
|
678
686
|
permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
|
|
687
|
+
effectivePermissionMode: null,
|
|
688
|
+
mcpPermissionModeOverrides: {},
|
|
679
689
|
permissionModeBeforeFullAccess: null,
|
|
680
690
|
autonomousRun: null,
|
|
681
691
|
loopInterviewBrief: null,
|
|
@@ -725,6 +735,8 @@ function createSessionManager(opts) {
|
|
|
725
735
|
mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
|
|
726
736
|
dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
|
|
727
737
|
permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
|
|
738
|
+
effectivePermissionMode: null,
|
|
739
|
+
mcpPermissionModeOverrides: {},
|
|
728
740
|
permissionModeBeforeFullAccess: null,
|
|
729
741
|
autonomousRun: null,
|
|
730
742
|
loopInterviewBrief: null,
|
|
@@ -826,7 +838,8 @@ function createSessionManager(opts) {
|
|
|
826
838
|
var _capsByVendor = capabilitiesByVendor || {};
|
|
827
839
|
var _sessionVendor = session.vendor || defaultVendor || "claude";
|
|
828
840
|
var _vendorCaps = _capsByVendor[_sessionVendor] || {};
|
|
829
|
-
|
|
841
|
+
var _permissionState = require("./session-permission-mode").clientState(session, "default", defaultVendor);
|
|
842
|
+
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
|
|
830
843
|
// Send vendor-specific slash commands
|
|
831
844
|
var _vendorCmds = (session.slashCommandsByVendor && session.slashCommandsByVendor[_sessionVendor])
|
|
832
845
|
|| session.slashCommands
|
package/lib/ws-schema.js
CHANGED
|
@@ -252,6 +252,7 @@ var schema = {
|
|
|
252
252
|
"set_server_default_model": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the server-wide default model" },
|
|
253
253
|
"set_project_default_model": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the project default model" },
|
|
254
254
|
"set_permission_mode": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set permission mode for the current session" },
|
|
255
|
+
"permission_mode_result": { direction: "s2c", handler: "lib/public/modules/permission-control.js", description: "Correlated result for an exact project/session permission mode change" },
|
|
255
256
|
"set_session_full_access": { direction: "c2s", handler: "lib/project-sessions.js", description: "Toggle Clay-managed permission skipping for a GUI session" },
|
|
256
257
|
"session_full_access_changed": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A session's full-access state changed" },
|
|
257
258
|
"session_full_access_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Result of toggling session full access" },
|
|
@@ -269,6 +270,7 @@ var schema = {
|
|
|
269
270
|
"autonomous_run_state": { direction: "s2c", handler: "lib/public/modules/autonomous-run.js", description: "Synchronized Until complete run state" },
|
|
270
271
|
"reload_skills": { direction: "c2s", handler: "lib/project-sessions.js", description: "Hot-reload skills/MCP/agents on the active query without a restart" },
|
|
271
272
|
"set_mcp_permission_mode_override": { direction: "c2s", handler: "lib/project-sessions.js", description: "Override permission mode (default/auto/null) for one MCP server" },
|
|
273
|
+
"mcp_permission_mode_result": { direction: "s2c", handler: "lib/public/modules/mcp-ui.js", description: "Correlated result for one session MCP AI review policy change" },
|
|
272
274
|
"set_server_default_mode": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the server-wide default permission mode" },
|
|
273
275
|
"set_project_default_mode": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the project default permission mode" },
|
|
274
276
|
"set_effort": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set effort level for the current session" },
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Bounded fail-closed lifecycle for worker PreToolUse policy IPC requests.
|
|
2
|
+
|
|
3
|
+
function denied(message) {
|
|
4
|
+
return { behavior: "deny", message: message };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function createPolicyBroker(options) {
|
|
8
|
+
options = options || {};
|
|
9
|
+
var send = options.send;
|
|
10
|
+
var timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
|
|
11
|
+
var pending = {};
|
|
12
|
+
|
|
13
|
+
function settle(requestId, result) {
|
|
14
|
+
var record = pending[requestId];
|
|
15
|
+
if (!record) return false;
|
|
16
|
+
delete pending[requestId];
|
|
17
|
+
clearTimeout(record.timer);
|
|
18
|
+
if (record.signal && record.onAbort && typeof record.signal.removeEventListener === "function") {
|
|
19
|
+
record.signal.removeEventListener("abort", record.onAbort);
|
|
20
|
+
}
|
|
21
|
+
record.resolve(result);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function request(message, signal) {
|
|
26
|
+
return new Promise(function(resolve) {
|
|
27
|
+
if (signal && signal.aborted) {
|
|
28
|
+
resolve(denied("Permission policy cancelled."));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
var requestId = message.requestId;
|
|
32
|
+
var record = { resolve: resolve, signal: signal || null, onAbort: null, timer: null };
|
|
33
|
+
pending[requestId] = record;
|
|
34
|
+
record.onAbort = function() {
|
|
35
|
+
settle(requestId, denied("Permission policy cancelled."));
|
|
36
|
+
};
|
|
37
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
38
|
+
signal.addEventListener("abort", record.onAbort, { once: true });
|
|
39
|
+
}
|
|
40
|
+
record.timer = setTimeout(function() {
|
|
41
|
+
settle(requestId, denied("Permission policy timed out."));
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
var sent = false;
|
|
44
|
+
try { sent = send(message) === true; }
|
|
45
|
+
catch (error) { sent = false; }
|
|
46
|
+
if (!sent) settle(requestId, denied("Permission policy IPC is unavailable."));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function respond(message) {
|
|
51
|
+
var result = message.error ? denied(message.error) : message.result;
|
|
52
|
+
return settle(message.requestId, result || null);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clear(message) {
|
|
56
|
+
var ids = Object.keys(pending);
|
|
57
|
+
for (var i = 0; i < ids.length; i++) settle(ids[i], denied(message || "Permission policy IPC closed."));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pendingCount() {
|
|
61
|
+
return Object.keys(pending).length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { request: request, respond: respond, clear: clear, pendingCount: pendingCount };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { createPolicyBroker: createPolicyBroker };
|