fix(vscode): restore permission auto-accept parity
Add a VS Code-specific foreground permission responder while preserving the server-owned implementation for web, desktop, and mobile runtimes. Persist the authoritative VS Code policy in extension global state and expose matching GET/PUT bridge routes. Broadcast policy updates to the sidebar, session editor panels, and agent manager so every active webview observes the same explicit per-session policy. Resolve missing child-session lineage through OpenCode, honor nearest explicit ancestor overrides, deduplicate concurrent requests, retry transient replies, and reconcile pending permissions after enablement, bootstrap, and reconnect. Treat resolved requests as handled and route notification suppression through the same responder outcome. Keep post-toggle reconciliation failures non-fatal after policy persistence and fail closed when lineage or replies cannot be confirmed. Document that auto-accept intentionally cannot run while every OpenChamber webview is closed or suspended.
This commit is contained in:
@@ -140,6 +140,14 @@ export class AgentManagerPanelProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
this._panel?.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
if (!this._panel) {
|
||||
return;
|
||||
|
||||
@@ -318,6 +318,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
this._view?.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the webview to run the full OpenCode reload flow (overlay + managed
|
||||
* restart via the bridge + config/data refresh) — the same flow used after an
|
||||
|
||||
@@ -52,6 +52,10 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
|
||||
- `bridge-permission-auto-accept-runtime.ts`
|
||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
||||
- Broadcasts policy snapshots to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
|
||||
|
||||
## Extension guideline
|
||||
|
||||
When adding new bridge route families:
|
||||
|
||||
@@ -199,6 +199,16 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
|
||||
|
||||
const createContext = () => {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
globalState: {
|
||||
get: (key: string) => values.get(key),
|
||||
update: async (key: string, value: unknown) => { values.set(key, value); },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('VS Code permission auto-accept policy bridge', () => {
|
||||
test('persists policy and broadcasts the authoritative snapshot', async () => {
|
||||
const context = createContext();
|
||||
const broadcasts: unknown[] = [];
|
||||
const dependencies = { broadcast: async (snapshot: unknown) => { broadcasts.push(snapshot); } };
|
||||
const response = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '1',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'root', enabled: true },
|
||||
}, context, dependencies);
|
||||
|
||||
assert.equal(response?.success, true);
|
||||
assert.deepEqual(response?.data, { sessions: { root: true } });
|
||||
assert.deepEqual(broadcasts, [{ sessions: { root: true } }]);
|
||||
|
||||
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '2',
|
||||
type: 'api:permission-auto-accept:get',
|
||||
}, context, dependencies);
|
||||
assert.deepEqual(reloaded?.data, { sessions: { root: true } });
|
||||
});
|
||||
|
||||
test('rejects malformed policy writes', async () => {
|
||||
const broadcasts: unknown[] = [];
|
||||
const response = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '1',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'root', enabled: 'yes' },
|
||||
}, createContext(), { broadcast: async (snapshot) => { broadcasts.push(snapshot); } });
|
||||
|
||||
assert.equal(response?.success, false);
|
||||
assert.deepEqual(broadcasts, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const STORAGE_KEY = 'permissionAutoAccept';
|
||||
|
||||
type PolicyContext = {
|
||||
globalState: {
|
||||
get: (key: string) => unknown;
|
||||
update: (key: string, value: unknown) => PromiseLike<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PermissionAutoAcceptSnapshot = {
|
||||
sessions: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const normalizeSnapshot = (value: unknown): PermissionAutoAcceptSnapshot => {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as { sessions?: unknown }
|
||||
: {};
|
||||
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
|
||||
? Object.entries(source.sessions)
|
||||
: [];
|
||||
const sessions: Record<string, boolean> = {};
|
||||
for (const [sessionId, enabled] of entries) {
|
||||
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
|
||||
}
|
||||
return { sessions };
|
||||
};
|
||||
|
||||
const readPermissionAutoAcceptPolicy = (context: PolicyContext) =>
|
||||
normalizeSnapshot(context.globalState.get(STORAGE_KEY));
|
||||
|
||||
async function setPermissionAutoAcceptPolicy(
|
||||
context: PolicyContext,
|
||||
sessionId: string,
|
||||
enabled: boolean,
|
||||
broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown>,
|
||||
) {
|
||||
const current = readPermissionAutoAcceptPolicy(context);
|
||||
const snapshot = {
|
||||
sessions: { ...current.sessions, [sessionId]: enabled },
|
||||
};
|
||||
await context.globalState.update(STORAGE_KEY, snapshot);
|
||||
await broadcast(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function handlePermissionAutoAcceptBridgeMessage(
|
||||
message: { id: string; type: string; payload?: unknown },
|
||||
context?: PolicyContext,
|
||||
dependencies?: { broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown> },
|
||||
) {
|
||||
if (message.type !== 'api:permission-auto-accept:get' && message.type !== 'api:permission-auto-accept:set') {
|
||||
return null;
|
||||
}
|
||||
if (!context) return { id: message.id, type: message.type, success: false, error: 'Extension context is unavailable' };
|
||||
|
||||
if (message.type === 'api:permission-auto-accept:get') {
|
||||
return { id: message.id, type: message.type, success: true, data: readPermissionAutoAcceptPolicy(context) };
|
||||
}
|
||||
|
||||
const payload = message.payload && typeof message.payload === 'object'
|
||||
? message.payload as { sessionId?: unknown; enabled?: unknown }
|
||||
: {};
|
||||
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
|
||||
if (!sessionId) return { id: message.id, type: message.type, success: false, error: 'sessionId is required' };
|
||||
if (typeof payload.enabled !== 'boolean') {
|
||||
return { id: message.id, type: message.type, success: false, error: 'enabled must be a boolean' };
|
||||
}
|
||||
|
||||
const snapshot = await setPermissionAutoAcceptPolicy(
|
||||
context,
|
||||
sessionId,
|
||||
payload.enabled,
|
||||
dependencies?.broadcast ?? (() => Promise.resolve()),
|
||||
);
|
||||
return { id: message.id, type: message.type, success: true, data: snapshot };
|
||||
}
|
||||
@@ -558,15 +558,6 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:notifications/auto-accept': {
|
||||
const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown };
|
||||
const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : '';
|
||||
if (!sessionId) {
|
||||
return { id, type, success: false, error: 'sessionId is required' };
|
||||
}
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { handleFsBridgeMessage } from './bridge-fs-runtime';
|
||||
import { handleConfigBridgeMessage } from './bridge-config-runtime';
|
||||
import { handleSystemBridgeMessage } from './bridge-system-runtime';
|
||||
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
|
||||
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
|
||||
import {
|
||||
fetchOpenCodeSkillsFromApi,
|
||||
persistSettings,
|
||||
@@ -63,6 +64,18 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
const { id, type, payload } = message;
|
||||
|
||||
try {
|
||||
const permissionAutoAcceptResponse = await handlePermissionAutoAcceptBridgeMessage(
|
||||
{ id, type, payload },
|
||||
ctx?.context,
|
||||
{
|
||||
broadcast: (snapshot) => vscode.commands.executeCommand(
|
||||
'openchamber.internal.permissionAutoAcceptSynced',
|
||||
snapshot,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (permissionAutoAcceptResponse) return permissionAutoAcceptResponse;
|
||||
|
||||
const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload });
|
||||
if (standardGitResponse) {
|
||||
return standardGitResponse;
|
||||
|
||||
@@ -200,6 +200,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.internal.permissionAutoAcceptSynced', (snapshot: unknown) => {
|
||||
chatViewProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
sessionEditorProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
agentManagerProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeWindowState((state) => {
|
||||
chatViewProvider?.notifyWindowFocusChanged(state.focused);
|
||||
|
||||
Reference in New Issue
Block a user