mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 13:02:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25e0c834da | ||
|
|
f8616f5d3a | ||
|
|
0cab1046cf | ||
|
|
a56d1ab785 | ||
|
|
cbe89995e6 | ||
|
|
72383a50c9 | ||
|
|
8bcad28f9d | ||
|
|
59edc87ed4 | ||
|
|
5944a02238 | ||
|
|
b17713ef95 | ||
|
|
5db0f90751 |
No files matched your search
@@ -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, []);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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); });
|
||||
}
|
||||
Reference in new issue
Block a user