Compare commits

...
Author SHA1 Message Date
bincxz 4c0b02d29d fix(scripts): let explicit user scripts run in observer mode
Observer mode stays on AI-started scripts that write to the terminal. Compose bar, connect, and panel runs are explicit user actions and no longer read that setting.
2026-09-29 20:16:40 +08:00
4 changed files with 107 additions and 2 deletions

No files matched your search

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runAutomationScript,
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
@@ -8,6 +9,7 @@ import {
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
@@ -246,3 +248,103 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
function installPermissionMode(mode: string) {
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
}
const writeScript: Snippet = {
id: 'write-script',
label: 'Write',
command: "await nct.screen.sendLine('echo hi');",
kind: 'script',
};
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let seenMode: string | undefined;
netcattyBridge.get = () => ({
scriptRun: async (params) => {
seenMode = params.permissionMode;
return { runId: 'user-run', runIds: ['user-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
const result = await runAutomationScript({
snippet: writeScript,
sessionId: 'sess-user',
});
assert.equal(result.runId, 'user-run');
assert.equal(seenMode, 'auto');
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript blocks an AI write script in observer mode', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let called = false;
netcattyBridge.get = () => ({
scriptRun: async () => {
called = true;
return { runId: 'ai-run', runIds: ['ai-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runAutomationScript({
snippet: writeScript,
sessionId: 'sess-ai',
initiatedBy: 'ai',
}),
/Observer mode blocks scripts that write to the terminal/,
);
assert.equal(called, false);
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let seenMode: string | undefined;
netcattyBridge.get = () => ({
scriptRun: async (params) => {
seenMode = params.permissionMode;
return { runId: 'ai-read', runIds: ['ai-read'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await runAutomationScript({
snippet: {
id: 'read-script',
label: 'Read',
command: "await nct.screen.waitForPrompt(1000);",
kind: 'script',
},
sessionId: 'sess-ai-read',
initiatedBy: 'ai',
});
assert.equal(seenMode, 'observer');
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
hostname?: string;
username?: string;
};
/** AI-initiated runs honor Observer. User runs are an explicit action and always execute. */
initiatedBy?: 'user' | 'ai';
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = readPermissionMode();
const permissionMode = params.initiatedBy === 'ai' ? readPermissionMode() : 'auto';
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
+1 -1
View File
@@ -30,7 +30,7 @@ await nct.dialog.alert('Netcatty script smoke test OK');
/** Full integration test for onConnect / manual run; dialog API enabled by default. */
export const SCRIPT_INTEGRATION_TEST = `// Netcatty Integration Test — onConnect / manual full API exercise
// Trigger: onConnect or Run now | Permission: Auto or Confirm (dialogs need non-Observer)
// Trigger: onConnect or Run now
const CONFIG = {
SAMPLE_COUNT: 8,
@@ -424,6 +424,7 @@ async function executeSnippetOrScriptRun(
snippet,
sessionId,
sessionMeta,
initiatedBy: 'ai',
});
if (!wait) {
return { ok: true, sessionId, snippetId: snippet.id, runId, kind: 'script' };