Compare commits

...
24 changed files with 464 additions and 49 deletions

No files matched your search

+6 -1
View File
@@ -72,7 +72,12 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) runtime.invalidateConfiguration();
if (key === null || (key && configurationKeys.has(key))) {
runtime.invalidateConfiguration();
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
}
};
window.addEventListener('pagehide', pageHide);
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
@@ -144,6 +144,15 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'backend-revision';
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
assert.deepEqual(errors, []);
+15
View File
@@ -100,6 +100,21 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
await act(async () => { button('Deny').click(); await displayed; await settle(); });
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
await act(async () => {
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
}); await settle();
});
const dialog = document.querySelector('[role="dialog"]')!;
assert.match(dialog.textContent!, /Operation:.*get_environment/);
assert.match(dialog.textContent!, /session\.environment/);
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
}
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
+13 -5
View File
@@ -3,6 +3,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import globalAgentToolSpecs from '../../infrastructure/ai/harness/generated/globalAgentToolSpecs.json';
/** Rendered only by the trusted app, never inside a plugin view. */
export function PluginPermissionHost() {
@@ -12,6 +13,9 @@ export function PluginPermissionHost() {
if (!prompt) return null;
const description = describePluginPermission(prompt.permission, resolvedLocale);
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
const action = prompt.displayContext?.action;
const operation = action && (globalAgentToolSpecs.find((tool) => tool.capabilityId === action.capabilityId)?.toolName ?? action.capabilityId);
const selections = prompt.displayContext?.selections ?? [];
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogHeader>
@@ -20,13 +24,17 @@ export function PluginPermissionHost() {
</DialogHeader>
<p className="text-sm">{description.description}</p>
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
{prompt.displayContext?.action && <div className="space-y-1">
{action && <div className="space-y-1">
<p className="text-sm break-words">{zh ? '操作' : 'Operation'}: <code>{operation}</code>{operation !== action.capabilityId && <> ({action.capabilityId})</>}</p>
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{prompt.displayContext.action.text}</pre>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{action.text}</pre>
</div>}
{(selections.length > 0 || prompt.permission === 'ai.agent.actions') && <div className="space-y-1">
<p className="text-sm">{zh ? '目标' : 'Targets'}:{selections.length === 0 && (zh ? ' 未选择目标' : ' No targets selected')}</p>
{selections.map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
</div>}
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
+11 -1
View File
@@ -18,10 +18,20 @@ sha256sum /tmp/netcatty-global-agent.ncpkg
```
The reproducible sample SHA-256 is
`36993b1d566814d3b5304337fab73ff464e422d93ba52909e95e3ceedcad2a6a`.
`7f2f0b2a1d37344012a14a50e428fa41a2dfbac09db663d638d6a7f0a57b35af`.
Two independent package builds matched. Build scripts and tests are excluded
from the six-file installed package.
The sample reserves transcript space inside the actual 798-by-548-pixel plugin
viewport measured during native QA. In a scheduled native test environment, run
`npm run test:plugin-global-agent-layout` for the bounded render regression. It
loads the real sample HTML/CSS with synthetic history, including the fixture
notice, and checks transcript scrolling, reachable configuration, and visible
mouse Stop. It does not load the plugin script or start an Agent/provider. This
render check remains pending where the normal browser sandbox cannot start;
do not disable the sandbox to run it. Also verify the installed sample visually
at the 1188-by-848-pixel app window before accepting the viewport correction.
## Native fixture acceptance (parent-scheduled GUI only)
1. Use an isolated synthetic profile, no real Notes, hosts, histories or keys.
@@ -0,0 +1,51 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { createRequire, wrap } = require("node:module");
// Execute the real driver/registry source with only config repair and SDK
// inference replaced. No user config is read and no SDK process is launched.
function loadSource(name, replacements) {
const filename = require.resolve(name); const local = createRequire(filename);
const module = { exports: {} };
const wrapper = vm.runInThisContext(wrap(fs.readFileSync(filename, "utf8")), { filename });
wrapper(module.exports, (id) => replacements[id] ?? local(id), module, filename, path.dirname(filename));
return module.exports;
}
function fixture() {
const repairs = []; const sends = []; const errors = [];
const driver = loadSource("./claudeDriver.cjs", { "./claudeConfig.cjs": { ensureClaudeConfig: () => repairs.push("repair") } });
const run = driver.runClaudeTurn;
driver.runClaudeTurn = (args) => run({ ...args, queryFn: ({ options }) => {
sends.push(options);
return (async function* () { yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Synthetic" } } }; })();
} });
const registry = loadSource("./index.cjs", { "./claudeDriver.cjs": driver });
const emitter = new Proxy({ emitError: (error) => errors.push(error) }, { get: (target, key) => target[key] ?? (() => {}) });
const env = Object.freeze({ CLAUDE_CONFIG_DIR: "/synthetic/selected-profile", ANTHROPIC_BASE_URL: "http://127.0.0.1:1", ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" });
const ctx = { prompt: "Synthetic", env, emitter, toolIntegrationMode: "mcp", cwd: "/synthetic/turn", binPath: "/synthetic/official-claude",
abortController: new AbortController(), assertSessionValid: () => {}, injectedMcpServers: [{ name: "selected-only", command: "host-managed", args: [], env: [] }] };
return { repairs, sends, errors, env, ctx, run: (overrides) => registry.getDriver("claude").runTurn({ ...ctx, ...overrides }) };
}
for (const explicitProfile of [true, false]) test(`delegated registry turn skips config repair with ${explicitProfile ? "explicit" : "default"} host profile`, async () => {
const f = fixture(); const env = explicitProfile ? f.env : { ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" };
await f.run({ delegatedAgent: true, env });
assert.deepEqual(f.repairs, []); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
const options = f.sends[0]; assert.equal(options.env, env);
assert.equal(options.cwd, f.ctx.cwd); assert.equal(options.pathToClaudeCodeExecutable, f.ctx.binPath);
assert.deepEqual(options.tools, []); assert.deepEqual(options.settingSources, []);
assert.equal(options.strictMcpConfig, true); assert.equal(options.persistSession, false);
assert.deepEqual(Object.keys(options.mcpServers), ["selected-only"]);
});
for (const delegatedAgent of [undefined, false]) test(`builtin registry turn retains repair when delegated flag is ${String(delegatedAgent)}`, async () => {
const f = fixture(); await f.run({ delegatedAgent });
assert.deepEqual(f.repairs, ["repair"]); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
assert.equal(f.sends[0].env, f.env); assert.equal(f.sends[0].settingSources, undefined);
});
@@ -5,7 +5,7 @@
*
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
* - Repairs ~/.claude.json before builtin turns only (ensureClaudeConfig).
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
* side-effect tools so the agent can only act through the injected netcatty
* MCP server (approval/scope/blocklist enforced there).
@@ -244,8 +244,10 @@ function buildClaudePromptInput(prompt, attachments) {
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
*/
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
ensureClaudeConfig();
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid, delegatedAgent = false }) {
// A delegated turn may use the selected host profile for authentication, but
// must not repair or delete unrelated files in the main process's home.
if (!delegatedAgent) ensureClaudeConfig();
let query = queryFn;
if (!query) {
let sdk;
@@ -1,6 +1,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
// SDK queries in this file are synthetic. Never repair real user config.
const configPath = require.resolve("./claudeConfig.cjs");
const priorConfigModule = require.cache[configPath];
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
else delete require.cache[configPath];
function collector() {
const events = [];
+2 -1
View File
@@ -46,7 +46,8 @@ const DRIVER_REGISTRY = {
toolIntegrationMode: ctx.toolIntegrationMode,
delegatedAgent: ctx.delegatedAgent === true,
});
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter,
assertSessionValid: ctx.assertSessionValid, delegatedAgent: ctx.delegatedAgent === true });
},
async listModels(ctx) {
return claude.listClaudeModels({
@@ -3,6 +3,14 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
// Load the query-only Claude fixture with config repair replaced. Its live
// prompt guard tests must never read or repair the executor's actual profile.
const configPath = require.resolve("./claudeConfig.cjs");
const priorConfigModule = require.cache[configPath];
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
const claudeDriver = require("./claudeDriver.cjs");
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
else delete require.cache[configPath];
function fixture() {
const privacy = createAiPrivacyService();
@@ -172,7 +180,7 @@ for (const pausedMethod of ["initialize", "session/new"]) {
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
const cases = [
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["claude", (f) => claudeDriver.runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
@@ -2218,6 +2218,32 @@ test("delegated Agent entry rejects unverified runtimes and Skills before native
assert.equal(validated, 1);
});
test("actual SDK entry derives delegated mode from main authority, ignoring caller flag spoofing", async (t) => {
const { PluginAgentMcpDelegations } = require("../../../plugins/agent/mcpDelegation.cjs");
const registry = new PluginAgentMcpDelegations(); t.after(() => registry.shutdown());
const chat = "plugin-agent:11111111-1111-4111-8111-111111111111";
const tag = "22222222-2222-4222-8222-222222222222";
registry.register(chat, { turnTag: tag, senderId: 7, tools: [], assertCurrent() {}, dispatch() {} });
const h = createPrivacyStreamHarness(createTestPrivacy(false));
const directory = fs.mkdtempSync(nodePath.join(os.tmpdir(), "netcatty-claude-flag-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
h.ctx.tempDirBridge = { getTempDir: () => directory };
Object.assign(h.ctx.mcpServerBridge, {
isPluginAgentConversation: (id) => registry.isReserved(id),
assertPluginAgentCaller: (...args) => registry.assertCaller(...args),
getPluginAgentMcpConfiguration: (...args) => registry.configuration(...args),
onPluginAgentRelease: (...args) => registry.onRelease(...args),
});
const handler = h.handlers.get("netcatty:ai:sdk-agent:stream");
const payload = { requestId: `plugin-agent_${tag}_fixture`, chatSessionId: chat, sdkBackend: "claude", toolIntegrationMode: "mcp", prompt: "Synthetic", delegatedAgent: false };
assert.equal((await handler({ sender: { id: 7 } }, payload)).ok, true);
assert.equal(h.turns[0].delegatedAgent, true, "caller cannot turn off delegated protection");
assert.equal((await h.stream({ delegatedAgent: true })).ok, true);
assert.equal(h.turns[1].delegatedAgent, false, "builtin caller cannot opt into the delegated branch");
await assert.rejects(handler({ sender: { id: 99 } }, { ...payload, requestId: `plugin-agent_${tag}_foreign`, delegatedAgent: true }));
assert.equal(h.turns.length, 2, "a spoofed flag cannot replace current sender/turn authority");
});
test("delegated native cancellation is exact-request and cannot mark replacement chat work cancelled", async () => {
const broad = [];
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
@@ -0,0 +1,29 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
test("actual plugin broadcast skips destroyed WebContents while its BrowserWindow is still alive", () => {
// Evaluate the actual registrar callback with narrow window fixtures. This
// models the destruction-event ordering, not an installed Electron crash.
const source = fs.readFileSync(path.join(__dirname, "../main/registerBridges.cjs"), "utf8");
const callbacks = [...source.matchAll(/broadcast: \(channel, payload\) => \{([\s\S]*?)\n \},/g)];
assert.equal(callbacks.length, 1);
const received = [];
const deadWindow = { isDestroyed: () => true, get webContents() { throw Error("Destroyed BrowserWindow getter"); } };
const closingWindow = { isDestroyed: () => false, webContents: {
isDestroyed: () => true, send() { throw Error("Object has been destroyed"); },
} };
const liveWindow = { isDestroyed: () => false, webContents: {
isDestroyed: () => false, send: (...args) => received.push(args),
} };
const broadcast = vm.runInNewContext(`(channel, payload) => {${callbacks[0][1]}\n}`, {
electronModule: { BrowserWindow: { getAllWindows: () => [deadWindow, closingWindow, liveWindow] } },
});
const payload = { host: [], session: [] };
assert.doesNotThrow(() => broadcast("netcatty:plugins:scope-catalog-changed", payload));
assert.deepEqual(received, [["netcatty:plugins:scope-catalog-changed", payload]]);
});
+1 -1
View File
@@ -240,7 +240,7 @@ function createBridgeRegistrar(context) {
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
broadcast: (channel, payload) => {
for (const window of electronModule.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) window.webContents.send(channel, payload);
}
},
});
+42 -4
View File
@@ -36,7 +36,7 @@ class PluginAgentService {
this.clock = options.clock ?? Date.now;
this.backends = new Map(); this.selections = new Map(); this.chats = new Map(); this.turns = new Map();
this.budgets = new Map(); this.pendingOpens = new Set(); this.registrations = []; this.closed = false;
this.pendingSelections = new Map(); this.targetManager = null; this.targetSubscriptions = [];
this.pendingSelections = new Map(); this.startupTurns = new Map(); this.targetManager = null; this.targetSubscriptions = [];
this.eventValidator = createDefinitionValidator("PluginAgentEvent");
this.sessionValidator = createDefinitionValidator("PluginAgentHostSession");
this.scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => this.#guard(ctx),
@@ -127,11 +127,33 @@ class PluginAgentService {
}
registerRpcCapabilities(registry) {
const startupLifetime = async (_context, next) => next();
startupLifetime.prepareAuthorization = (context) => {
if (context.method !== "agent.send") return undefined;
// Registry cleanup runs after its final authority check. Until then the
// caller still owns startup cancellation because no handle was delivered.
return () => {
const turn = this.startupTurns.get(context.permissionOperation);
if (!turn) return;
try {
if (!turn.handoffReady || context.signal.aborted) throw fail("cancelled");
this.#assertNow(turn);
} catch { this.#end(turn); }
turn.detachStartup();
};
};
this.registrations.push(registry.use(startupLifetime));
for (const [operation, paramsType, resultType] of ROUTES) {
const input = createDefinitionValidator(paramsType); const output = createDefinitionValidator(resultType);
this.registrations.push(registry.registerRequest(`agent.${operation}`, async (params, context) => {
const result = await this[operation](params, context); await this.#guard(context);
if (!output(result)) throw fail("internal"); return result;
if (!output(result)) throw fail("internal");
if (operation === "send") {
const turn = this.startupTurns.get(context.permissionOperation);
if (!turn || turn.handle !== result.turnHandle) throw fail("cancelled");
turn.handoffReady = true;
}
return result;
}, { metadata: { capability: "plugin-agent", mutating: !["listBackends", "listChats", "history", "read"].includes(operation), permission: "ai.agent.use" },
validateParams: (params) => { if (!input(params)) throw fail("invalidArgument"); return Object.freeze(structuredClone(params)); },
authorization: async (params, context) => {
@@ -254,6 +276,11 @@ class PluginAgentService {
try { await this.#backendCurrent(turn.chat.backend, ctx); }
catch (error) { this.#end(turn, "unavailable"); throw error; }
return this.scopes.dispatch(turn.ticket, ctx, { capabilityId: spec.capabilityId, targets }, async (execution) => {
this.#assertNow(turn);
// Ask may have waited while the configured backend was edited. Check the
// trusted renderer's current revision again before the selected effect.
try { await this.#backendCurrent(turn.chat.backend, ctx); }
catch (error) { this.#end(turn, "unavailable"); throw error; }
this.#assertNow(turn); const result = await execute(execution); this.#assertNow(turn); return result;
});
}
@@ -267,7 +294,16 @@ class PluginAgentService {
done: false, reading: false, bytes: 0, toolCalls: 0, generation: ++chat.generation, expiresAt: this.clock() + 300_000, hostHandle: null,
policyRevision: this.permission.getPolicyRevision(this.database.getActivePlugin(owner.pluginId)), senderId: this.host.getSenderId() };
chat.activeTurn = turn; this.turns.set(turn.handle, turn);
turn.context = { ...context, signal: controller.signal, assertActive: async () => this.#assertNow(turn) };
const cancelStartup = () => this.#end(turn);
turn.detachStartup = () => {
context.signal?.removeEventListener("abort", cancelStartup);
if (this.startupTurns.get(context.permissionOperation) === turn) this.startupTurns.delete(context.permissionOperation);
};
this.startupTurns.set(context.permissionOperation, turn);
context.signal?.addEventListener("abort", cancelStartup, { once: true });
try {
context.signal?.throwIfAborted();
this.#charge(owner); await this.#backendCurrent(chat.backend, context);
turn.ticket = await this.scopes.begin(chat.scope.handle, context);
turn.ticket.controller.signal.addEventListener("abort", () => this.#end(turn), { once: true });
@@ -330,7 +366,7 @@ class PluginAgentService {
#end(turn, reason = "cancelled") {
if (turn.done) return;
const wasCurrent = turn.chat.activeTurn === turn;
turn.done = true; turn.endEvent = { kind: "end", reason }; clearTimeout(turn.timer); turn.controller.abort();
turn.done = true; turn.detachStartup?.(); turn.endEvent = { kind: "end", reason }; clearTimeout(turn.timer); turn.controller.abort();
if (turn.ticket) this.scopes.finish(turn.ticket);
turn.registration?.dispose();
if (wasCurrent) {
@@ -338,7 +374,9 @@ class PluginAgentService {
try { this.getMcpBridge().applyChatSessionCancelled(turn.chat.record.id, true); } catch {}
}
if (turn.hostHandle) void this.host.requestAgent("agent.release", { hostHandle: turn.hostHandle },
{ ...turn.context, signal: undefined }).catch(() => {});
// Cleanup authenticates the owning source, independently of the ended
// turn or its completed Send request. The host still checks app lifetime.
{ ...turn.context, signal: undefined, assertActive: async () => this.source.assertCurrent(turn.owner) }).catch(() => {});
}
async stop(params, context) { const turn = await this.#turn(params, context); this.#end(turn); return { ok: true }; }
async closeChat(params, context) {
+64 -3
View File
@@ -17,7 +17,7 @@ function fixture(t) {
const manifest = { id: "com.example.agent", publisher: "example", version: "1.0.0", permissions: { required: ["ai.agent.use", "ai.agent.actions"] } };
let identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "r1", runtimeKind: "browser", manifest };
let sourceId = "source-a"; let time = 1; let locked = false; const modes = new Map(); let focus = "a"; let effects = 0;
const audits = []; const decisions = []; const hostRequests = []; const invalidations = new Set();
const audits = []; const decisions = []; const hostRequests = []; const releasedHandles = []; const invalidations = new Set();
const plugin = () => ({ id: manifest.id, enabled: true, activeVersion: manifest.version, manifest,
source: { id: sourceId, kind: "third-party", origin: "https://synthetic.invalid" }, runtime: { status: "running" } });
const database = { getActivePlugin: (id) => id === manifest.id ? plugin() : null,
@@ -39,7 +39,7 @@ function fixture(t) {
const host = { generation: 1, getSenderId: () => 7,
assertActive: async (context) => { await context.assertActive(); if (locked) throw Error("Locked"); },
onDidInvalidate: (callback) => { invalidations.add(callback); return () => invalidations.delete(callback); },
requestAgent: async (kind, payload) => {
requestAgent: async (kind, payload, context) => {
hostRequests.push({ kind, payload: structuredClone(payload) });
if (kind === "agent.backends") return { backends: [
{ hostHandle: backendHandle, backend: "catty", label: "Catty fixture", status: "supported", reason: "ready" },
@@ -50,6 +50,7 @@ function fixture(t) {
if (kind === "agent.targets") return choose();
if (kind === "agent.run") return run(payload);
if (kind === "agent.next") return { done: false, event: events.shift() ?? { kind: "end", reason: "completed" }, session: sessions.get(payload.hostHandle) };
if (kind === "agent.release") { await context.assertActive(); releasedHandles.push(payload.hostHandle); }
return { ok: true };
}, releaseRuntime: () => {}, shutdown: () => {} };
const delegation = new PluginAgentMcpDelegations();
@@ -72,7 +73,7 @@ function fixture(t) {
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, (context) => { context.beforeExecute(); effects++; return "synthetic"; });
};
t.after(() => { service.shutdown(); delegation.shutdown(); permission.shutdown(); fs.rmSync(directory, { recursive: true, force: true }); });
return { service, call, open, dispatch, events, sessions, hostRequests, decisions, audits, host, delegation, targetState,
return { service, call, open, dispatch, events, sessions, hostRequests, releasedHandles, decisions, audits, host, delegation, targetState,
effects: () => effects, focusTo: (id) => { focus = id; }, focus: () => focus, modeTo: (value, permission = "ai.agent.actions") => { modes.set(permission, value); },
approve: (fn) => { approve = fn; }, run: (fn) => { run = fn; }, tick: (ms) => { time += ms; },
choose: (fn) => { choose = fn; }, resourceChanged: () => { for (const callback of resourceListeners) callback(); },
@@ -83,9 +84,12 @@ function fixture(t) {
test("registered Agent routes validate opaque authority, report unsupported modes and preserve core-only native history", async (t) => {
const f = fixture(t); const backends = await f.call("listBackends");
assert.deepEqual(f.hostRequests.map((request) => request.kind), ["agent.backends"], "public backend listing never starts native model discovery");
await assert.rejects(f.call("listBackends", { delegatedAgent: true }));
assert.equal(backends.items.find((entry) => entry.backend === "codex").status, "unsupported");
await assert.rejects(f.call("openChat", { backendRef: randomUUID(), selectionHandle: randomUUID(), permissionMode: "auto" }));
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic conversation" });
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Spoof delegated mode", delegatedAgent: false }));
const [handle, session] = [...f.sessions][0];
session.messages = [{ role: "user", content: "Synthetic conversation" }, { role: "assistant", content: "Synthetic answer" }];
session.externalSessionId = "NATIVE_PRIVATE_SESSION"; f.events.push({ kind: "text", text: "Synthetic answer" });
@@ -118,6 +122,34 @@ test("actual source policy decides once per tool and targets never follow focus
assert.equal(f.effects(), 1);
});
test("backend revision changed during Ask cannot execute the already selected target without any pending event read", async (t) => {
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending approval" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
const entered = deferred(); const resume = deferred();
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
const pending = f.dispatch(turn); await entered.promise;
const checksBeforeApproval = f.hostRequests.filter((item) => item.kind === "agent.current").length;
f.currentTo(false); resume.resolve();
const result = await pending.then(() => "executed", () => "rejected");
assert.equal(f.effects(), 0); assert.equal(result, "rejected");
assert.ok(f.hostRequests.filter((item) => item.kind === "agent.current").length > checksBeforeApproval);
assert.equal(native.signal.aborted, true); assert.equal(internal.controller.signal.aborted, true);
assert.equal(f.decisions.length, 1);
});
test("empty-argument environment Ask carries the canonical host operation and selected-target disclosure", async (t) => {
const f = fixture(t); f.choose(async () => ({ targets: [] })); f.modeTo("ask");
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic environment" });
const internal = f.service.turns.get(turn.turnHandle);
await f.delegation.dispatch("netcatty/getContext", { chatSessionId: internal.chat.record.id },
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, () => ({ hosts: [], hostCount: 0 }));
assert.equal(f.decisions.length, 1);
assert.deepEqual(f.decisions[0].displayContext.action, { capabilityId: "session.environment", text: "{}" });
assert.deepEqual(f.decisions[0].displayContext.selections, []);
});
test("Actions Deny keeps model conversation available with no tools; Agent-use Deny prevents host dispatch", async (t) => {
const f = fixture(t); f.modeTo("deny"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic model-only intent" });
@@ -158,6 +190,35 @@ test("late start/open completion after release cannot publish handles or affect
assert.ok((await f.open()).chatHandle); assert.equal(f.service.chats.size, 1);
});
test("registered send cancellation during trusted startup revokes the exact turn before a handle can be handed off", async (t) => {
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
const caller = new AbortController();
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
const pending = f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic cancelled startup" }, { signal: caller.signal });
const rejected = assert.rejects(pending); await entered.promise;
const internal = [...f.service.turns.values()][0]; const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, internal.handle, () => native.abort());
caller.abort(); resume.resolve(); await rejected;
assert.equal(internal.chat.activeTurn, null); assert.equal(internal.controller.signal.aborted, true);
assert.equal(f.delegation.has(internal.chat.record.id), false); assert.equal(native.signal.aborted, true);
assert.equal(internal.hostHandle, null, "late startup completion cannot publish an orphan handle");
assert.equal(f.service.startupTurns.size, 0);
});
test("completed send detaches request lifetime from its live turn and cannot cancel replacement B", async (t) => {
const f = fixture(t); const chat = await f.open(); const caller = new AbortController();
const first = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic completed handoff" }, { signal: caller.signal });
const a = f.service.turns.get(first.turnHandle); caller.abort();
assert.equal(f.service.startupTurns.size, 0);
assert.equal(a.controller.signal.aborted, false); assert.equal(await f.dispatch(first), "synthetic");
await f.call("stop", { turnHandle: first.turnHandle });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(f.releasedHandles.includes(a.hostHandle), "completed request lifetime cannot block exact host-handle cleanup");
const second = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic replacement B" });
const b = f.service.turns.get(second.turnHandle); caller.abort();
assert.equal(b.controller.signal.aborted, false); assert.equal(await f.dispatch(second), "synthetic");
});
test("worker lifecycle during target confirmation cannot silently bind a replacement connection or leave native work running", async (t) => {
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending work" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
+20 -18
View File
@@ -2,28 +2,30 @@
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
* { box-sizing:border-box; }
html, body { height:100%; overflow:hidden; }
body { margin:0; }
main { max-width:760px; padding:36px; margin:auto; }
header { margin-bottom:26px; }
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
main { max-width:760px; height:100%; min-height:0; padding:12px; margin:auto; display:flex; flex-direction:column; gap:8px; }
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
#model { font-weight:600; }
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:10px; margin:0; background:hsl(var(--muted)); }
.setup { flex:0 1 auto; min-height:0; overflow-y:auto; display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:6px 12px; }
.setup > div { min-width:0; }
.setup .actions, #selection { grid-column:1 / -1; margin:0; }
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:3px; }
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:6px 10px; border-radius:7px; font:14px/1.5 system-ui,sans-serif; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
textarea { resize:vertical; min-height:84px; }
form { margin:22px 0; }
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
textarea { display:block; resize:vertical; height:64px; min-height:64px; max-height:16vh; }
form { flex:none; margin:0; }
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:8px; }
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:6px 12px; font:14px/1.5 system-ui,sans-serif; font-weight:600; cursor:pointer; }
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
button:disabled { opacity:.42; cursor:default; }
#choose-reference { margin-top:10px; }
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
.response { min-height:160px; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
footer { padding-top:8px; }
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
.response { flex:1 0 190px; min-height:190px; display:grid; grid-template-columns:auto minmax(0,1fr); grid-template-rows:minmax(0,1fr) auto; column-gap:12px; overflow:hidden; }
pre { grid-column:1 / -1; min-height:0; overflow-y:auto; overscroll-behavior:contain; white-space:pre-wrap; overflow-wrap:anywhere; font:14px/1.6 system-ui,sans-serif; margin:0; }
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); }
.response .hint { min-width:0; margin:6px 0 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hint:empty { display:none; }
main > #qa { flex:none; margin:0; }
footer { flex:none; margin:0; }
@media(max-width:520px) { .setup { grid-template-columns:minmax(0,1fr); } }
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
+5 -5
View File
@@ -1,8 +1,8 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Global Agent Chat</title><link rel="stylesheet" href="panel.css"></head>
<body><main><header><span class="badge">AGENT PLATFORM EXAMPLE</span><h1>Global Agent Chat</h1><p>A conversation independent of the active terminal or workspace.</p><p id="qa" class="hint"></p></header>
<section class="card"><label for="backend">Configured Agent backend</label><select id="backend" disabled></select><button id="refresh" type="button">Refresh backends</button>
<label for="conversation">Owned conversation</label><select id="conversation"><option value="">New conversation</option></select>
<div class="actions"><button id="targets" type="button">Choose terminals</button><button id="open" type="button" disabled>Open conversation</button></div><p id="selection" class="hint">No targets selected yet.</p></section>
<body><main><p id="qa" class="hint"></p>
<section class="card setup"><div><label for="backend">Configured Agent backend</label><select id="backend" disabled></select></div>
<div><label for="conversation">Owned conversation</label><select id="conversation"><option value="">New conversation</option></select></div>
<div class="actions"><button id="refresh" type="button">Refresh backends</button><button id="targets" type="button">Choose terminals</button><button id="open" type="button" disabled>Open conversation</button></div><p id="selection" class="hint">No targets selected yet.</p></section>
<section class="card response"><pre id="transcript" aria-live="polite"></pre><p id="usage" class="hint"></p><p id="activity" class="hint"></p></section>
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" maxlength="16384" placeholder="Ask about the selected terminals"></textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><span id="status" role="status">Loading configured backends…</span></div></form>
<footer>Core owns Agent execution and conversation history. Actions follow this plugin's Allow / Ask / Deny setting. Only Catty and Claude Code MCP mode are eligible in this version; other native modes are unavailable here.</footer></main><script src="panel.js"></script></body></html>
<footer>Actions follow this plugin's Allow / Ask / Deny setting.</footer></main><script src="panel.js"></script></body></html>
+1
View File
@@ -52,6 +52,7 @@ element('open').addEventListener('click', async () => {
chat = result; const history = await invoke('history', { chatHandle: chat.chatHandle });
if (closed || run !== generation) return;
element('transcript').textContent = history.items.map((item) => `${item.role}: ${item.text}`).join('\n\n');
element('usage').textContent = ''; element('activity').textContent = '';
status(`Ready · ${result.backendLabel}`);
} catch { await closeChat(); if (!closed && run === generation) status('Conversation unavailable. Refresh backends or choose terminals again; your draft is kept.'); }
finally { if (!closed && run === generation) controls(false); }
+15 -3
View File
@@ -3,9 +3,9 @@
const test = require("node:test"); const assert = require("node:assert/strict");
const fs = require("node:fs"); const path = require("node:path"); const { JSDOM } = require("jsdom");
test("global Chat sample follows host theme and preserves drafts on explicit target expiry without re-sharing or retrying", async (t) => {
test("global Chat sample follows host theme, preserves expiry drafts and clears status on conversation changes", async (t) => {
const dom = new JSDOM(fs.readFileSync(path.join(__dirname, "panel.html"), "utf8"), { runScripts: "outside-only" });
let now = 1; let environment; let selections = 0; let sends = 0; let disposed = 0;
let now = 1; let environment; let selections = 0; let sends = 0; let disposed = 0; let history = [];
const calls = [];
dom.window.Date.now = () => now;
Object.defineProperty(dom.window, "netcattyView", { value: {
@@ -13,7 +13,8 @@ test("global Chat sample follows host theme and preserves drafts on explicit tar
executeCommand: async (name) => {
const op = name.split(".").at(-1); calls.push(op);
if (op === "backends") return { items: [{ backendRef: "host-ref", label: "Synthetic configured Catty", status: "supported", reason: "ready" }] };
if (op === "chats" || op === "history") return { items: [] };
if (op === "chats") return { items: [] };
if (op === "history") return { items: history };
if (op === "targets") { selections++; return { selectionHandle: "selection", expiresAt: now + 300_000, items: [{ title: "Synthetic terminal" }] }; }
if (op === "open") return { chatHandle: "chat", backendLabel: "Synthetic Catty", expiresAt: now + 300_000 };
if (op === "send") { sends++; throw Error("Synthetic denied send"); }
@@ -38,4 +39,15 @@ test("global Chat sample follows host theme and preserves drafts on explicit tar
send(); await settle(); assert.equal(sends, 1); assert.equal(selections, 2); assert.match(element("status").textContent, /Request ended/);
await settle(); assert.equal(sends, 1, "a failed inference is never automatically retried");
assert.equal(element("prompt").value, "Keep this synthetic draft"); assert.ok(calls.includes("history")); assert.equal(disposed, 0);
element("usage").textContent = "Previous conversation usage"; element("activity").textContent = "Previous conversation activity";
element("open").click(); await settle();
assert.equal(element("transcript").textContent, ""); assert.equal(element("usage").textContent, ""); assert.equal(element("activity").textContent, "");
history = [{ role: "assistant", text: "Owned synthetic history" }];
const owned = dom.window.document.createElement("option"); owned.value = "owned-conversation"; element("conversation").append(owned);
element("usage").textContent = "Another conversation usage"; element("activity").textContent = "Another conversation activity";
element("conversation").value = owned.value; element("conversation").dispatchEvent(new dom.window.Event("change")); await settle();
element("open").click(); await settle();
assert.match(element("transcript").textContent, /Owned synthetic history/);
assert.equal(element("usage").textContent, ""); assert.equal(element("activity").textContent, "");
assert.equal(element("prompt").value, "Keep this synthetic draft"); assert.equal(sends, 1);
});
@@ -18,11 +18,11 @@ function fixture(t: TestContext, driver: TurnDriver, prepare = async () => {}) {
prepareModel: prepare, getExternal: () => [] });
const runtime = createTrustedPluginAgentRuntime({ isActive: () => true, chooseTargets: async () => [], backends, runtime: new AgentRuntime({ drivers: [driver] }) });
const session = { conversationRef: crypto.randomUUID(), id: `plugin-agent:${crypto.randomUUID()}`, agentId: 'catty', title: 'Synthetic', scope: { type: 'global' }, messages: [], createdAt: 1, updatedAt: 1 };
const call = (kind: string, payload: unknown, requestId: string = crypto.randomUUID(), runtimeId = 'r1'): Promise<PluginAgentHostResult> => runtime.handle({ requestId, kind, payload, runtimeId, actionToken: 'fixture' } as PluginAgentHostRequest, new AbortController().signal);
const call = (kind: string, payload: unknown, requestId: string = crypto.randomUUID(), runtimeId = 'r1', signal = new AbortController().signal): Promise<PluginAgentHostResult> => runtime.handle({ requestId, kind, payload, runtimeId, actionToken: 'fixture' } as PluginAgentHostRequest, signal);
t.after(() => { runtime.shutdown(); if (old) Object.defineProperty(globalThis, 'window', old); else Reflect.deleteProperty(globalThis, 'window'); });
const start = async (requestId?: string) => {
const start = async (requestId?: string, signal?: AbortSignal) => {
const result = await call('agent.backends', {}); assert.ok('backends' in result);
return call('agent.run', { session, backendHandle: result.backends[0].hostHandle, terminalSessions: [], text: 'Synthetic user intent', maxIterations: 12, delegationTag: crypto.randomUUID(), capabilities: [] }, requestId);
return call('agent.run', { session, backendHandle: result.backends[0].hostHandle, terminalSessions: [], text: 'Synthetic user intent', maxIterations: 12, delegationTag: crypto.randomUUID(), capabilities: [] }, requestId, 'r1', signal);
};
return { runtime, call, start, modelTo: (value: string) => { revision = value; } };
}
@@ -52,6 +52,25 @@ test('trusted runtime reuses actual AgentRuntime and projects bounded events wit
await assert.rejects(f.call('agent.next', started, undefined, 'foreign-runtime'));
});
test('plugin backend listing and current checks never invoke Claude native model discovery or repair', async (t) => {
const { getDriver } = createRequire(import.meta.url)('../../../electron/bridges/aiBridge/sdk/index.cjs');
let discoveryCalls = 0;
t.mock.method(getDriver('claude'), 'listModels', async () => { discoveryCalls++; throw Error('Native discovery must not run'); });
const backends = new ConfiguredPluginAgentBackends({ getModel: () => null,
getExternal: () => [{ id: 'selected-claude', name: 'Synthetic Claude', command: '/synthetic/official-claude', enabled: true, sdkBackend: 'claude',
env: { CLAUDE_CONFIG_DIR: '/synthetic/selected-profile', ANTHROPIC_API_KEY: 'FIXTURE_ONLY_MARKER' } }],
getExternalModel: () => 'synthetic-model' });
const host = createTrustedPluginAgentRuntime({ isActive: () => true, chooseTargets: async () => [], backends, runtime: new AgentRuntime({ drivers: [] }) });
t.after(() => host.shutdown());
const request = { requestId: crypto.randomUUID(), runtimeId: 'r1', actionToken: 'fixture', kind: 'agent.backends', payload: {} } as const;
const result = await host.handle(request, new AbortController().signal); assert.ok('backends' in result);
const selected = result.backends.find((entry) => entry.backend === 'claude')!; assert.equal(selected.status, 'supported');
await backends.resolve(selected.hostHandle, 'r1').prepare();
const current = await host.handle({ ...request, kind: 'agent.current', payload: { hostHandle: selected.hostHandle } }, new AbortController().signal);
assert.deepEqual(current, { current: true }); assert.equal(discoveryCalls, 0);
assert.doesNotMatch(JSON.stringify(result), /FIXTURE_ONLY_MARKER|CLAUDE_CONFIG_DIR|selected-profile|official-claude/);
});
test('cancelled start replies and runtime release abort exact turns, dropping late events', async (t) => {
const entered = deferred(); const resume = deferred(); let signal: AbortSignal | undefined;
const f = fixture(t, { backend: 'catty', run: async (_input, context) => { signal = context.signal; entered.resolve(); await resume.promise; context.emit({ id: 'late', type: 'model_delta', text: 'LATE_TEXT' } as Parameters<typeof context.emit>[0]); } });
@@ -76,3 +95,32 @@ test('host configuration changes abort a pending existing runtime turn and suppr
resume.resolve(); const value = await f.call('agent.next', started); assert.ok('event' in value);
assert.deepEqual(value.event, { kind: 'end', reason: 'unavailable' });
});
test('Stop then Send waits for the actual runtime driver to unwind before starting replacement B', async (t) => {
const resume = deferred(); t.after(resume.resolve); let starts = 0;
const f = fixture(t, { backend: 'catty', run: async (_input, context) => {
if (++starts === 1) await resume.promise;
context.emit({ id: 'delta', type: 'model_delta', text: starts === 1 ? 'LATE_A' : 'Replacement B' } as Parameters<typeof context.emit>[0]);
} });
const first = await f.start(); assert.ok('hostHandle' in first);
await f.call('agent.release', first);
let published = false; const pending = f.start().then((value) => { published = true; return value; });
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(published, false, 'replacement handle must not precede runtime availability');
assert.equal(starts, 1); resume.resolve();
const second = await pending; assert.ok('hostHandle' in second);
const next = await f.call('agent.next', second); assert.ok('event' in next);
assert.deepEqual(next.event, { kind: 'text', text: 'Replacement B' }); assert.equal(starts, 2);
});
for (const change of ['cancel', 'release', 'configuration'] as const) test(`replacement waiting for old runtime revalidates ${change} before starting`, async (t) => {
const resume = deferred(); t.after(resume.resolve); let starts = 0;
const f = fixture(t, { backend: 'catty', run: async () => { starts++; await resume.promise; } });
const first = await f.start(); await f.call('agent.release', first);
const caller = new AbortController(); const pending = f.start(undefined, caller.signal); const rejected = assert.rejects(pending);
await new Promise<void>((resolve) => setImmediate(resolve));
if (change === 'cancel') caller.abort();
else if (change === 'release') f.runtime.releaseRuntime('r1');
else f.modelTo('2');
resume.resolve(); await rejected; assert.equal(starts, 1);
});
@@ -112,6 +112,12 @@ export function createTrustedPluginAgentRuntime(options: {
await backend.prepare(); active(); signal.throwIfAborted();
if (backends.resolve(payload.backendHandle, runtimeId) !== backend) throw Error('Selected Agent configuration changed');
if ([...turns.values()].some((entry) => entry.session.id === payload.session.id && !entry.done)) throw Error('Agent chat is busy');
// Stop acknowledges cancellation before an asynchronous driver has
// necessarily unwound. Reuse the runtime's existing session barrier.
await runtime.waitForActiveTurn(payload.session.id);
active(); signal.throwIfAborted();
if (backends.resolve(payload.backendHandle, runtimeId) !== backend) throw Error('Selected Agent configuration changed');
if ([...turns.values()].some((entry) => entry.session.id === payload.session.id && !entry.done)) throw Error('Agent chat is busy');
const session = structuredClone(payload.session);
const prior = structuredClone(session) as unknown as AISession;
const assistantMsgId = crypto.randomUUID();
+1
View File
@@ -70,6 +70,7 @@
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
"test:plugin-global-agent-layout": "electron scripts/plugin-global-agent-layout.live.test.cjs",
"test:xterm-image-resize": "electron scripts/xterm-image-resize.live.test.cjs",
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
@@ -0,0 +1,71 @@
"use strict";
/* global process, __dirname, clearTimeout, setTimeout, console */
// Render only the sample's HTML/CSS with synthetic text. No plugin, host bridge,
// provider, account configuration or Agent runtime is started by this test.
if (!process.versions.electron) {
require("node:test")("global Agent sample preserves transcript and composer space", {
skip: "run npm run test:plugin-global-agent-layout in the scheduled native QA environment",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow, session } = require("electron");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const sample = path.join(__dirname, "../examples/plugins/global-agent");
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("agent-layout-test")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const finish = (code) => {
clearTimeout(watchdog);
if (win && !win.isDestroyed()) win.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
const watchdog = setTimeout(() => { console.error("AGENT_LAYOUT_FAIL timeout"); finish(1); }, 15_000);
void app.whenReady().then(async () => {
// Inline the real stylesheet and omit panel.js: even a broken layout has no
// path to execute plugin commands or initiate model/credential requests.
const html = fs.readFileSync(path.join(sample, "panel.html"), "utf8")
.replace('<link rel="stylesheet" href="panel.css">', `<style>${fs.readFileSync(path.join(sample, "panel.css"), "utf8")}</style>`)
.replace('<script src="panel.js"></script>', "")
.replace("<head>", '<head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'">');
const isolatedSession = session.fromPartition("agent-layout-synthetic");
isolatedSession.webRequest.onBeforeRequest({ urls: ["http://*/*", "https://*/*"] }, (_details, callback) => callback({ cancel: true }));
win = new BrowserWindow({ show: false, useContentSize: true, width: 798, height: 548,
webPreferences: { session: isolatedSession, sandbox: true, contextIsolation: true, nodeIntegration: false } });
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
for (const qa of [false, true]) {
const actual = await win.webContents.executeJavaScript(`(() => {
const element = id => document.getElementById(id);
element('qa').textContent = ${JSON.stringify(qa ? "Isolated deterministic fixture · no real model inference or paid API" : "")};
element('transcript').textContent = 'assistant: ' + 'Synthetic owned conversation. '.repeat(100);
element('usage').textContent = 'Input 12 · Output 6';
element('activity').textContent = 'session.environment';
element('status').textContent = 'Complete';
element('stop').disabled = false;
element('backend').innerHTML = '<option>Synthetic configured Catty</option>';
element('conversation').innerHTML = '<option>Owned synthetic conversation</option>';
const bounds = node => { const r = node.getBoundingClientRect(); return { top:r.top, bottom:r.bottom, height:r.height, width:r.width }; };
const transcript = element('transcript');
const setup = document.querySelector('.setup') || element('backend').closest('section');
return { width:innerWidth, height:innerHeight, transcript:bounds(transcript),
transcriptClient:transcript.clientHeight, transcriptScroll:transcript.scrollHeight,
response:bounds(transcript.closest('section')), compose:bounds(element('compose')), stop:bounds(element('stop')),
setup:bounds(setup), setupOverflow:getComputedStyle(setup).overflowY };
})()`);
const detail = JSON.stringify({ qa, ...actual });
assert.equal(actual.width, 798, detail); assert.equal(actual.height, 548, detail);
assert.ok(actual.response.height >= 180, `usable response card: ${detail}`);
assert.ok(actual.transcriptClient >= 120, `visible conversation text: ${detail}`);
assert.ok(actual.transcriptScroll > actual.transcriptClient, `transcript scrolls independently: ${detail}`);
assert.ok(actual.compose.top >= actual.response.bottom - 1, `composer follows transcript: ${detail}`);
assert.ok(actual.stop.bottom <= actual.height && actual.stop.height >= 30, `mouse Stop stays visible: ${detail}`);
assert.ok(actual.setup.height >= 100 && actual.setupOverflow === "auto", `configuration stays reachable: ${detail}`);
console.log(`AGENT_LAYOUT_PASS ${detail}`);
}
finish(0);
}).catch((error) => { console.error("AGENT_LAYOUT_FAIL", error); finish(1); });
}