mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 11:22:54 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdc97782e |
No files matched your search
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginLogs() {
|
||||
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
|
||||
: action === 'open' ? bridge.openPluginLogFolder?.()
|
||||
: action === 'export' ? bridge.exportPluginLogs?.()
|
||||
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
|
||||
if (!result) throw new Error('Local plugin logging is unavailable');
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (cause) {
|
||||
let message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (action === 'configure') {
|
||||
// Configuration fails closed before disk I/O. Show actual host state,
|
||||
// even when persisting a setting failed, rather than keeping stale ON.
|
||||
try {
|
||||
const actual = await netcattyBridge.require().getPluginLogStatus?.();
|
||||
if (!actual) throw new Error('Local plugin log status is unavailable');
|
||||
if (mounted.current) setStatus(actual);
|
||||
} catch (refreshError) {
|
||||
if (mounted.current) setStatus(null);
|
||||
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
|
||||
}
|
||||
}
|
||||
if (mounted.current) setError(message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void run('status');
|
||||
return () => { mounted.current = false; };
|
||||
}, [run]);
|
||||
return { status, error, busy, run };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { compile } from '@tailwindcss/node';
|
||||
import postcss from 'postcss';
|
||||
import HostDetailsPanel from './HostDetailsPanel';
|
||||
import { Card } from './ui/card';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
|
||||
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
|
||||
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
|
||||
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
|
||||
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
|
||||
<Card id="ordinary-card">Outside the security surface</Card>
|
||||
</TooltipProvider></I18nProvider>);
|
||||
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
|
||||
try {
|
||||
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
|
||||
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
|
||||
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
|
||||
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
|
||||
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
|
||||
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
|
||||
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
|
||||
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
|
||||
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
|
||||
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
|
||||
const css = compiler.build(classes);
|
||||
const parsed = postcss.parse(css);
|
||||
const rules: postcss.Rule[] = [];
|
||||
parsed.walkRules(rule => { rules.push(rule); });
|
||||
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
|
||||
assert.ok(rule && rule.type === 'rule');
|
||||
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
|
||||
assert.ok(matches.includes(panel));
|
||||
assert.ok(matches.includes(permissions));
|
||||
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
|
||||
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
|
||||
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
|
||||
const declaration = declarations.get(`--color-${name}`);
|
||||
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
|
||||
assert.equal(declaration.important, true);
|
||||
}
|
||||
for (const name of ['card','background','primary']) {
|
||||
const utility = rules.find(node => node.selector === `.bg-${name}`);
|
||||
assert.ok(utility && utility.type === 'rule');
|
||||
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
|
||||
}
|
||||
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
|
||||
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'Event', 'MouseEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginLogSettings } = await import('./PluginLogSettings');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
|
||||
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
|
||||
const actions: string[] = [];
|
||||
let failConfigure = false;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginLogStatus() { return status; },
|
||||
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
|
||||
async openPluginLogFolder() { actions.push('open'); return status; },
|
||||
async exportPluginLogs() { actions.push('export'); return status; },
|
||||
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
|
||||
} });
|
||||
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
|
||||
const checkboxes = () => [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
|
||||
try {
|
||||
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
|
||||
assert.equal(checkboxes()[0].checked, false);
|
||||
assert.equal(checkboxes()[0].disabled, false, 'StrictMode initialization completes');
|
||||
assert.equal(checkboxes()[1].disabled, true);
|
||||
assert.match(document.body.textContent!, /no upload or sync/);
|
||||
assert.match(document.body.textContent!, /Dropped: 2/);
|
||||
await act(async () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
|
||||
await act(async () => checkboxes()[1].click());
|
||||
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
|
||||
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
|
||||
await act(async () => button('Enable temporarily').click());
|
||||
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
|
||||
await act(async () => button('Open folder').click());
|
||||
await act(async () => button('Export').click());
|
||||
await act(async () => button('Clear logs…').click());
|
||||
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
|
||||
failConfigure = true;
|
||||
await act(async () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
|
||||
assert.equal(checkboxes()[0].checked, false, 'failed configuration refreshes actual OFF host state');
|
||||
assert.equal(checkboxes()[1].checked, false);
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePluginLogs } from '../../../application/state/usePluginLogs';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ConfirmDialog } from '../../ui/confirm-dialog';
|
||||
|
||||
/** Embed in the existing plugin settings surface, without a new marketplace tab. */
|
||||
export function PluginLogSettings() {
|
||||
const { resolvedLocale } = useI18n();
|
||||
const zh = resolvedLocale.startsWith('zh');
|
||||
const { status, error, busy, run } = usePluginLogs();
|
||||
const [confirmVerbose, setConfirmVerbose] = useState(false);
|
||||
const disclosure = zh
|
||||
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
|
||||
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
|
||||
return <section data-host-security-surface="" className="space-y-3 rounded-lg border border-border/70 p-4 text-sm" aria-label={zh ? '本地插件日志' : 'Local plugin logs'}>
|
||||
<h4 className="font-medium">{zh ? '本地插件日志' : 'Local plugin logs'}</h4>
|
||||
<p className="text-xs text-muted-foreground">{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</p>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(event) => void run('configure', { saveToDisk: event.target.checked, verbose: false })} />{zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally'}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(event) => event.target.checked ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />{zh ? '包含插件详细诊断文字(可能含敏感数据)' : 'Include verbose plugin text (may contain sensitive data)'}</label>
|
||||
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
|
||||
</div>
|
||||
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
|
||||
</section>;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { PluginMarketplace } from './PluginMarketplace';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import { PluginLogSettings } from './PluginLogSettings';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
@@ -401,7 +400,6 @@ export default function SettingsPluginsTab() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
|
||||
<PluginLogSettings />
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Local logging validation
|
||||
|
||||
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
All data used in these checks is synthetic and each persistence test creates its
|
||||
own temporary profile. No real logs or credentials were opened.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
|
||||
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
|
||||
| `npm run build` | Passed; existing large-chunk warning |
|
||||
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
|
||||
| `npm run check:plugin-contract` | Passed, no generated drift |
|
||||
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
|
||||
| `git diff --check` | Passed |
|
||||
|
||||
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
|
||||
field exclusion, host source attribution, unknown-source fallback, URL rejection,
|
||||
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
|
||||
limits, rotation, expiration, drop/error counts, export, clear confirmation,
|
||||
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
|
||||
regressions cover direct paths, symlink aliases, a symlinked profile directory,
|
||||
and revocation during the final protected-storage check.
|
||||
|
||||
## Remaining native checks
|
||||
|
||||
Electron launch failed before application/profile startup. The executable at
|
||||
`node_modules/electron/dist/electron --version` aborted with:
|
||||
|
||||
> The SUID sandbox helper binary was found, but is not configured correctly.
|
||||
|
||||
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
|
||||
switch, privilege escalation, permission repair, or native retry followed. Native
|
||||
smoke execution, screenshots, and a short recording remain pending. The acceptance
|
||||
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
|
||||
`npm run pack:dir` completed its production build, then failed during serialport
|
||||
native rebuilding: node-gyp returned ENOENT for mkdir
|
||||
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
|
||||
A native package was not produced.
|
||||
|
||||
## Parent integration
|
||||
|
||||
The only shared settings-page change is one import and one `<PluginLogSettings />`
|
||||
element. Preserve that element when integrating the marketplace task's page.
|
||||
There are no Installed, Official, Third-party, source-database, or unrelated host
|
||||
data changes.
|
||||
|
||||
The default host-owned resolver is wired through `createPluginHostService`. It
|
||||
consumes #3981's confirmed API without editing its database or sync implementation.
|
||||
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
|
||||
transport-registration change is needed beyond the existing logging registration.
|
||||
|
||||
## Source and review follow-up
|
||||
|
||||
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
|
||||
the logging backend overlay. Its logging/source/host regression command passed
|
||||
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
|
||||
shutdown-drain case. The product branch does not import marketplace changes or bump its database
|
||||
schema.
|
||||
|
||||
Independent review of the implementation follow-up found no remaining blocking
|
||||
findings. Independent runs passed 50 focused regressions with one expected source
|
||||
skip, 20 real-source integration regressions with no skips, and the logging UI
|
||||
interaction test. The reviewer made no edits and did not launch Electron. A further independent
|
||||
review of the newer encoded-local-owner contract found no issues; all six
|
||||
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
|
||||
with an additional probe confirming the local encoded owner.
|
||||
|
||||
Fixed review findings have deterministic regressions: 31 days of low-volume
|
||||
appends and export cannot extend segment retention via mtime; stale sources cannot
|
||||
rotate away existing evidence; settings-write failure shows actual host state;
|
||||
log draining still completes when another shutdown stage rejects; and activation
|
||||
permission denial produces a fixed host record before runtime creation.
|
||||
|
||||
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
|
||||
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
|
||||
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
|
||||
Those runs do not validate the follow-up head. Its exact-head CI is reported in
|
||||
the PR after pushing.
|
||||
|
||||
The native fixture build is reproducible: 3,270 bytes, SHA-256
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
Native screenshots and recording remain assigned to the parent's working cloud
|
||||
GUI machine. No local sandbox bypass or packaging retry is authorized by this
|
||||
handoff.
|
||||
@@ -1,178 +0,0 @@
|
||||
# Local plugin logging
|
||||
|
||||
Local file persistence defaults to off. The existing permission panel keeps its
|
||||
bounded security history independently. Changing or clearing local log files does
|
||||
not clear that history, grant permissions, or change a plugin's source.
|
||||
|
||||
## Saved records
|
||||
|
||||
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
|
||||
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
|
||||
They stay on the device. No cloud upload or sync path includes these files or the
|
||||
local preference at `<userData>/plugins/local-logging.json`.
|
||||
|
||||
Host audit records contain these facts:
|
||||
|
||||
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
|
||||
- Source ID, source kind, and namespace from an injected host provenance resolver.
|
||||
Missing provenance is `null`. Manifest claims never imply an official source.
|
||||
- Registered host RPC operation, authorization outcome, result, numeric error code,
|
||||
elapsed milliseconds, and available input/output `data` field byte counts.
|
||||
- Target identifiers from host authorization descriptors, replaced with a keyed
|
||||
SHA-256 digest. The random key stays in memory, so target correlation lasts only
|
||||
for the current app process.
|
||||
- Cumulative dropped-record and write-error counts at enqueue time.
|
||||
|
||||
Runtime start, running, stop, failure, protocol rejection, and permission revocation
|
||||
produce separate host facts. Required and advanced activation permission failures
|
||||
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
|
||||
validation or authorization fails. Authorization has separate `allowed`, `denied`,
|
||||
`not-required`, and `not-reached` states. A handler error after approval preserves
|
||||
`allowed` and records the failed result. Unknown values remain explicit.
|
||||
|
||||
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
|
||||
binary `data` field. They are not total wire traffic or decoded base64 size.
|
||||
Missing measurements are `null`. Streaming traffic outside these calls does not
|
||||
become a complete per-byte trace. Host facts describe what the host observed;
|
||||
plugin-returned success cannot prove an external side effect occurred.
|
||||
|
||||
Host audit records never copy request bodies, response bodies, error messages,
|
||||
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
|
||||
content. Error codes come from the host RPC error set. No audit field accepts
|
||||
plugin-supplied source identity.
|
||||
|
||||
## Optional plugin text
|
||||
|
||||
Verbose diagnostics require a separate confirmation and turn off after app
|
||||
restart. Console messages, utility stdout/stderr, and `logger.write` text use
|
||||
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
|
||||
adds identity outside the nested `diagnostic` object. Plugin fields named
|
||||
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
|
||||
|
||||
Text and structured fields have traversal, length, depth and total-record limits.
|
||||
Control characters are removed and known sensitive field names are replaced.
|
||||
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
|
||||
under an unexpected key or inside a sentence. Review files before sharing them.
|
||||
No plugin text is persisted when verbose diagnostics are off.
|
||||
|
||||
## Limits and lifecycle
|
||||
|
||||
The shared sink retains at most three files of 1 MiB each, for at most seven days.
|
||||
Expiry uses the first host timestamp in each segment, read within the existing
|
||||
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
|
||||
the same cutoff. A segment is removed when its first record expires; capacity
|
||||
rotation or segment expiry can remove later records sooner. Retention runs on
|
||||
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
|
||||
The asynchronous queue accepts at most 256 KiB and records drops when full.
|
||||
Plugin text can occupy only a quarter of that queue, leaving capacity for host
|
||||
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
|
||||
records per minute, with a 240-record initial burst), including console output. Each
|
||||
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
|
||||
both drop and error counts and never fail the plugin operation.
|
||||
|
||||
Turning saving off stops ingress, discards queued records that have not started,
|
||||
and waits for any current write. Runtime cleanup closes its diagnostic logger.
|
||||
App shutdown drains accepted writes through the existing manager shutdown path,
|
||||
including when another cleanup stage rejects. A failed settings write refreshes
|
||||
the checkbox from actual host state while retaining the original error.
|
||||
Clearing pauses ingress, discards queued records, waits for a current write and
|
||||
removes only the sink's known files. Cancellation leaves them intact. Export takes
|
||||
a bounded snapshot on the write queue and uses the native Save dialog.
|
||||
|
||||
Files use mode 0600 and the directory uses 0700 on systems that support them.
|
||||
The host filesystem broker rejects writes to the audit directory and preferences,
|
||||
including canonical paths reached through symlinks. Plugin views cannot invoke
|
||||
log management IPC. No plugin API exposes audit mutation. These local files are
|
||||
not a tamper-evident ledger against the OS account or the development-only utility
|
||||
runtime, which already has full Node and filesystem authority.
|
||||
|
||||
## Integration boundaries
|
||||
|
||||
`PluginLogSettings` is a standalone compact component, mounted by a single import
|
||||
and element in `SettingsPluginsTab`. It adds no marketplace tab and changes no
|
||||
Installed, Official, Third-party, or source-database implementation. When merging
|
||||
with the marketplace branch, preserve this element in the host settings surface.
|
||||
|
||||
`createPluginHostService` now injects the host-owned
|
||||
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
|
||||
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
|
||||
|
||||
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
|
||||
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
|
||||
- `privateOwner(pluginId)` supplies the opaque namespace.
|
||||
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
|
||||
|
||||
The adapter receives host activation identity only, after the existing caller
|
||||
guards. It never accepts source or owner fields from plugin request parameters.
|
||||
Missing APIs, an absent installation, changed version/hash, a retained-source
|
||||
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
|
||||
bare-plugin-ID fallback. The current host API encodes every owner, including
|
||||
local imports; a bare ID returned as an owner remains unknown.
|
||||
|
||||
Each logger snapshots the source binding and supplies a host-only `isCurrent`
|
||||
check. The sink checks it at enqueue, after asynchronous preflight, before
|
||||
rotation mutations and immediately before appending. Records from a replaced or
|
||||
uninstalled source are dropped and counted. They are never relabelled as the new
|
||||
source. Already saved records keep their original identity.
|
||||
|
||||
The optional `resolvePluginProvenance` override remains host-owned. Its result
|
||||
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
|
||||
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
|
||||
periods, underscores, colons and hyphens. Opaque namespaces accept the same
|
||||
characters up to 256 bytes. URLs, display names and plugin claims are not source
|
||||
identity. `official` describes the host-bound source, not official maintenance.
|
||||
|
||||
The source database, marketplace page and sync implementation are unchanged by
|
||||
this adapter. It also needs no new shared host-registration change for transport.
|
||||
|
||||
## Native QA with synthetic data
|
||||
|
||||
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
|
||||
synthetic fixture below. Do not retry the blocked local Electron launch.
|
||||
Do not open real logs or credentials. Capture screenshots of the settings card,
|
||||
verbose disclosure, and native Clear confirmation. Record a short flow:
|
||||
|
||||
1. Open Plugins settings. Confirm both options are off and no host-v1 files exist.
|
||||
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
|
||||
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
|
||||
host identity, outcomes, target digests, and absence of synthetic secret bodies.
|
||||
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
|
||||
the disclosure, write synthetic text, and verify the unverified record type.
|
||||
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
|
||||
app and verify that local saving remains selected but verbose text is off.
|
||||
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
|
||||
panel's separate security history remains. Turn local saving off.
|
||||
|
||||
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
|
||||
|
||||
```sh
|
||||
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
|
||||
```
|
||||
|
||||
Package: 3,270 bytes. SHA-256:
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
It declares only `commands` and `menus`; its command-palette entries are under
|
||||
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
|
||||
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
|
||||
to exercise drop accounting. **Test undeclared network denial** must return denial
|
||||
without a prompt or network I/O because the manifest declares no network access.
|
||||
No request body or credential is supplied. Do not grant additional permissions.
|
||||
|
||||
Capture the compact settings card, verbose disclosure and native Clear dialog,
|
||||
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
|
||||
build, a normal local import must log the source and opaque owner returned by its
|
||||
host database. On the older base, both stay explicitly unknown. Do not forge an
|
||||
Official source through fixture metadata. The real-source replacement case is
|
||||
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
|
||||
integration worktree; it uses only temporary synthetic database state.
|
||||
|
||||
The execution environment for this change could not launch Electron: its SUID
|
||||
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
|
||||
any profile. No sandbox-disable retry or system-permission change was attempted.
|
||||
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
|
||||
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
|
||||
system-directory change or packaging retry was attempted.
|
||||
|
||||
Native screenshots and recording therefore remain pending in the parent desktop
|
||||
QA queue. Component interaction tests are separate evidence and do not replace
|
||||
that acceptance.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Selected terminal sharing — implementation WIP
|
||||
|
||||
Base: ThemePack `f868221b5f11810802f929369ac8eee46166079a`.
|
||||
Branch: `feat/plugin-terminal-sharing`. This is saved unfinished source, not an
|
||||
accepted API or an installable/native-tested workflow. Do not merge or release.
|
||||
|
||||
Implemented source so far:
|
||||
|
||||
- Canonical optional action-only `terminal.selection.read`, feature
|
||||
`terminal.selection-sharing`, bounded result and host-target schemas; SDK
|
||||
`context.terminals.shareSelection()` and runtime forwarding through the
|
||||
existing `ui.interact` broker. No independent parallel RPC stack.
|
||||
- Main-only worker/session generation and window-owner binding accessor; no
|
||||
endpoint or Host DTO returned to plugins.
|
||||
- Host target/handle authority, opaque signed short-lived receipts, capacity,
|
||||
expiry and lifecycle hooks; no selected text stored in the authority.
|
||||
- Broker admission binding to the selected resource before a once-only grant,
|
||||
signed Preview/Share phases, fixed audit metadata and teardown integration.
|
||||
- Trusted app-only selected-target setter, not a plugin API. No database,
|
||||
installation/source/cache identity or private-storage schema changes.
|
||||
|
||||
Still missing, and required before this counts as working:
|
||||
|
||||
1. Trusted renderer target publication for the real active terminal/workspace;
|
||||
actual bounded selection reader registered by the terminal surface. Read
|
||||
only on Preview, from a visible connected terminal, never through Agent/MCP
|
||||
history/context readers; refuse sensitive input, empty or oversized selection.
|
||||
2. Host dialog Preview response and explicit Share rendering, failure states,
|
||||
exact resource/selection consistency and global lifecycle verification.
|
||||
3. Canonical/SDK/broker/authority/real-worker/renderer normal and boundary tests,
|
||||
generated-contract and compatibility review (including optional-only
|
||||
declaration semantics), quotas, owner/reconnect/close/revoke/late-result cases.
|
||||
4. Useful ordinary-browser synthetic example, fixture ID/hash/precise permissions,
|
||||
build/CI, independent review/fix/review and parent-owned native acceptance.
|
||||
5. Separate input-draft layer: reuse the actual compose control without inheriting
|
||||
broadcast targets, overwriting an existing draft silently, writing PTY bytes
|
||||
or sending Enter. Require a separately approved target-bound draft action and
|
||||
final active identity check. No input-proposal method/permission is delivered
|
||||
by this WIP.
|
||||
|
||||
Known integration constraints:
|
||||
|
||||
- The market source-binding task owns installation, cache, authorization/private
|
||||
storage identity and associated database schema. Coordinate before touching
|
||||
those locations; runtime/assertActive identity must remain host-owned.
|
||||
- Keep the ThemePack security-color delta in its own branch/head. Incorporate
|
||||
the eventually accepted head with a normal integration merge, not by rewriting
|
||||
frozen evidence.
|
||||
- The user allows simplification of unreleased plugin historical data/protocols;
|
||||
do not add legacy migration layers. Do not remove unrelated host/input data
|
||||
migrations or weaken the sandbox.
|
||||
@@ -133,27 +133,3 @@ This is source identity and a source-based ordering diagnosis, not a claim that
|
||||
baseline runtime failure was reproduced locally. The updated test needs the new
|
||||
exact-head CI result. That same first run passed the added Vite browser-import
|
||||
smoke, resolving the earlier local temp-directory limitation for that head.
|
||||
|
||||
### Native security-color delta
|
||||
|
||||
Parent native acceptance at f868 verified zero-permission installation/enabling,
|
||||
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
|
||||
default, restart persistence, and Disable fallback with the unavailable notice.
|
||||
The isolated schema-3 profile was not touched. These core results remain valid.
|
||||
|
||||
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
|
||||
grant-panel text inside an otherwise fixed security container. The cause was
|
||||
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
|
||||
boundary does not recompute aliases already resolved on an ancestor. The same
|
||||
existing security rule now fixes both raw tokens and those color aliases. The
|
||||
selector and protected regions are unchanged; ordinary application cards remain
|
||||
theme-controlled. No host, credential or grant operation changed.
|
||||
|
||||
The regression renders the actual HostDetailsPanel (including General, Address,
|
||||
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
|
||||
compiles the production Tailwind source, verifies which aliases those utilities
|
||||
consume, checks the fixed declarations, and verifies an ordinary card is outside
|
||||
the security selector. This is component/compiled-CSS coverage, not a claim of
|
||||
native computed-color acceptance. The parent's native color delta remains the
|
||||
final visual check. A local Chromium probe aborted because its SUID sandbox was
|
||||
not configured; sandboxing was not disabled to retry.
|
||||
@@ -13,6 +13,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
|
||||
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
|
||||
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
|
||||
|
||||
@@ -390,6 +390,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
onRendererEvent = null,
|
||||
} = options;
|
||||
let child = null;
|
||||
let pluginSessionAuthorityEpoch = 0;
|
||||
const pending = new Map();
|
||||
const pendingOutput = new Map();
|
||||
const pendingOutputBytes = new Map();
|
||||
@@ -1857,6 +1858,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
},
|
||||
});
|
||||
child = worker;
|
||||
pluginSessionAuthorityEpoch++;
|
||||
worker.on?.("message", (message) => {
|
||||
if (child === worker) handleMessage(message);
|
||||
});
|
||||
@@ -2246,6 +2248,14 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionId) return null;
|
||||
return sessionWebContentsIds.get(sessionId) ?? null;
|
||||
},
|
||||
// Main-process resource authority only; never expose worker objects or endpoints.
|
||||
getPluginSessionBinding(sessionId, webContentsId) {
|
||||
const generation = sessionGenerations.get(sessionId);
|
||||
if (!child || !Number.isSafeInteger(generation) || closedSessions.has(sessionId)
|
||||
|| pendingSessionStartSequences.has(sessionId) || outputRoutePending.has(sessionId)
|
||||
|| sessionWebContentsIds.get(sessionId) !== webContentsId || !isLiveWebContentsId(webContentsId)) return null;
|
||||
return Object.freeze({ epoch: pluginSessionAuthorityEpoch, generation });
|
||||
},
|
||||
getSessionHostId(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return sessionHostIds.get(sessionId) ?? null;
|
||||
|
||||
@@ -166,6 +166,7 @@ function createBridgeRegistrar(context) {
|
||||
electron: electronModule,
|
||||
safeStorage,
|
||||
getInteractionWindow: () => win,
|
||||
getTerminalWorkerManager: () => terminalWorkerManager,
|
||||
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
|
||||
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
|
||||
requestPermissionDecision: createNativePermissionDecisionProvider({
|
||||
@@ -245,7 +246,6 @@ function createBridgeRegistrar(context) {
|
||||
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
|
||||
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
|
||||
themeService: pluginHostService?.themeService,
|
||||
logService: pluginHostService?.logService,
|
||||
viewHost: pluginHostService?.viewHost,
|
||||
env: process.env,
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
|
||||
@@ -187,8 +187,6 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
|
||||
class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.protectedDirectories = options.protectedDirectories ?? [];
|
||||
this.protectedFiles = options.protectedFiles ?? [];
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
|
||||
@@ -263,33 +261,10 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
}
|
||||
|
||||
async #assertWritable(canonical) {
|
||||
const variants = async (entry) => {
|
||||
const lexical = path.resolve(entry);
|
||||
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
|
||||
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
|
||||
};
|
||||
for (const file of this.protectedFiles) {
|
||||
if ((await variants(file)).includes(canonical)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
for (const directory of this.protectedDirectories) {
|
||||
for (const parent of await variants(directory)) {
|
||||
const relative = path.relative(parent, canonical);
|
||||
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
await this.#assertWritable(path.resolve(value.path));
|
||||
assertAuthorizedPath(context, "filesystem.write", value.path);
|
||||
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
|
||||
await this.#assertWritable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.write", canonical);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
|
||||
await context.assertActive();
|
||||
@@ -303,7 +278,6 @@ class PluginFilesystemBroker {
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
await this.#assertWritable(canonical);
|
||||
await context.assertActive();
|
||||
if (value.overwrite) await handle.truncate(0);
|
||||
await handle.writeFile(value.bytes);
|
||||
|
||||
@@ -390,61 +390,3 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const directory = path.join(root, "host-logs");
|
||||
await fsp.mkdir(directory);
|
||||
const audit = path.join(directory, "runtime.jsonl");
|
||||
const preferences = path.join(root, "local-logging.json");
|
||||
await fsp.writeFile(audit, "host audit");
|
||||
await fsp.writeFile(preferences, "host preferences");
|
||||
const alias = path.join(root, "alias");
|
||||
await fsp.symlink(audit, alias);
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
|
||||
for (const target of [audit, preferences, alias]) {
|
||||
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
|
||||
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
|
||||
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
|
||||
});
|
||||
|
||||
test("host audit protection resolves a symlinked userData directory", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const actual = path.join(root, "actual-profile");
|
||||
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
|
||||
const profileAlias = path.join(root, "profile-alias");
|
||||
await fsp.symlink(actual, profileAlias);
|
||||
const target = path.join(actual, "logs", "runtime.jsonl");
|
||||
await fsp.writeFile(target, "host audit");
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
|
||||
});
|
||||
|
||||
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const logs = path.join(root, "host-logs");
|
||||
const target = path.join(root, "synthetic.txt");
|
||||
await fsp.mkdir(logs);
|
||||
await fsp.writeFile(target, "prior");
|
||||
let checks = 0;
|
||||
let active = true;
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
|
||||
...fsp, async realpath(requested) {
|
||||
const canonical = await fsp.realpath(requested);
|
||||
if (requested === logs && ++checks === 3) active = false;
|
||||
return canonical;
|
||||
},
|
||||
} });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
const context = { ...runtimeContext(authorization), async assertActive() {
|
||||
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
|
||||
} };
|
||||
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "prior");
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.example.local-logging-fixture.${suffix}`, handler,
|
||||
));
|
||||
register("emit", () => {
|
||||
context.logger.info("Synthetic diagnostic for local logging QA", {
|
||||
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
|
||||
prompt: "SYNTHETIC-PROMPT-BODY",
|
||||
Notes: "SYNTHETIC-NOTE-BODY",
|
||||
SQL: "SYNTHETIC-SQL-BODY",
|
||||
results: "SYNTHETIC-RESULT-BODY",
|
||||
terminalContent: "SYNTHETIC-TERMINAL-BODY",
|
||||
pluginId: "synthetic-spoofed-plugin",
|
||||
sourceId: "official",
|
||||
runtimeId: "synthetic-spoofed-runtime",
|
||||
});
|
||||
return { ok: true, synthetic: true };
|
||||
});
|
||||
register("noise", () => {
|
||||
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
|
||||
return { ok: true, synthetic: true, emitted: 400 };
|
||||
});
|
||||
register("denied", async () => {
|
||||
// No network permission is declared. The host must reject before any
|
||||
// network I/O or approval prompt; no body or credential is ever supplied.
|
||||
try {
|
||||
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
|
||||
throw new Error("Synthetic undeclared request unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
|
||||
return { denied: true, synthetic: true };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.example.local-logging-fixture",
|
||||
"name": "local-logging-fixture",
|
||||
"displayName": "Synthetic local logging fixture",
|
||||
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "example",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"main": { "browser": "index.js" },
|
||||
"activationEvents": [
|
||||
"onCommand:com.example.local-logging-fixture.emit",
|
||||
"onCommand:com.example.local-logging-fixture.noise",
|
||||
"onCommand:com.example.local-logging-fixture.denied"
|
||||
],
|
||||
"permissions": { "required": ["commands", "menus"], "optional": [] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
|
||||
],
|
||||
"menus": [
|
||||
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -426,6 +429,9 @@
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SharedTerminalSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -536,6 +542,11 @@
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"terminalSessionId": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -731,7 +742,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"NonResourceScopedPermission": {
|
||||
@@ -765,7 +777,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"ResourceScopedPermission": {
|
||||
@@ -872,7 +885,10 @@
|
||||
{
|
||||
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
|
||||
}
|
||||
]
|
||||
],
|
||||
"not": {
|
||||
"const": "terminal.selection.read"
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"type": "object",
|
||||
@@ -5163,6 +5179,77 @@
|
||||
"entries",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
"TerminalSelectionSharingFeature": {
|
||||
"const": "terminal.selection-sharing"
|
||||
},
|
||||
"TerminalSelectionSharingLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"handleTtlMs": 120000,
|
||||
"handlesPerRuntime": 4,
|
||||
"handlesTotal": 64
|
||||
}
|
||||
},
|
||||
"HostTerminalTarget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostTerminalSelectionReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "terminal.selection.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SharedTerminalSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 64,
|
||||
"maxLength": 256
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"handle",
|
||||
"expiresAt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { createHmac, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { TerminalSelectionAuthority } = require("./terminalSelectionAuthority.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const schema = require("./generated/plugin-contract.schema.json");
|
||||
const LIMITS = schema.$defs.HostInteractionLimits.const;
|
||||
@@ -31,7 +32,12 @@ function assertInteractionRequest(params) {
|
||||
}
|
||||
return Object.freeze(structuredClone(params));
|
||||
}
|
||||
function interactionAuthorization(params) {
|
||||
function interactionAuthorization(params, terminalTarget) {
|
||||
if (params.kind === "terminal.selection.read") {
|
||||
if (!terminalTarget) throw fail("failedPrecondition", "A host-selected terminal is required");
|
||||
return { permission: "terminal.selection.read", resources: [terminalTarget.resource],
|
||||
reason: "Preview and explicitly share the selected terminal text", operationId: "terminal:share-selection", allowedScopes: ["once"] };
|
||||
}
|
||||
const permission = params.kind === "clipboard.read" ? "clipboard.read"
|
||||
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
|
||||
return {
|
||||
@@ -55,6 +61,12 @@ class PluginHostInteractionService {
|
||||
this.timeoutMs = Math.min(options.timeoutMs ?? LIMITS.timeoutMs, LIMITS.timeoutMs);
|
||||
this.key = randomBytes(32);
|
||||
this.pending = new Map();
|
||||
this.terminalSelection = new TerminalSelectionAuthority({getWindow: this.getWindow, getManager: options.getTerminalWorkerManager, clock: this.clock});
|
||||
this.terminalInvalidation = this.terminalSelection.onDidInvalidate(() => {
|
||||
for (const entry of [...this.pending.values()]) if (entry.terminalTarget && !this.terminalSelection.isCurrent(entry.terminalTarget)) {
|
||||
this.#finish(entry, null, fail("cancelled", "Selected terminal connection changed"));
|
||||
}
|
||||
});
|
||||
this.rates = new Map();
|
||||
this.admissions = new WeakMap();
|
||||
this.admissionGuards = new Map();
|
||||
@@ -75,8 +87,9 @@ class PluginHostInteractionService {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
return interactionAuthorization(params);
|
||||
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context) : undefined;
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget });
|
||||
return interactionAuthorization(params, terminalTarget);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -132,7 +145,7 @@ class PluginHostInteractionService {
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const binding = JSON.stringify([
|
||||
nonce, entry.id, entry.context.pluginId, entry.context.pluginVersion,
|
||||
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt,
|
||||
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt, entry.terminalTarget?.resource ?? null,
|
||||
]);
|
||||
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
|
||||
}
|
||||
@@ -143,6 +156,7 @@ class PluginHostInteractionService {
|
||||
requestId: entry.id, actionToken: entry.actionToken,
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId } : {}),
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
});
|
||||
}
|
||||
@@ -222,10 +236,13 @@ class PluginHostInteractionService {
|
||||
if (this.pending.size >= LIMITS.pendingTotal || [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
|
||||
throw fail("resourceExhausted", "Host interaction limit reached");
|
||||
}
|
||||
const terminalTarget = params.kind === "terminal.selection.read"
|
||||
? admission?.terminalTarget ?? this.terminalSelection.capture(sender, context) : undefined;
|
||||
if (terminalTarget) this.terminalSelection.assertCurrent(terminalTarget);
|
||||
const now = this.clock();
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
id: randomUUID(), context, params, sender, resolve, reject,
|
||||
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
|
||||
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
|
||||
};
|
||||
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
@@ -254,10 +271,19 @@ class PluginHostInteractionService {
|
||||
await entry.context.assertActive();
|
||||
this.#assertWindow(sender);
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
if (entry.terminalTarget) this.terminalSelection.assertCurrent(entry.terminalTarget);
|
||||
if (response.action === "cancel") {
|
||||
this.#audit(entry, "cancelled");
|
||||
return this.#finish(entry, { cancelled: true });
|
||||
}
|
||||
if (entry.params.kind === "terminal.selection.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Terminal sharing requires a preview action");
|
||||
const text = assertTextBytes(response.value);
|
||||
if (!text.length) throw fail("failedPrecondition", "Select terminal text before requesting a share");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
this.quotaManager.chargeBytes(entry.context.runtimeId, "terminal", Buffer.byteLength(text));
|
||||
entry.previewText = text; entry.phase = "preview"; this.#present(entry); return true;
|
||||
}
|
||||
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
@@ -281,6 +307,9 @@ class PluginHostInteractionService {
|
||||
else if (entry.params.kind === "quickPick") {
|
||||
if (!entry.params.items.some((item) => item.id === response.value)) throw fail("invalidArgument", "Host interaction choice is invalid");
|
||||
value = response.value;
|
||||
} else if (entry.params.kind === "terminal.selection.read") {
|
||||
if (entry.phase !== "preview" || typeof entry.previewText !== "string") throw fail("failedPrecondition", "Terminal selection was not previewed");
|
||||
value = {text:entry.previewText, ...this.terminalSelection.issue(entry.terminalTarget, entry.context)};
|
||||
} else if (entry.params.kind === "clipboard.read") value = entry.previewText;
|
||||
else value = true;
|
||||
this.#audit(entry, entry.params.kind === "clipboard.write" ? "copy-confirmed" : "share-confirmed");
|
||||
@@ -295,8 +324,11 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
|
||||
setTerminalTarget(sender, target) { return this.terminalSelection.setTarget(sender, target); }
|
||||
|
||||
releaseWindow(sender) {
|
||||
if (this.owner !== sender) return;
|
||||
this.terminalSelection.releaseWindow(sender);
|
||||
this.admissionRevision++;
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
@@ -305,12 +337,14 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
this.terminalSelection.releasePlugin(pluginId);
|
||||
this.admissionRevision++;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
this.terminalSelection.releaseRuntime(runtimeId);
|
||||
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
|
||||
this.rates.delete(runtimeId);
|
||||
}
|
||||
@@ -323,7 +357,7 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) this.admissionRevision++;
|
||||
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
@@ -332,6 +366,7 @@ class PluginHostInteractionService {
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.terminalInvalidation.dispose(); this.terminalSelection.shutdown();
|
||||
this.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,73 +175,45 @@ class PluginHostRpcRegistry {
|
||||
await assertCurrent?.();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
let authorizationOutcome = "not-reached";
|
||||
let auditResources;
|
||||
let auditResult;
|
||||
let auditError;
|
||||
try {
|
||||
await assertActive();
|
||||
const validatedParams = registration.validateParams
|
||||
? registration.validateParams(params)
|
||||
: params;
|
||||
if (validatedParams && typeof validatedParams.then === "function") {
|
||||
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
|
||||
}
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
metadata: registration.metadata,
|
||||
recordAuthorization: (outcome) => {
|
||||
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
|
||||
},
|
||||
params: validatedParams,
|
||||
});
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
const authorization = resolvedAuthorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(resolvedAuthorization);
|
||||
auditResources = authorization?.resources;
|
||||
await assertActive();
|
||||
const context = Object.freeze({
|
||||
...authorizationContext,
|
||||
authorization,
|
||||
});
|
||||
let index = -1;
|
||||
const dispatch = async (nextIndex) => {
|
||||
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
|
||||
index = nextIndex;
|
||||
const current = middleware[nextIndex];
|
||||
if (current) return current(context, () => dispatch(nextIndex + 1));
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
auditResult = result;
|
||||
return result;
|
||||
} catch (error) {
|
||||
auditError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
// Never record params, handler results, error messages or plugin text.
|
||||
const dataBytes = (value) => {
|
||||
if (typeof value === "string") return Buffer.byteLength(value);
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
|
||||
return null;
|
||||
};
|
||||
const field = (value, key) => value && typeof value === "object"
|
||||
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
||||
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
|
||||
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
|
||||
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
|
||||
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
|
||||
await assertActive();
|
||||
const validatedParams = registration.validateParams
|
||||
? registration.validateParams(params)
|
||||
: params;
|
||||
if (validatedParams && typeof validatedParams.then === "function") {
|
||||
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
|
||||
}
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
metadata: registration.metadata,
|
||||
params: validatedParams,
|
||||
});
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
const authorization = resolvedAuthorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(resolvedAuthorization);
|
||||
await assertActive();
|
||||
const context = Object.freeze({
|
||||
...authorizationContext,
|
||||
authorization,
|
||||
});
|
||||
let index = -1;
|
||||
const dispatch = async (nextIndex) => {
|
||||
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
|
||||
index = nextIndex;
|
||||
const current = middleware[nextIndex];
|
||||
if (current) return current(context, () => dispatch(nextIndex + 1));
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
return result;
|
||||
};
|
||||
const requestHandlers = Object.freeze(Object.fromEntries(
|
||||
[...this.requests].map(([method, registration]) => [
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
|
||||
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
|
||||
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
|
||||
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
|
||||
@@ -39,7 +37,6 @@ const {
|
||||
resolveDefaultRuntimeKind,
|
||||
} = require("./runtimeSupervisor.cjs");
|
||||
const { PluginQuotaManager } = require("./quotaManager.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
|
||||
const { PluginSecretStore } = require("./secretStore.cjs");
|
||||
const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
@@ -95,7 +92,6 @@ function createPluginHostService(options) {
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -103,7 +99,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -143,8 +139,6 @@ function createPluginHostService(options) {
|
||||
const filesystemBroker = new PluginFilesystemBroker({
|
||||
quotaManager,
|
||||
openDirectoryHandle: options.openPluginDirectoryHandle,
|
||||
protectedDirectories: [paths.logs],
|
||||
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
|
||||
});
|
||||
const networkBroker = new PluginNetworkBroker({
|
||||
fetch: options.fetch ?? (options.electron.net?.fetch
|
||||
@@ -196,6 +190,7 @@ function createPluginHostService(options) {
|
||||
getWindow: options.getInteractionWindow,
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
getTerminalWorkerManager: options.getTerminalWorkerManager,
|
||||
});
|
||||
interactionService.registerRpcCapabilities(rpcRegistry);
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
@@ -211,36 +206,25 @@ function createPluginHostService(options) {
|
||||
|| !context.availableKinds.includes(kind)
|
||||
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
||||
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
|
||||
try {
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
});
|
||||
if (kind === "utility") {
|
||||
await permissionEngine.authorize({
|
||||
pluginId: context.plugin.id,
|
||||
pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null,
|
||||
manifest: context.plugin.manifest,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
});
|
||||
if (kind === "utility") {
|
||||
await permissionEngine.authorize({
|
||||
pluginId: context.plugin.id,
|
||||
pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null,
|
||||
manifest: context.plugin.manifest,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const identity = logService.identity({
|
||||
pluginId: context.plugin.id, pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null, runtimeKind: kind, securityPrincipal: context.securityPrincipal,
|
||||
}, context.plugin);
|
||||
logService.audit(identity, { operation: "runtime.authorization", result: "error",
|
||||
authorization: error?.code === RPC_ERRORS.permissionDenied ? "denied" : "unknown",
|
||||
errorCode: error?.code });
|
||||
throw error;
|
||||
}
|
||||
return kind;
|
||||
};
|
||||
@@ -250,14 +234,13 @@ function createPluginHostService(options) {
|
||||
};
|
||||
runtimeSupervisor = new RuntimeSupervisor({
|
||||
electron: options.electron,
|
||||
logService,
|
||||
database,
|
||||
packageStore,
|
||||
protocol,
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -274,16 +257,6 @@ function createPluginHostService(options) {
|
||||
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
|
||||
});
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
permissionEngine.onDidRevoke((event) => {
|
||||
const plugin = database.getActivePlugin(event.pluginId);
|
||||
const identity = runtimeSupervisor.getRuntimeIdentity(event.pluginId) ?? {
|
||||
pluginId: event.pluginId, pluginVersion: plugin?.activeVersion,
|
||||
};
|
||||
logService.audit(logService.identity(identity, plugin), {
|
||||
operation: "permission.revoked", authorization: "not-required", result: "ok",
|
||||
resources: event.resource ? [event.resource] : [],
|
||||
});
|
||||
});
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
@@ -407,24 +380,19 @@ function createPluginHostService(options) {
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
try {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
} finally {
|
||||
await logService.close();
|
||||
}
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
},
|
||||
});
|
||||
return {
|
||||
logService,
|
||||
themeService,
|
||||
interactionService,
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
@@ -455,7 +423,6 @@ function createPluginHostService(options) {
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
void logService.close();
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -401,83 +401,3 @@ test("normal release blocks a custom utility placement before permission prompts
|
||||
}), /requires developer mode/);
|
||||
assert.equal(prompts, 0);
|
||||
});
|
||||
|
||||
test("host service logs revocation with injected provenance and drains through manager shutdown", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-host-"));
|
||||
let seen;
|
||||
const service = createPluginHostService({ ...createOptions(root), resolvePluginProvenance(input) {
|
||||
seen = input;
|
||||
return { sourceId: "synthetic-host-source", namespace: "test", sourceKind: "local" };
|
||||
} });
|
||||
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const manifest = { manifestVersion: 1, id: "com.example.synthetic-audit", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" } };
|
||||
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "b".repeat(64), packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
|
||||
await service.logService.ready;
|
||||
assert.equal(service.logService.status().saveToDisk, false);
|
||||
await service.logService.configure({ saveToDisk: true, verbose: false });
|
||||
service.permissionEngine.revokeAll(manifest.id);
|
||||
await service.logService.sink.flush();
|
||||
const record = JSON.parse(await fsp.readFile(service.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "permission.revoked");
|
||||
assert.equal(record.sourceId, "synthetic-host-source");
|
||||
assert.equal(record.archiveSha256, "b".repeat(64));
|
||||
assert.equal(record.pluginVersion, "1.0.0");
|
||||
assert.equal(record.runtimeId, null);
|
||||
assert.equal(seen.pluginId, manifest.id);
|
||||
assert.equal(seen.archiveSha256, "b".repeat(64));
|
||||
await service.manager.shutdown();
|
||||
assert.equal(service.logService.sink.closed, true);
|
||||
});
|
||||
|
||||
test("logging always drains before database close when another shutdown stage rejects", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-shutdown-failure-"));
|
||||
const host = createPluginHostService(createOptions(root));
|
||||
t.after(async () => { await host.manager.shutdown().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
await host.logService.ready;
|
||||
await host.logService.configure({ saveToDisk: true, verbose: false });
|
||||
let resume;
|
||||
const gate = new Promise((resolve) => { resume = resolve; });
|
||||
host.logService.sink.queue = host.logService.sink.queue.then(() => gate);
|
||||
const logger = host.logService.createLogger({ pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "host-shutdown", runtimeKind: "browser" }, {});
|
||||
logger.audit({ operation: "synthetic.before-shutdown", result: "ok" });
|
||||
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
|
||||
let settled = false;
|
||||
const shutdown = host.manager.shutdown().finally(() => { settled = true; });
|
||||
// Attach the rejection assertion before yielding so the expected failure is handled.
|
||||
const rejected = assert.rejects(shutdown, /synthetic companion shutdown failure/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.doesNotThrow(() => host.database.db.prepare("SELECT 1").get());
|
||||
resume();
|
||||
await rejected;
|
||||
assert.equal(host.logService.sink.closed, true);
|
||||
assert.equal(host.logService.retentionTimer._destroyed, true);
|
||||
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "synthetic.before-shutdown");
|
||||
});
|
||||
|
||||
test("activation permission denial is audited before a runtime logger exists", async (t) => {
|
||||
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
|
||||
const host = createPluginHostService(createOptions(root));
|
||||
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
|
||||
const archiveSha256 = "a".repeat(64);
|
||||
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
|
||||
await host.logService.ready;
|
||||
await host.logService.configure({ saveToDisk: true, verbose: false });
|
||||
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
|
||||
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
|
||||
}), { code: RPC_ERRORS.permissionDenied });
|
||||
await host.logService.sink.flush();
|
||||
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "runtime.authorization");
|
||||
assert.equal(record.authorization, "denied");
|
||||
assert.equal(record.runtimeId, null);
|
||||
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
|
||||
assert.equal(record.archiveSha256, archiveSha256);
|
||||
assert.equal(Object.hasOwn(record, "message"), false);
|
||||
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const test = require("node:test");
|
||||
|
||||
test("native logging fixture packs reproducibly and exposes only synthetic commands", async (t) => {
|
||||
const root = path.join(__dirname, "fixtures", "local-logging-plugin");
|
||||
const output = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-logging-package-"));
|
||||
t.after(() => fs.rm(output, { recursive: true, force: true }));
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
await buildPluginPackage(root, path.join(output, "one.ncpkg"));
|
||||
await buildPluginPackage(root, path.join(output, "two.ncpkg"));
|
||||
assert.deepEqual(await fs.readFile(path.join(output, "one.ncpkg")), await fs.readFile(path.join(output, "two.ncpkg")));
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
|
||||
assert.deepEqual(manifest.permissions, { required: ["commands", "menus"], optional: [] });
|
||||
const plugin = (await import(pathToFileURL(path.join(root, "index.js")).href)).default;
|
||||
const commands = new Map();
|
||||
const logs = [];
|
||||
let deniedRequests = 0;
|
||||
plugin.activate({
|
||||
subscriptions: { add() {} },
|
||||
commands: { registerCommand(id, handler) { commands.set(id, handler); } },
|
||||
logger: { info(...args) { logs.push(args); } },
|
||||
network: { async request(request) { assert.equal(request.method, "GET"); assert.equal(request.body, undefined); deniedRequests++; throw Object.assign(new Error("synthetic permission denial"), { code: "permission_denied" }); } },
|
||||
});
|
||||
assert.deepEqual(await commands.get(`${manifest.id}.emit`)(), { ok: true, synthetic: true });
|
||||
assert.match(logs[0][0], /Synthetic/);
|
||||
assert.equal(logs[0][1].sourceId, "official", "spoofed field is deliberately nested plugin text, never host provenance");
|
||||
assert.deepEqual(await commands.get(`${manifest.id}.denied`)(), { denied: true, synthetic: true });
|
||||
assert.equal(deniedRequests, 1);
|
||||
});
|
||||
@@ -637,21 +637,11 @@ class PluginPermissionEngine {
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
if (context.metadata.public === true) {
|
||||
context.recordAuthorization?.("not-required");
|
||||
return next();
|
||||
}
|
||||
if (context.metadata.public === true) return next();
|
||||
if (!context.authorization) {
|
||||
context.recordAuthorization?.("denied");
|
||||
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
|
||||
}
|
||||
try {
|
||||
await this.authorize(context, context.authorization);
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
|
||||
throw error;
|
||||
}
|
||||
context.recordAuthorization?.("allowed");
|
||||
await this.authorize(context, context.authorization);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,11 +20,7 @@ const CHANNELS = Object.freeze({
|
||||
themeApply: "netcatty:plugins:theme-apply",
|
||||
themesChanged: "netcatty:plugins:themes-changed",
|
||||
status: "netcatty:plugins:status",
|
||||
logStatus: "netcatty:plugins:log-status",
|
||||
logConfigure: "netcatty:plugins:log-configure",
|
||||
logOpen: "netcatty:plugins:log-open",
|
||||
logExport: "netcatty:plugins:log-export",
|
||||
logClear: "netcatty:plugins:log-clear",
|
||||
terminalTarget: "netcatty:plugins:terminal-target",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
list: "netcatty:plugins:list",
|
||||
@@ -689,14 +685,6 @@ function registerPluginBridge(ipcMain, options) {
|
||||
}
|
||||
return id;
|
||||
};
|
||||
for (const [channel, action] of [
|
||||
[CHANNELS.logStatus, "status"], [CHANNELS.logConfigure, "configure"],
|
||||
[CHANNELS.logOpen, "openFolder"], [CHANNELS.logExport, "exportLogs"], [CHANNELS.logClear, "clear"],
|
||||
]) handle(channel, async (_manager, payload) => {
|
||||
if (!options.logService) throw new Error("Local plugin logging is unavailable");
|
||||
await options.logService.ready;
|
||||
return options.logService[action](payload);
|
||||
});
|
||||
handle(CHANNELS.permissions, async (_manager, payload) => {
|
||||
const pluginId = assertManagedPluginId(payload);
|
||||
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
|
||||
@@ -739,6 +727,10 @@ function registerPluginBridge(ipcMain, options) {
|
||||
));
|
||||
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
|
||||
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
|
||||
handle(CHANNELS.terminalTarget, async (_manager, target, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions unavailable");
|
||||
return options.interactionService.setTerminalTarget(event.sender, target);
|
||||
});
|
||||
handle(CHANNELS.themeCatalog, async () => {
|
||||
if (!options.themeService) throw new Error("Plugin themes are unavailable");
|
||||
return options.themeService.catalog();
|
||||
|
||||
@@ -1721,17 +1721,3 @@ test('theme catalog and selection remain trusted app management channels',async(
|
||||
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
|
||||
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
|
||||
});
|
||||
|
||||
test("local log management rejects plugin/untrusted senders before touching the sink", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
const actions = [];
|
||||
const logService = { ready: Promise.resolve() };
|
||||
for (const action of ["status", "configure", "openFolder", "exportLogs", "clear"]) logService[action] = async () => { actions.push(action); return { saveToDisk: false }; };
|
||||
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, logService, isTrustedSender: (event) => event?.trusted === true });
|
||||
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) {
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({}, {}), /Untrusted/);
|
||||
assert.equal(actions.length, 0);
|
||||
}
|
||||
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
|
||||
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Host-only adapter. Call with the host-assigned activation identity after the
|
||||
* caller guard; never pass plugin request params, manifest source claims or an
|
||||
* owner supplied by a plugin. Missing source APIs mean unknown provenance.
|
||||
*/
|
||||
function createPluginLogProvenanceResolver(database) {
|
||||
const methods = ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"];
|
||||
const resolve = (identity) => {
|
||||
if (!methods.every((method) => typeof database?.[method] === "function")) return null;
|
||||
const plugin = database.getActivePlugin(identity.pluginId);
|
||||
if (!plugin || plugin.id !== identity.pluginId
|
||||
|| plugin.activeVersion !== identity.pluginVersion
|
||||
|| plugin.archiveSha256 !== identity.archiveSha256) return null;
|
||||
const source = plugin.source;
|
||||
const retained = database.getRetainedPluginSource(identity.pluginId);
|
||||
if (!source || !retained || source.id !== retained.id || source.kind !== retained.kind) return null;
|
||||
const owner = database.privateOwner(identity.pluginId);
|
||||
if (typeof owner !== "string" || !owner || owner === identity.pluginId || database.publicPluginId(owner) !== identity.pluginId) return null;
|
||||
return { sourceId: source.id, sourceKind: source.kind, namespace: owner };
|
||||
};
|
||||
return (identity) => {
|
||||
const initial = resolve(identity);
|
||||
if (!initial) return null;
|
||||
return Object.freeze({ ...initial, isCurrent: () => {
|
||||
try {
|
||||
const current = resolve(identity);
|
||||
return current !== null && current.sourceId === initial.sourceId
|
||||
&& current.sourceKind === initial.sourceKind && current.namespace === initial.namespace;
|
||||
} catch { return false; }
|
||||
} });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPluginLogProvenanceResolver };
|
||||
@@ -1,70 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
|
||||
test("real source database replacement never reattributes old runtime log records", {
|
||||
skip: typeof PluginDatabase.prototype.getRetainedPluginSource !== "function" && "requires the source API from PR #3981; run on the documented integration worktree",
|
||||
}, async (t) => {
|
||||
const { thirdPartySource, OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-integration-"));
|
||||
const host = createPluginHostService({
|
||||
app: { getPath: () => root, getAppPath: () => process.cwd(), getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
});
|
||||
const database = host.database;
|
||||
const pluginId = "com.example.source-log-fixture";
|
||||
// The current source contract encodes every namespace, including local.
|
||||
const { LOCAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
database.installVersion({ pluginId: "com.example.local-source-fixture", version: "1.0.0", manifest: {}, archiveSha256: "b".repeat(64), packageRelativePath: "com.example.local-source-fixture/1.0.0", source: LOCAL_SOURCE });
|
||||
const localOwner = database.privateOwner("com.example.local-source-fixture");
|
||||
assert.notEqual(localOwner, "com.example.local-source-fixture");
|
||||
assert.equal(database.publicPluginId(localOwner), "com.example.local-source-fixture");
|
||||
|
||||
const first = thirdPartySource("Synthetic first source", "https://synthetic-first.example.com");
|
||||
const install = (source) => database.installVersion({ pluginId, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
|
||||
install(first);
|
||||
const service = host.logService;
|
||||
t.after(async () => { await host.manager.shutdown().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const activation = { pluginId, pluginVersion: "1.0.0", runtimeId: "source-fixture-A", runtimeKind: "browser" };
|
||||
const firstLogger = service.createLogger(activation, database.getActivePlugin(pluginId));
|
||||
firstLogger.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
|
||||
await service.sink.flush();
|
||||
const oldOwner = database.privateOwner(pluginId);
|
||||
let resume;
|
||||
const queueGate = new Promise((resolve) => { resume = resolve; });
|
||||
service.sink.queue = service.sink.queue.then(() => queueGate);
|
||||
await firstLogger.write("info", "synthetic queued before replacement");
|
||||
database.removePlugin(pluginId);
|
||||
assert.equal(database.getRetainedPluginSource(pluginId).id, first.id);
|
||||
database.clearUninstalledSourceData(pluginId, first.id);
|
||||
install(OFFICIAL_SOURCE);
|
||||
assert.equal(database.publicPluginId(oldOwner), null);
|
||||
resume();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 1);
|
||||
const replacement = service.createLogger({ ...activation, runtimeId: "source-fixture-B" }, database.getActivePlugin(pluginId));
|
||||
replacement.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
|
||||
await service.sink.flush();
|
||||
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].sourceId, first.id);
|
||||
assert.equal(records[0].namespace, oldOwner);
|
||||
assert.equal(records[1].sourceId, "official");
|
||||
assert.equal(records[1].namespace, database.privateOwner(pluginId));
|
||||
assert.equal(Object.hasOwn(records[1], "officiallyMaintained"), false);
|
||||
assert.doesNotMatch(JSON.stringify(records), /queued before replacement|synthetic-first.example.com/);
|
||||
replacement.audit({ operation: "synthetic.before-shutdown", result: "ok" });
|
||||
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
|
||||
await assert.rejects(host.manager.shutdown(), /synthetic companion shutdown failure/);
|
||||
assert.equal(service.sink.closed, true);
|
||||
const drained = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(drained.at(-1).operation, "synthetic.before-shutdown");
|
||||
assert.equal(drained.at(-1).sourceId, "official");
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
|
||||
function sourceFixture(pluginId = "com.example.synthetic-source-logging") {
|
||||
const identity = { pluginId, pluginVersion: "1.0.0", archiveSha256: "a".repeat(64), runtimeId: "host-runtime-A", runtimeKind: "browser" };
|
||||
let source = { id: `third-party:${"b".repeat(64)}`, kind: "third-party", name: "Synthetic source", origin: "https://synthetic.example.com" };
|
||||
let active = { id: pluginId, activeVersion: identity.pluginVersion, archiveSha256: identity.archiveSha256, source };
|
||||
let retained = source;
|
||||
const owner = () => source ? `source:${source.id === "official" ? "c".repeat(64) : "d".repeat(64)}:${pluginId}` : null;
|
||||
const database = {
|
||||
getActivePlugin(id) { assert.equal(id, pluginId); return active; },
|
||||
getRetainedPluginSource(id) { assert.equal(id, pluginId); return retained; },
|
||||
privateOwner(id) { assert.equal(id, pluginId); return owner(); },
|
||||
publicPluginId(encoded) { return encoded === owner() ? pluginId : null; },
|
||||
};
|
||||
return { identity, database, plugin: () => active, setActive(value) { active = value; },
|
||||
setRetained(value) { retained = value; },
|
||||
replace() { source = { id: "official", kind: "official", name: "Not a maintenance claim", origin: "https://plugins.netcatty.app" }; active = { ...active, source }; retained = source; },
|
||||
uninstall() { active = null; },
|
||||
};
|
||||
}
|
||||
|
||||
test("host provenance uses the bound owner and ignores plugin-supplied source metadata", () => {
|
||||
const f = sourceFixture();
|
||||
const resolver = createPluginLogProvenanceResolver(f.database);
|
||||
const result = resolver({ ...f.identity, sourceId: "official", source: { id: "official" }, owner: "spoof" });
|
||||
assert.equal(result.sourceId, f.plugin().source.id);
|
||||
assert.equal(result.sourceKind, "third-party");
|
||||
assert.equal(result.namespace, f.database.privateOwner(f.identity.pluginId));
|
||||
assert.equal(result.isCurrent(), true);
|
||||
assert.equal(Object.hasOwn(result, "officiallyMaintained"), false);
|
||||
assert.equal(Object.hasOwn(result, "origin"), false);
|
||||
f.replace();
|
||||
assert.equal(result.isCurrent(), false);
|
||||
assert.equal(resolver(f.identity).sourceId, "official");
|
||||
});
|
||||
|
||||
test("missing source APIs, retained mismatch, orphaned owner and changed package remain unknown", () => {
|
||||
const f = sourceFixture();
|
||||
const resolve = createPluginLogProvenanceResolver(f.database);
|
||||
assert.equal(createPluginLogProvenanceResolver({ getActivePlugin: f.database.getActivePlugin })(f.identity), null);
|
||||
assert.equal(resolve({ ...f.identity, pluginVersion: "2.0.0" }), null);
|
||||
assert.equal(resolve({ ...f.identity, archiveSha256: "c".repeat(64) }), null);
|
||||
f.database.privateOwner = () => null;
|
||||
assert.equal(resolve(f.identity), null, "no bare pluginId fallback");
|
||||
f.database.privateOwner = () => f.identity.pluginId;
|
||||
f.database.publicPluginId = (owner) => owner;
|
||||
assert.equal(resolve(f.identity), null, "a bare owner is not an authenticated namespace");
|
||||
f.database.privateOwner = () => "source:orphan";
|
||||
assert.equal(resolve(f.identity), null);
|
||||
const g = sourceFixture();
|
||||
g.setRetained({ id: "different", kind: "third-party" });
|
||||
assert.equal(createPluginLogProvenanceResolver(g.database)(g.identity), null);
|
||||
const h = sourceFixture();
|
||||
const snapshot = createPluginLogProvenanceResolver(h.database)(h.identity);
|
||||
h.uninstall();
|
||||
assert.equal(snapshot.isCurrent(), false);
|
||||
assert.equal(createPluginLogProvenanceResolver(h.database)(h.identity), null, "retained source alone cannot authenticate an installed activation");
|
||||
});
|
||||
|
||||
test("queued diagnostics and host facts are revalidated after async I/O before commit", async (t) => {
|
||||
const f = sourceFixture(`com.${"a".repeat(120)}`);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-log-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
assert.equal(service.status().saveToDisk, false);
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const loggerA = service.createLogger(f.identity, f.plugin());
|
||||
loggerA.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
let release;
|
||||
let entered;
|
||||
const paused = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const prune = service.sink.prune.bind(service.sink);
|
||||
service.sink.prune = async () => { entered(); await gate; return prune(); };
|
||||
loggerA.audit({ operation: "synthetic.pending-source-A", authorization: "allowed", result: "ok" });
|
||||
await loggerA.write("info", "synthetic pending diagnostic A");
|
||||
await paused;
|
||||
f.replace();
|
||||
release();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 2);
|
||||
const loggerB = service.createLogger({ ...f.identity, runtimeId: "host-runtime-B" }, f.plugin());
|
||||
loggerB.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 2);
|
||||
assert.match(records[0].sourceId, /^third-party:/u);
|
||||
assert.equal(records[1].sourceId, "official");
|
||||
assert.notEqual(records[0].namespace, records[1].namespace);
|
||||
assert.equal(records[0].runtimeId, "host-runtime-A");
|
||||
assert.equal(records[1].runtimeId, "host-runtime-B");
|
||||
assert.doesNotMatch(JSON.stringify(records), /synthetic.pending-source-A|pending diagnostic|example.com|maintenance/i);
|
||||
f.uninstall();
|
||||
await loggerB.write("info", "synthetic stale callback");
|
||||
loggerB.audit({ operation: "synthetic.after-uninstall", result: "ok" });
|
||||
assert.equal(service.status().droppedRecords, 4);
|
||||
});
|
||||
|
||||
test("a thrown source revalidation fails closed without breaking plugin execution", async (t) => {
|
||||
const f = sourceFixture();
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-failure-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const logger = service.createLogger(f.identity, f.plugin());
|
||||
f.database.getActivePlugin = () => { throw new Error("synthetic unavailable source database"); };
|
||||
await logger.write("info", "synthetic ignored");
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 2);
|
||||
assert.equal(service.status().writeErrors, 0);
|
||||
});
|
||||
|
||||
test("a stale queued source cannot rotate away existing audit evidence", async (t) => {
|
||||
const f = sourceFixture();
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-rotation-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, limits: { maxFiles: 1 }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: false });
|
||||
const logger = service.createLogger(f.identity, f.plugin());
|
||||
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
const before = await fs.readFile(service.sink.file(0), "utf8");
|
||||
service.sink.maxBytes = Buffer.byteLength(before) + 16;
|
||||
let resume;
|
||||
let entered;
|
||||
const paused = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { resume = resolve; });
|
||||
const prune = service.sink.prune.bind(service.sink);
|
||||
service.sink.prune = async () => { entered(); await gate; return prune(); };
|
||||
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await paused;
|
||||
f.replace();
|
||||
resume();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 1);
|
||||
assert.equal(await fs.readFile(service.sink.file(0), "utf8"), before);
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHmac, randomBytes } = require("node:crypto");
|
||||
const { PluginLogger, PluginLogSink } = require("./pluginLogger.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const bounded = (value, length = 128) => typeof value === "string"
|
||||
? value.slice(0, length).replace(/[^a-zA-Z0-9._:@/-]/gu, "_") : null;
|
||||
const sourceKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,128}$/u.test(value) ? value : null;
|
||||
const ownerKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,256}$/u.test(value) ? value : null;
|
||||
const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
|
||||
class PluginLogService {
|
||||
constructor(options) {
|
||||
this.directory = path.join(options.paths.logs, "host-v1");
|
||||
this.preferencesPath = path.join(options.paths.root, "local-logging.json");
|
||||
this.resolveProvenance = options.resolveProvenance ?? (() => null);
|
||||
this.electron = options.electron;
|
||||
this.provenanceChecks = new WeakMap();
|
||||
this.salt = randomBytes(32);
|
||||
this.sink = new PluginLogSink({ directory: this.directory, ...options.limits });
|
||||
this.settings = { saveToDisk: false, verbose: false };
|
||||
this.management = Promise.resolve();
|
||||
try {
|
||||
if (fs.statSync(this.preferencesPath).size <= 1024) {
|
||||
const saved = JSON.parse(fs.readFileSync(this.preferencesPath, "utf8"));
|
||||
this.settings.saveToDisk = saved.saveToDisk === true;
|
||||
// Free-form diagnostics always require fresh consent after app restart.
|
||||
}
|
||||
} catch {}
|
||||
this.ready = this.sink.setEnabled(this.settings.saveToDisk).then(async () => {
|
||||
if (!this.sink.enabled && fs.existsSync(this.directory)) await this.sink.prune();
|
||||
}).catch(() => {
|
||||
this.sink.writeErrors++;
|
||||
this.settings.saveToDisk = false;
|
||||
this.sink.enabled = false;
|
||||
});
|
||||
this.retentionTimer = setInterval(() => {
|
||||
if (!fs.existsSync(this.directory) || this.sink.closed) return;
|
||||
this.sink.queue = this.sink.queue.then(() => this.sink.prune()).catch(() => { this.sink.writeErrors++; });
|
||||
}, 60 * 60 * 1000);
|
||||
this.retentionTimer.unref?.();
|
||||
}
|
||||
|
||||
status() {
|
||||
return { ...this.settings, ...this.sink.status(), maxBytes: this.sink.maxBytes * this.sink.maxFiles,
|
||||
retentionDays: this.sink.retentionMs / 86400000 };
|
||||
}
|
||||
async configure(request) {
|
||||
if (!request || typeof request.saveToDisk !== "boolean" || typeof request.verbose !== "boolean") throw new TypeError("Invalid plugin log settings");
|
||||
return this.#manage(async () => {
|
||||
if (this.sink.closed) throw new Error("Plugin logging is closed");
|
||||
const settings = { saveToDisk: request.saveToDisk, verbose: request.saveToDisk && request.verbose };
|
||||
// Stop ingress before changing preferences. Failure leaves persistence off.
|
||||
this.settings = { saveToDisk: false, verbose: false };
|
||||
await this.sink.setEnabled(false);
|
||||
await fsp.mkdir(path.dirname(this.preferencesPath), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.preferencesPath}.tmp`;
|
||||
await fsp.writeFile(temporary, JSON.stringify({ saveToDisk: settings.saveToDisk }), { mode: 0o600 });
|
||||
await fsp.rename(temporary, this.preferencesPath);
|
||||
await this.sink.setEnabled(settings.saveToDisk);
|
||||
this.settings = settings;
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
#manage(action) {
|
||||
const result = this.management.then(() => this.ready).then(action);
|
||||
this.management = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
identity(identity, plugin) {
|
||||
// Only the injected HOST resolver may assign source and namespace identity.
|
||||
let source = null;
|
||||
try { source = this.resolveProvenance(Object.freeze({ pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
|
||||
archiveSha256: plugin?.archiveSha256 ?? null, securityPrincipal: identity.securityPrincipal })); } catch {}
|
||||
const metadata = Object.freeze({ pluginId: bounded(identity.pluginId), pluginVersion: bounded(identity.pluginVersion),
|
||||
runtimeId: bounded(identity.runtimeId), runtimeKind: bounded(identity.runtimeKind),
|
||||
archiveSha256: /^[a-f0-9]{64}$/u.test(plugin?.archiveSha256 ?? "") ? plugin.archiveSha256 : null,
|
||||
sourceId: sourceKey(source?.sourceId), sourceKind: sourceKey(source?.sourceKind), namespace: ownerKey(source?.namespace) });
|
||||
if (source?.isCurrent && typeof source.isCurrent === "function") {
|
||||
this.provenanceChecks.set(metadata, () => source.isCurrent() === true);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
createLogger(identity, plugin) {
|
||||
const provenance = this.identity(identity, plugin);
|
||||
return new PluginLogger({ sink: this.sink, identity: provenance, verbose: () => this.settings.verbose, isCurrent: this.provenanceChecks.get(provenance), audit: (event) => this.audit(provenance, event) });
|
||||
}
|
||||
audit(identity, event) {
|
||||
if (!this.sink.enabled) return;
|
||||
// An allowlist, never spread request/response/error objects into an audit.
|
||||
const resources = Array.isArray(event.resources) ? event.resources.slice(0, 16) : [];
|
||||
this.sink.enqueue({ timestamp: new Date().toISOString(), recordType: "host-audit", trust: "host-observed",
|
||||
...identity, operation: bounded(event.operation, 256),
|
||||
targetIds: resources.filter((item) => typeof item === "string").map((item) =>
|
||||
createHmac("sha256", this.salt).update(item.slice(0, 8192)).digest("hex")),
|
||||
authorization: ["allowed", "denied", "not-required", "not-reached", "unknown"].includes(event.authorization) ? event.authorization : "unknown",
|
||||
result: event.result === "ok" ? "ok" : "error",
|
||||
errorCode: Object.values(RPC_ERRORS).includes(event.errorCode) ? event.errorCode : event.result === "ok" ? null : RPC_ERRORS.internal,
|
||||
durationMs: count(event.durationMs), inputBytes: count(event.inputBytes), outputBytes: count(event.outputBytes) }, this.provenanceChecks.get(identity));
|
||||
}
|
||||
async openFolder() {
|
||||
return this.#manage(async () => {
|
||||
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const error = await this.electron.shell.openPath(this.directory);
|
||||
if (error) throw new Error("Could not open plugin log folder");
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async exportLogs() {
|
||||
return this.#manage(async () => {
|
||||
const result = await this.electron.dialog.showSaveDialog({ title: "Export local plugin logs", defaultPath: "netcatty-plugin-logs.jsonl" });
|
||||
if (result.canceled || !result.filePath) return this.status();
|
||||
if (path.resolve(result.filePath).startsWith(path.resolve(this.directory) + path.sep)) throw new Error("Choose a destination outside the log folder");
|
||||
const task = this.sink.queue.then(async () => {
|
||||
const chunks = [];
|
||||
for (let index = this.sink.maxFiles - 1; index >= 0; index--) {
|
||||
try {
|
||||
const stats = await fsp.lstat(this.sink.file(index));
|
||||
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > this.sink.maxBytes) throw new Error("Unsafe plugin log file");
|
||||
if (await this.sink.retained(this.sink.file(index))) chunks.push(await fsp.readFile(this.sink.file(index)));
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
await fsp.writeFile(result.filePath, Buffer.concat(chunks), { mode: 0o600 });
|
||||
});
|
||||
this.sink.queue = task.catch(() => {});
|
||||
await task;
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async clear() {
|
||||
return this.#manage(async () => {
|
||||
const result = await this.electron.dialog.showMessageBox({ type: "warning", title: "Clear local plugin logs?",
|
||||
message: "Delete the saved local audit and diagnostic files? The bounded security history in plugin permissions is kept.",
|
||||
buttons: ["Cancel", "Clear logs"], defaultId: 0, cancelId: 0, noLink: true });
|
||||
if (result.response === 1) await this.sink.clear();
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async close() {
|
||||
clearInterval(this.retentionTimer);
|
||||
await this.management;
|
||||
await this.ready;
|
||||
await this.sink.close();
|
||||
}
|
||||
}
|
||||
module.exports = { PluginLogService };
|
||||
@@ -1,186 +1,96 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const { constants } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
|
||||
const { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
|
||||
const { assertPluginStorageSegment } = require("./paths.cjs");
|
||||
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|api.?key|private.?key|credential|authorization|cookie|totp|otpauth|prompt|notes?|sql|results?|terminal|content|body|payload)/iu;
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
|
||||
|
||||
// Bounds apply to the entire traversal, including hostile, cyclic or wide input.
|
||||
// This is damage limitation for opt-in diagnostics, not a guarantee of secrecy.
|
||||
function redactLogValue(value, key = "", depth = 0, budget = { remaining: 4096 }) {
|
||||
function redactLogValue(value, key = "", depth = 0) {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
if (depth >= 6 || budget.remaining <= 0) return "[TRUNCATED]";
|
||||
budget.remaining -= 16;
|
||||
if (value == null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value === "string") {
|
||||
const text = value.slice(0, Math.min(2048, Math.max(0, budget.remaining))).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
|
||||
budget.remaining -= text.length;
|
||||
return text;
|
||||
}
|
||||
if (typeof value !== "object") return "[UNSUPPORTED]";
|
||||
try {
|
||||
const output = Array.isArray(value) ? [] : Object.create(null);
|
||||
// Stop enumeration as soon as the budget is exhausted.
|
||||
let count = 0;
|
||||
for (const entryKey in value) {
|
||||
if (budget.remaining <= 0 || count++ >= 32) break;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
|
||||
if (!descriptor?.enumerable) continue;
|
||||
const safeKey = entryKey.slice(0, 64).replace(/[\u0000-\u001f\u007f]/gu, " ");
|
||||
budget.remaining -= safeKey.length;
|
||||
const item = "value" in descriptor ? redactLogValue(descriptor.value, entryKey, depth + 1, budget) : "[UNAVAILABLE]";
|
||||
if (Array.isArray(output)) output.push(item);
|
||||
else output[safeKey] = item;
|
||||
if (depth >= 8) return "[TRUNCATED]";
|
||||
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
|
||||
if (typeof value === "string") return value.slice(0, 2_048);
|
||||
if (Array.isArray(value)) {
|
||||
const output = [];
|
||||
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
output.push(descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, "", depth + 1)
|
||||
: "[UNAVAILABLE]");
|
||||
}
|
||||
return output;
|
||||
} catch { return "[UNAVAILABLE]"; }
|
||||
}
|
||||
|
||||
/** One app-owned sink gives every plugin a shared disk and queue budget. */
|
||||
class PluginLogSink {
|
||||
constructor(options) {
|
||||
this.directory = options.directory;
|
||||
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
|
||||
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
|
||||
this.maxPendingBytes = options.maxPendingBytes ?? 256 * 1024;
|
||||
this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
|
||||
for (const value of [this.maxBytes, this.maxFiles, this.maxPendingBytes, this.retentionMs]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError("Invalid plugin log limit");
|
||||
}
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.enabled = false;
|
||||
this.closed = false;
|
||||
this.pendingBytes = 0;
|
||||
this.diagnosticPendingBytes = 0;
|
||||
this.dropped = 0;
|
||||
this.writeErrors = 0;
|
||||
this.queue = Promise.resolve();
|
||||
this.generation = 0;
|
||||
}
|
||||
|
||||
enqueue(record, isCurrent = () => true) {
|
||||
if (!this.enabled || this.closed) return;
|
||||
if (this.clearing) { this.dropped++; return; }
|
||||
const current = () => { try { return isCurrent() === true; } catch { return false; } };
|
||||
if (!current()) { this.dropped++; return; }
|
||||
let line;
|
||||
try { line = `${JSON.stringify({ ...record, timestamp: new Date(this.clock()).toISOString(), droppedRecords: this.dropped, writeErrors: this.writeErrors })}\n`; }
|
||||
catch { this.dropped++; return; }
|
||||
const bytes = Buffer.byteLength(line);
|
||||
const diagnostic = record.recordType === "plugin-diagnostic";
|
||||
if ((diagnostic && this.diagnosticPendingBytes + bytes > this.maxPendingBytes / 4) || bytes > Math.min(this.maxBytes, 16 * 1024) || this.pendingBytes + bytes > this.maxPendingBytes) {
|
||||
this.dropped++; return;
|
||||
}
|
||||
const generation = this.generation;
|
||||
this.pendingBytes += bytes;
|
||||
if (diagnostic) this.diagnosticPendingBytes += bytes;
|
||||
this.queue = this.queue.then(async () => {
|
||||
if (generation !== this.generation || !current()) { this.dropped++; return; }
|
||||
await this.prune();
|
||||
const active = this.file(0);
|
||||
let size = 0;
|
||||
try { size = (await fs.stat(active)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
if (!current()) { this.dropped++; return; }
|
||||
if (size + bytes > this.maxBytes) {
|
||||
await fs.rm(this.file(this.maxFiles - 1), { force: true });
|
||||
for (let index = this.maxFiles - 2; index >= 0; index--) {
|
||||
if (!current()) { this.dropped++; return; }
|
||||
try { await fs.rename(this.file(index), this.file(index + 1)); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const output = {};
|
||||
for (const entryKey of Object.keys(value).slice(0, 64)) {
|
||||
if (SENSITIVE_KEY.test(entryKey)) {
|
||||
output[entryKey.slice(0, 128)] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
if (!current()) { this.dropped++; return; }
|
||||
const handle = await fs.open(active, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
|
||||
try {
|
||||
if (!(await handle.stat()).isFile()) throw new Error("Unsafe plugin log file");
|
||||
if (!current()) { this.dropped++; return; }
|
||||
await handle.writeFile(line);
|
||||
} finally { await handle.close(); }
|
||||
}).catch(() => { this.dropped++; this.writeErrors++; }).finally(() => {
|
||||
this.pendingBytes -= bytes;
|
||||
if (diagnostic) this.diagnosticPendingBytes -= bytes;
|
||||
});
|
||||
}
|
||||
|
||||
file(index) { return path.join(this.directory, index === 0 ? "runtime.jsonl" : `runtime.${index}.jsonl`); }
|
||||
async retained(file) {
|
||||
// Every host record is capped at 16 KiB. Read only the first line: append
|
||||
// updates mtime, so mtime cannot enforce record retention for active files.
|
||||
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const buffer = Buffer.alloc(16 * 1024);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const newline = buffer.indexOf(10, 0);
|
||||
if (newline < 0 || newline >= bytesRead) return false;
|
||||
let first;
|
||||
try { first = JSON.parse(buffer.subarray(0, newline).toString("utf8")); } catch { return false; }
|
||||
const start = typeof first.timestamp === "string" ? Date.parse(first.timestamp) : NaN;
|
||||
return Number.isFinite(start) && this.clock() - start < this.retentionMs;
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
async prune() {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
for (let index = 0; index < this.maxFiles; index++) {
|
||||
try {
|
||||
const file = this.file(index);
|
||||
const stats = await fs.lstat(file);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe plugin log file");
|
||||
if (stats.size > this.maxBytes || !await this.retained(file)) await fs.rm(file);
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
|
||||
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, entryKey, depth + 1)
|
||||
: "[UNAVAILABLE]";
|
||||
}
|
||||
return output;
|
||||
}
|
||||
flush() { return this.queue; }
|
||||
async setEnabled(enabled) {
|
||||
this.enabled = false;
|
||||
this.generation++;
|
||||
await this.flush();
|
||||
if (enabled === true && !this.closed) {
|
||||
await this.prune();
|
||||
this.enabled = true;
|
||||
}
|
||||
}
|
||||
async clear() {
|
||||
this.clearing = true;
|
||||
this.generation++;
|
||||
try {
|
||||
await this.flush();
|
||||
for (let index = 0; index < this.maxFiles; index++) await fs.rm(this.file(index), { force: true });
|
||||
} finally { this.clearing = false; }
|
||||
}
|
||||
async close() { this.closed = true; this.enabled = false; await this.flush(); }
|
||||
status() { return { droppedRecords: this.dropped, writeErrors: this.writeErrors, pendingBytes: this.pendingBytes }; }
|
||||
return "[UNSUPPORTED]";
|
||||
}
|
||||
|
||||
class PluginLogger {
|
||||
constructor(options = {}) {
|
||||
this.sink = options.sink;
|
||||
this.identity = options.identity;
|
||||
this.verbose = options.verbose ?? (() => false);
|
||||
this.isCurrent = options.isCurrent;
|
||||
this.closed = false;
|
||||
this.audit = options.audit ?? (() => {});
|
||||
this.bucket = new TokenBucket(DEFAULT_QUOTAS.logWritesPerMinute, DEFAULT_QUOTAS.logWritesPerMinute / 60000, options.clock ?? Date.now);
|
||||
constructor(options) {
|
||||
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
|
||||
this.directory = path.join(options.logsDirectory, this.pluginId);
|
||||
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
|
||||
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
|
||||
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
|
||||
this.pendingBytes = 0;
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
|
||||
write(level, message, fields) {
|
||||
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
|
||||
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
|
||||
this.sink.enqueue({
|
||||
const record = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
|
||||
...this.identity,
|
||||
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
|
||||
diagnostic: {
|
||||
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
|
||||
fields: redactLogValue(fields),
|
||||
},
|
||||
}, this.isCurrent);
|
||||
return Promise.resolve();
|
||||
message: String(message ?? "").slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
|
||||
});
|
||||
const line = `${record}\n`;
|
||||
const bytes = Buffer.byteLength(line);
|
||||
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
|
||||
this.pendingBytes += bytes;
|
||||
this.queue = this.queue
|
||||
.then(() => this.#append(line))
|
||||
.catch(() => {})
|
||||
.finally(() => { this.pendingBytes -= bytes; });
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async #append(line) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const activePath = path.join(this.directory, "runtime.log");
|
||||
let size = 0;
|
||||
try { size = (await fs.stat(activePath)).size; } catch {}
|
||||
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
|
||||
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
async #rotate() {
|
||||
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
|
||||
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
|
||||
path.join(this.directory, `runtime.${index}.log`),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
|
||||
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
|
||||
module.exports = { PluginLogger, redactLogValue };
|
||||
@@ -1,258 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginLogger, PluginLogSink, redactLogValue } = require("./pluginLogger.cjs");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
async function fixture(t, limits = {}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-plugin-logs-"));
|
||||
const paths = { root, logs: path.join(root, "logs") };
|
||||
let response = 0;
|
||||
const service = new PluginLogService({ paths, limits, resolveProvenance: () => ({ sourceId: "synthetic-source", sourceKind: "local", namespace: "test" }),
|
||||
electron: { shell: { openPath: async () => "" }, dialog: { showMessageBox: async () => ({ response }), showSaveDialog: async () => ({ filePath: path.join(root, "export.jsonl") }) } } });
|
||||
await service.ready;
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const identity = { pluginId: "com.example.synthetic", pluginVersion: "1.2.3", runtimeId: "host-runtime-1", runtimeKind: "browser" };
|
||||
const logger = service.createLogger(identity, { archiveSha256: "a".repeat(64) });
|
||||
const records = async () => {
|
||||
await service.sink.flush();
|
||||
const files = await fs.readdir(service.directory).catch(() => []);
|
||||
const lines = (await Promise.all(files.map((name) => fs.readFile(path.join(service.directory, name), "utf8")))).join("");
|
||||
return lines.trim() ? lines.trim().split("\n").map(JSON.parse) : [];
|
||||
};
|
||||
return { service, logger, identity, root, paths, records, confirm: () => { response = 1; } };
|
||||
}
|
||||
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
|
||||
|
||||
test("persistence defaults off and disabled logger does not coerce or inspect text", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.logger.write("error", { toString() { throw Error("must not execute"); } });
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.deepEqual(await f.records(), []);
|
||||
await assert.rejects(fs.stat(f.service.directory), { code: "ENOENT" });
|
||||
assert.equal(f.service.status().saveToDisk, false);
|
||||
});
|
||||
test("plugin logs redact structured secrets and remain bounded on disk", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-logs-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const logger = new PluginLogger({
|
||||
pluginId: "com.example.logger",
|
||||
logsDirectory: root,
|
||||
maxBytes: 240,
|
||||
maxFiles: 2,
|
||||
});
|
||||
|
||||
test("enabled host audit excludes bodies and binds host source, version, hash and runtime", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
await f.logger.write("info", "SYNTHETIC_SECRET_PROMPT");
|
||||
f.logger.audit({ operation: "network.request", authorization: "allowed", result: "ok", resources: ["https://user:password@example.test/private?api_key=SYNTHETIC_SECRET"],
|
||||
body: "SYNTHETIC_SECRET_BODY", error: "SYNTHETIC_SECRET_ERROR", inputBytes: 24, outputBytes: 64, durationMs: 3, pluginId: "spoof" });
|
||||
const records = await f.records();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].pluginId, f.identity.pluginId);
|
||||
assert.equal(records[0].sourceId, "synthetic-source");
|
||||
assert.equal(records[0].archiveSha256, "a".repeat(64));
|
||||
assert.equal(records[0].runtimeId, "host-runtime-1");
|
||||
assert.equal(records[0].authorization, "allowed");
|
||||
assert.equal(records[0].inputBytes, 24);
|
||||
assert.match(records[0].targetIds[0], /^[a-f0-9]{64}$/u);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|example.test|password|spoof/);
|
||||
});
|
||||
|
||||
test("verbose diagnostics are opt-in, bounded and cannot impersonate host records", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await f.logger.write("bad", "message\u001b\n", { sourceId: "spoof", recordType: "host-audit", apiKey: "SYNTHETIC_SECRET", prompt: "SYNTHETIC_SECRET", Notes: "SYNTHETIC_SECRET", SQL: "SYNTHETIC_SECRET", results: "SYNTHETIC_SECRET", terminalContent: "SYNTHETIC_SECRET", credentialPayload: "SYNTHETIC_SECRET" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, "synthetic-source");
|
||||
assert.equal(records[0].recordType, "plugin-diagnostic");
|
||||
assert.equal(records[0].trust, "plugin-authored-unverified");
|
||||
assert.equal(records[0].diagnostic.fields.sourceId, "spoof");
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|\\u001b/);
|
||||
const cyclic = {}; cyclic.self = cyclic;
|
||||
await f.logger.write("error", { toString() { throw Error("coercion"); } }, cyclic);
|
||||
await f.logger.write("info", "proxy", new Proxy({}, { ownKeys() { throw Error("proxy"); } }));
|
||||
const getter = {};
|
||||
Object.defineProperty(getter, "value", { enumerable: true, get() { throw Error("accessor"); } });
|
||||
assert.equal(redactLogValue(getter).value, "[UNAVAILABLE]");
|
||||
assert.equal((await f.records()).length, 3);
|
||||
const restored = new PluginLogService({ paths: f.paths });
|
||||
await restored.ready;
|
||||
assert.equal(restored.status().saveToDisk, true);
|
||||
assert.equal(restored.status().verbose, false);
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
test("shared disk cap, queue cap, retention and drop accounting", async (t) => {
|
||||
const f = await fixture(t, { maxBytes: 800, maxFiles: 2, maxPendingBytes: 1600 });
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await logger.write("info", `record-${index}`, {
|
||||
nested: { apiToken: "must-not-appear", visible: index },
|
||||
});
|
||||
}
|
||||
let files = await fs.readdir(f.service.directory);
|
||||
const files = fs.readdirSync(path.join(root, "com.example.logger"));
|
||||
assert.ok(files.length <= 2);
|
||||
for (const name of files) assert.ok((await fs.stat(path.join(f.service.directory, name))).size <= 800);
|
||||
for (let i = 0; i < 1000; i++) f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.ok(f.service.sink.pendingBytes <= 1600);
|
||||
assert.ok(f.service.status().droppedRecords > 0);
|
||||
await f.service.sink.flush();
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.ok((await f.records()).some((record) => record.droppedRecords > 0));
|
||||
files = await fs.readdir(f.service.directory);
|
||||
const future = Date.now() + f.service.sink.retentionMs;
|
||||
f.service.sink.clock = () => future;
|
||||
await f.service.sink.prune();
|
||||
assert.deepEqual(await fs.readdir(f.service.directory), []);
|
||||
const contents = files.map((file) => fs.readFileSync(
|
||||
path.join(root, "com.example.logger", file),
|
||||
"utf8",
|
||||
)).join("\n");
|
||||
assert.doesNotMatch(contents, /must-not-appear/);
|
||||
assert.match(contents, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test("disable, revoke, clear confirmation and shutdown stop future diagnostic writes", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await f.logger.write("info", "before revoke");
|
||||
await f.service.sink.flush();
|
||||
f.logger.close();
|
||||
await f.logger.write("info", "after revoke");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
await f.service.clear();
|
||||
assert.equal((await f.records()).length, 1);
|
||||
f.confirm(); await f.service.clear();
|
||||
assert.equal((await f.records()).length, 0);
|
||||
const active = f.service.createLogger(f.identity, {});
|
||||
await f.service.configure({ saveToDisk: false, verbose: false });
|
||||
await active.write("info", "disabled");
|
||||
assert.equal((await f.records()).length, 0);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await active.write("info", "before shutdown");
|
||||
await f.service.close();
|
||||
await active.write("info", "after shutdown");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
});
|
||||
|
||||
test("export is local and does not alter retained files", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
f.logger.audit({ operation: "filesystem.stat", result: "ok", authorization: "allowed" });
|
||||
await f.service.exportLogs();
|
||||
const exported = await fs.readFile(path.join(f.root, "export.jsonl"), "utf8");
|
||||
assert.equal(JSON.parse(exported).operation, "filesystem.stat");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
});
|
||||
|
||||
test("RPC audit includes denied, malformed and public operations without error messages or bodies", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const engine = new PluginPermissionEngine({ database: { listPermissionGrants: () => [] } });
|
||||
t.after(() => engine.shutdown());
|
||||
registry.use(engine.createMiddleware());
|
||||
registry.registerRequest("synthetic.public", (params) => ({ data: params.data }), { metadata: { public: true } });
|
||||
registry.registerRequest("synthetic.denied", () => { throw Error("not reached"); });
|
||||
registry.registerRequest("synthetic.invalid", () => null, { validateParams() { throw new Error("SYNTHETIC_SECRET_ERROR"); } });
|
||||
const routes = registry.createRoutes({ ...f.identity, logger: f.logger });
|
||||
await routes.requestHandlers["synthetic.public"]({ data: "SYNTHETIC_SECRET_BODY" }, {});
|
||||
await assert.rejects(routes.requestHandlers["synthetic.denied"]({}, {}), { code: RPC_ERRORS.permissionDenied });
|
||||
await assert.rejects(routes.requestHandlers["synthetic.invalid"]({}, {}));
|
||||
const records = await f.records();
|
||||
assert.deepEqual(records.map((r) => r.authorization), ["not-required", "denied", "not-reached"]);
|
||||
assert.equal(records[0].inputBytes, 21);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
|
||||
});
|
||||
|
||||
test("I/O failure is counted and never fails plugin execution", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await fs.writeFile(f.service.sink.file(0), "");
|
||||
await fs.rm(f.service.sink.file(0));
|
||||
await fs.symlink(path.join(f.root, "outside"), f.service.sink.file(0));
|
||||
await f.logger.write("info", "synthetic");
|
||||
await f.service.sink.flush();
|
||||
assert.equal(f.service.status().writeErrors, 1);
|
||||
assert.equal(f.service.status().droppedRecords, 1);
|
||||
});
|
||||
|
||||
test("invalid sink limits fail early", () => {
|
||||
assert.throws(() => new PluginLogSink({ directory: "/unused", maxFiles: -1 }));
|
||||
const logger = new PluginLogger();
|
||||
assert.doesNotThrow(() => logger.write("info", "disabled"));
|
||||
});
|
||||
|
||||
test("unknown provenance stays unknown and a disabled restart prunes expired files", async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.service.resolveProvenance = () => null;
|
||||
const logger = f.service.createLogger({ ...f.identity, sourceId: "manifest-official-claim" }, { sourceId: "manifest-official-claim", archiveSha256: "c".repeat(64) });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, null);
|
||||
assert.equal(records[0].namespace, null);
|
||||
assert.doesNotMatch(JSON.stringify(records), /manifest-official-claim/);
|
||||
await f.service.configure({ saveToDisk: false, verbose: false });
|
||||
const expired = await fs.readFile(f.service.sink.file(0), "utf8");
|
||||
await fs.writeFile(f.service.sink.file(0), expired.replace(/"timestamp":"[^"]+"/u, '"timestamp":"2000-01-01T00:00:00.000Z"'));
|
||||
const restored = new PluginLogService({ paths: f.paths });
|
||||
await restored.ready;
|
||||
assert.equal(restored.status().saveToDisk, false);
|
||||
assert.deepEqual(await fs.readdir(restored.directory), []);
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
test("noisy and malformed plugin diagnostics remain bounded with visible drops", async (t) => {
|
||||
const f = await fixture(t, { maxPendingBytes: 4096 });
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
const wide = {};
|
||||
for (let index = 0; index < 100; index++) wide[`key-${index}`] = Array(100).fill("synthetic ".repeat(100));
|
||||
await f.logger.write(Symbol("invalid"), { toString() { throw Error("must not coerce"); } }, wide);
|
||||
for (let index = 0; index < 1000; index++) void f.logger.write("info", "synthetic noise", { pluginId: "spoof", index });
|
||||
assert.ok(f.service.sink.pendingBytes <= 4096);
|
||||
assert.ok(f.service.status().droppedRecords > 0);
|
||||
for (const record of await f.records()) {
|
||||
assert.equal(record.pluginId, f.identity.pluginId);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(record)) < 16384);
|
||||
}
|
||||
});
|
||||
|
||||
test("source resolver URLs and userinfo are not persisted as source identity", async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.service.resolveProvenance = () => ({ sourceId: "https://user:SYNTHETIC_SECRET@example.test/source?key=SYNTHETIC_SECRET", sourceKind: "local", namespace: "user@SYNTHETIC_SECRET" });
|
||||
const logger = f.service.createLogger(f.identity, {});
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, null);
|
||||
assert.equal(records[0].namespace, null);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
|
||||
});
|
||||
|
||||
test("31 days of low-volume appends never retain an active record for seven days", async (t) => {
|
||||
let now = Date.UTC(2030, 0, 1);
|
||||
const f = await fixture(t, { clock: () => now });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
for (let day = 0; day < 31; day++) {
|
||||
now = Date.UTC(2030, 0, 1) + day * 86400000;
|
||||
f.logger.audit({ operation: `synthetic.day-${day}`, authorization: "not-required", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.ok(records.length > 0);
|
||||
for (const record of records) assert.ok(now - Date.parse(record.timestamp) < f.service.sink.retentionMs);
|
||||
}
|
||||
assert.doesNotMatch(JSON.stringify(await f.records()), /synthetic.day-0"/u);
|
||||
});
|
||||
|
||||
test("export excludes an expired active segment even when its mtime is recent", async (t) => {
|
||||
let now = Date.UTC(2030, 0, 1);
|
||||
const f = await fixture(t, { clock: () => now });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
f.logger.audit({ operation: "synthetic.expired", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
now += 86400000;
|
||||
f.logger.audit({ operation: "synthetic.day-one", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
// Appending refreshed mtime; export must use the first host record's age.
|
||||
await fs.utimes(f.service.sink.file(0), new Date(now), new Date(now));
|
||||
now += f.service.sink.retentionMs;
|
||||
await f.service.exportLogs();
|
||||
assert.equal(await fs.readFile(path.join(f.root, "export.jsonl"), "utf8"), "");
|
||||
f.logger.audit({ operation: "synthetic.new-segment", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
await f.service.exportLogs();
|
||||
const records = (await fs.readFile(path.join(f.root, "export.jsonl"), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].operation, "synthetic.new-segment");
|
||||
test("log redaction does not invoke accessors", () => {
|
||||
const value = {};
|
||||
Object.defineProperty(value, "secret", {
|
||||
enumerable: true,
|
||||
get() { throw new Error("accessor invoked"); },
|
||||
});
|
||||
assert.deepEqual(redactLogValue(value), { secret: "[REDACTED]" });
|
||||
});
|
||||
@@ -518,6 +518,10 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
},
|
||||
};
|
||||
const terminals = {
|
||||
shareSelection: (title) => {
|
||||
if (!config.enabledFeatures?.includes("terminal.selection-sharing")) return Promise.reject(new Error("Terminal selection sharing is unsupported"));
|
||||
return interact({ kind: "terminal.selection.read", title });
|
||||
},
|
||||
onDidChange: runtimeApi.terminalEvents.event,
|
||||
};
|
||||
const environment = {
|
||||
|
||||
@@ -51,7 +51,6 @@ function resolveDefaultRuntimeKind({ plugin }) {
|
||||
class RuntimeSupervisor {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.logService = options.logService;
|
||||
this.database = options.database;
|
||||
this.packageStore = options.packageStore;
|
||||
this.protocol = options.protocol;
|
||||
@@ -203,7 +202,6 @@ class RuntimeSupervisor {
|
||||
quarantinedAt: details.quarantinedAt,
|
||||
});
|
||||
}
|
||||
if (identity) this.logService?.audit(identity.logger.identity, { operation: `runtime.${status}`, authorization: "not-required", result: ["error", "quarantined"].includes(status) ? "error" : "ok" });
|
||||
this.#emitRuntimeState(identity, status, details);
|
||||
}
|
||||
|
||||
@@ -280,7 +278,7 @@ class RuntimeSupervisor {
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
|
||||
}
|
||||
let logger;
|
||||
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
|
||||
let runtime;
|
||||
const identity = Object.freeze({
|
||||
pluginId,
|
||||
@@ -290,7 +288,7 @@ class RuntimeSupervisor {
|
||||
securityPrincipal,
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
get logger() { return logger; },
|
||||
logger,
|
||||
assertCurrent: () => {
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
@@ -303,13 +301,11 @@ class RuntimeSupervisor {
|
||||
}
|
||||
},
|
||||
});
|
||||
logger = this.logService?.createLogger(identity, plugin) ?? new PluginLogger();
|
||||
const routes = this.rpcRegistry.createRoutes(identity);
|
||||
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
|
||||
const onProtocolError = (error) => {
|
||||
logger.audit({ operation: "runtime.protocol", authorization: "not-reached", result: "error", errorCode: error?.code });
|
||||
return logger.write("error", "Plugin protocol violation", { error: error?.message });
|
||||
};
|
||||
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
runtime = kind === "browser"
|
||||
? this.runtimeFactories.browser({
|
||||
electron: this.electron,
|
||||
@@ -545,7 +541,6 @@ class RuntimeSupervisor {
|
||||
}
|
||||
|
||||
async #cleanupRuntime(identity) {
|
||||
identity?.logger?.close();
|
||||
if (!identity || !this.runtimeCleanup) return;
|
||||
await this.runtimeCleanup(Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
|
||||
@@ -1002,30 +1002,3 @@ test('data-only themes never create a runtime on startup or explicit start',asyn
|
||||
await assert.rejects(fixture.supervisor.start(manifest.id),/no executable runtime/);assert.equal(fixture.runtimeOptions.length,0);
|
||||
await fixture.supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("runtime cleanup closes diagnostic loggers before stale runtime callbacks can write", async (t) => {
|
||||
const { PluginLogger } = require("./pluginLogger.cjs");
|
||||
const loggers = [];
|
||||
const events = [];
|
||||
const fixture = createFixture(t, () => ({
|
||||
async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
|
||||
async stop() {},
|
||||
}), { logService: {
|
||||
createLogger(identity) {
|
||||
const logger = new PluginLogger({ identity });
|
||||
loggers.push(logger);
|
||||
return logger;
|
||||
},
|
||||
audit(_identity, event) { events.push(event.operation); },
|
||||
} });
|
||||
t.after(() => fixture.supervisor.shutdown());
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
assert.equal(loggers[0].closed, false);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
assert.equal(loggers[0].closed, true);
|
||||
assert.deepEqual(events.slice(0, 3), ["runtime.starting", "runtime.running", "runtime.stopped"]);
|
||||
await fixture.runtimeOptions[0].logger.write("info", "synthetic stale callback");
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
await fixture.supervisor.shutdown();
|
||||
assert.equal(loggers[1].closed, true);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
const {randomBytes,createHmac}=require('node:crypto');
|
||||
const {createDefinitionValidator}=require('./contractValidator.cjs');
|
||||
const {PluginRpcError,RPC_ERRORS}=require('./rpcRouter.cjs');
|
||||
const schema=require('./generated/plugin-contract.schema.json');
|
||||
const LIMITS=schema.$defs.TerminalSelectionSharingLimits.const;
|
||||
const FEATURE=schema.$defs.TerminalSelectionSharingFeature.const;
|
||||
const validateTarget=createDefinitionValidator('HostTerminalTarget');
|
||||
const fail=(code,message)=>new PluginRpcError(RPC_ERRORS[code],message);
|
||||
/** Host-only resource authority. It never stores selected text or an endpoint DTO. */
|
||||
class TerminalSelectionAuthority {
|
||||
constructor(options){
|
||||
this.getWindow=options.getWindow??(()=>null);this.getManager=options.getManager??(()=>null);this.clock=options.clock??Date.now;
|
||||
this.key=randomBytes(32);this.target=null;this.handles=new Map();this.listeners=new Set();this.observed=null;this.disposables=[];
|
||||
this.timer=setInterval(()=>this.prune(),1000);this.timer.unref?.();
|
||||
}
|
||||
onDidInvalidate(listener){this.listeners.add(listener);return{dispose:()=>this.listeners.delete(listener)};}
|
||||
invalidate(){this.prune();for(const listener of this.listeners){try{listener();}catch{}}}
|
||||
observe(manager){
|
||||
if(this.observed===manager)return;
|
||||
for(const item of this.disposables)item?.dispose?.();this.disposables=[];this.observed=manager;
|
||||
for(const method of ['onSessionOwned','onSessionClosed','onWorkerExit'])if(typeof manager?.[method]==='function')this.disposables.push(manager[method](()=>this.invalidate()));
|
||||
}
|
||||
setTarget(sender,payload){
|
||||
if(!validateTarget(payload))throw fail('invalidArgument','Invalid selected terminal target');
|
||||
if(!sender || this.getWindow()?.webContents!==sender || sender.isDestroyed?.())throw fail('permissionDenied','Selected terminal belongs to another window');
|
||||
if(this.target?.sender===sender && this.target.sessionId===payload.sessionId)return true;
|
||||
this.target=payload.sessionId?{sender,sessionId:payload.sessionId}:null;this.invalidate();return true;
|
||||
}
|
||||
capture(sender,context){
|
||||
const features=[...(context.manifest?.features?.required??[]),...(context.manifest?.features?.optional??[])];
|
||||
if(!features.includes(FEATURE))throw fail('unsupported','Terminal selection sharing feature was not declared');
|
||||
const manager=this.getManager();this.observe(manager);
|
||||
if(!this.target || this.target.sender!==sender)throw fail('failedPrecondition','Select a live terminal in the main window first');
|
||||
const {sessionId}=this.target;const binding=manager?.getPluginSessionBinding?.(sessionId,sender.id);
|
||||
if(!binding)throw fail('unavailable','Selected terminal has no active owned worker connection');
|
||||
return Object.freeze({sender,manager,sessionId,epoch:binding.epoch,generation:binding.generation,
|
||||
resource:`terminal-session:${sessionId}:window:${sender.id}:worker:${binding.epoch}:generation:${binding.generation}`});
|
||||
}
|
||||
isCurrent(target){
|
||||
if(!target || this.target?.sender!==target.sender || this.target?.sessionId!==target.sessionId || target.sender.isDestroyed?.() || this.getManager()!==target.manager)return false;
|
||||
const current=target.manager.getPluginSessionBinding(target.sessionId,target.sender.id);
|
||||
return !!current && current.epoch===target.epoch && current.generation===target.generation;
|
||||
}
|
||||
assertCurrent(target){if(!this.isCurrent(target))throw fail('cancelled','Selected terminal connection or owner changed');}
|
||||
prune(){for(const [token,entry]of this.handles)if(entry.expiresAt<=this.clock()||!this.isCurrent(entry.target))this.handles.delete(token);}
|
||||
issue(target,context){
|
||||
this.assertCurrent(target);this.prune();
|
||||
if(this.handles.size>=LIMITS.handlesTotal || [...this.handles.values()].filter(e=>e.runtimeId===context.runtimeId).length>=LIMITS.handlesPerRuntime)throw fail('resourceExhausted','Terminal selection handle limit reached');
|
||||
const expiresAt=this.clock()+LIMITS.handleTtlMs,nonce=randomBytes(24).toString('base64url');
|
||||
const signature=createHmac('sha256',this.key).update(JSON.stringify([nonce,target.resource,context.pluginId,context.pluginVersion,context.runtimeId,context.securityPrincipal??null,expiresAt])).digest('base64url');
|
||||
const handle=`${nonce}.${signature}`;
|
||||
this.handles.set(handle,{target,expiresAt,pluginId:context.pluginId,pluginVersion:context.pluginVersion,runtimeId:context.runtimeId,securityPrincipal:context.securityPrincipal});
|
||||
return {handle,expiresAt};
|
||||
}
|
||||
resolve(handle,context){
|
||||
this.prune();const entry=this.handles.get(handle);
|
||||
if(!entry || entry.pluginId!==context.pluginId || entry.pluginVersion!==context.pluginVersion || entry.runtimeId!==context.runtimeId || entry.securityPrincipal!==context.securityPrincipal)throw fail('permissionDenied','Terminal selection handle is unavailable');
|
||||
this.assertCurrent(entry.target);return entry.target;
|
||||
}
|
||||
releaseRuntime(runtimeId){for(const [key,entry]of this.handles)if(entry.runtimeId===runtimeId)this.handles.delete(key);}
|
||||
releasePlugin(pluginId){for(const [key,entry]of this.handles)if(entry.pluginId===pluginId)this.handles.delete(key);}
|
||||
releaseWindow(sender){if(this.target?.sender===sender)this.target=null;this.invalidate();}
|
||||
clearHandles(){this.handles.clear();}
|
||||
shutdown(){clearInterval(this.timer);for(const d of this.disposables)d?.dispose?.();this.handles.clear();this.listeners.clear();this.target=null;this.key.fill(0);}
|
||||
}
|
||||
module.exports={TerminalSelectionAuthority,FEATURE,LIMITS};
|
||||
@@ -122,11 +122,7 @@ function createPreloadApi(ctx) {
|
||||
}),
|
||||
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
|
||||
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
|
||||
getPluginLogStatus: () => ipcRenderer.invoke("netcatty:plugins:log-status"),
|
||||
configurePluginLogs: (settings) => ipcRenderer.invoke("netcatty:plugins:log-configure", settings),
|
||||
openPluginLogFolder: () => ipcRenderer.invoke("netcatty:plugins:log-open"),
|
||||
exportPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-export"),
|
||||
clearPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-clear"),
|
||||
setPluginTerminalTarget: (target) => ipcRenderer.invoke("netcatty:plugins:terminal-target", target),
|
||||
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
|
||||
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
|
||||
onPluginThemesChanged: (callback) => {
|
||||
|
||||
Vendored
+1
-15
@@ -439,22 +439,7 @@ declare global {
|
||||
| NetcattyPluginAuthenticationChallengeOpenEvent
|
||||
| NetcattyPluginAuthenticationChallengeCancelEvent;
|
||||
|
||||
interface NetcattyPluginLogStatus {
|
||||
saveToDisk: boolean;
|
||||
verbose: boolean;
|
||||
droppedRecords: number;
|
||||
writeErrors: number;
|
||||
pendingBytes: number;
|
||||
maxBytes: number;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
interface NetcattyBridge {
|
||||
getPluginLogStatus?(): Promise<NetcattyPluginLogStatus>;
|
||||
configurePluginLogs?(settings: { saveToDisk: boolean; verbose: boolean }): Promise<NetcattyPluginLogStatus>;
|
||||
openPluginLogFolder?(): Promise<NetcattyPluginLogStatus>;
|
||||
exportPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
clearPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
|
||||
@@ -469,6 +454,7 @@ declare global {
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
uninstallPlugin?(pluginId: string): Promise<boolean>;
|
||||
setPluginTerminalTarget?(target: import("@netcatty/plugin-contract").HostTerminalTarget): Promise<boolean>;
|
||||
getPluginThemes?(): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
applyPluginTheme?(request: import("@netcatty/plugin-contract").ThemePackApplyRequest): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
onPluginThemesChanged?(callback: () => void): () => void;
|
||||
|
||||
@@ -2221,26 +2221,6 @@ html[data-theme-transition] *::after {
|
||||
--border: var(--host-security-border) !important;
|
||||
--input: var(--host-security-input) !important;
|
||||
--ring: var(--host-security-ring) !important;
|
||||
/* Tailwind theme aliases resolve where declared; pin them in this scope too. */
|
||||
--color-background: hsl(var(--host-security-background)) !important;
|
||||
--color-foreground: hsl(var(--host-security-foreground)) !important;
|
||||
--color-card: hsl(var(--host-security-card)) !important;
|
||||
--color-card-foreground: hsl(var(--host-security-card-foreground)) !important;
|
||||
--color-popover: hsl(var(--host-security-popover)) !important;
|
||||
--color-popover-foreground: hsl(var(--host-security-popover-foreground)) !important;
|
||||
--color-primary: hsl(var(--host-security-primary)) !important;
|
||||
--color-primary-foreground: hsl(var(--host-security-primary-foreground)) !important;
|
||||
--color-secondary: hsl(var(--host-security-secondary)) !important;
|
||||
--color-secondary-foreground: hsl(var(--host-security-secondary-foreground)) !important;
|
||||
--color-muted: hsl(var(--host-security-muted)) !important;
|
||||
--color-muted-foreground: hsl(var(--host-security-muted-foreground)) !important;
|
||||
--color-accent: hsl(var(--host-security-accent)) !important;
|
||||
--color-accent-foreground: hsl(var(--host-security-accent-foreground)) !important;
|
||||
--color-destructive: hsl(var(--host-security-destructive)) !important;
|
||||
--color-destructive-foreground: hsl(var(--host-security-destructive-foreground)) !important;
|
||||
--color-border: hsl(var(--host-security-border)) !important;
|
||||
--color-input: hsl(var(--host-security-input)) !important;
|
||||
--color-ring: hsl(var(--host-security-ring)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
background-color: hsl(var(--background)) !important;
|
||||
border-color: hsl(var(--border)) !important;
|
||||
|
||||
@@ -369,6 +369,8 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
const allFeatures = new Set([...requiredFeatures, ...(manifest.features?.optional ?? [])]);
|
||||
if ((manifest.permissions?.optional ?? []).some((p) => p === 'terminal.selection.read') && !allFeatures.has('terminal.selection-sharing')) errors.push('Terminal selection sharing requires terminal.selection-sharing');
|
||||
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
|
||||
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
|
||||
@@ -333,6 +333,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -369,6 +372,9 @@
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SharedTerminalSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -479,6 +485,11 @@
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"terminalSessionId": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -664,7 +675,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"NonResourceScopedPermission": {
|
||||
@@ -698,7 +710,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"ResourceScopedPermission": {
|
||||
@@ -770,9 +783,16 @@
|
||||
"RequiredPermissionDeclaration": {
|
||||
"description": "Required resource-scoped permissions must name their complete activation-time approval bounds.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/NonResourceScopedPermission" },
|
||||
{ "$ref": "#/$defs/RequiredPermissionResourceDeclaration" }
|
||||
]
|
||||
{
|
||||
"$ref": "#/$defs/NonResourceScopedPermission"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
|
||||
}
|
||||
],
|
||||
"not": {
|
||||
"const": "terminal.selection.read"
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"type": "object",
|
||||
@@ -3058,6 +3078,77 @@
|
||||
"entries",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
"TerminalSelectionSharingFeature": {
|
||||
"const": "terminal.selection-sharing"
|
||||
},
|
||||
"TerminalSelectionSharingLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"handleTtlMs": 120000,
|
||||
"handlesPerRuntime": 4,
|
||||
"handlesTotal": 64
|
||||
}
|
||||
},
|
||||
"HostTerminalTarget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostTerminalSelectionReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "terminal.selection.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SharedTerminalSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 64,
|
||||
"maxLength": 256
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"handle",
|
||||
"expiresAt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,9 +205,10 @@ export type HostInteractionPrompt = {
|
||||
phase: "request" | "preview";
|
||||
request: HostInteractionRequest;
|
||||
previewText?: string;
|
||||
terminalSessionId?: string;
|
||||
};
|
||||
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest) | (HostTerminalSelectionReadRequest);
|
||||
|
||||
export type HostInteractionResponse = {
|
||||
requestId: string;
|
||||
@@ -220,7 +221,7 @@ export type HostInteractionResult = ({
|
||||
cancelled: true;
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true);
|
||||
value: (string) | (true) | (SharedTerminalSelection);
|
||||
});
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
@@ -237,6 +238,15 @@ export type HostQuickPickRequest = {
|
||||
|
||||
export type HostSingleLineInputText = string;
|
||||
|
||||
export type HostTerminalSelectionReadRequest = {
|
||||
kind: "terminal.selection.read";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type HostTerminalTarget = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -431,7 +441,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -587,7 +597,7 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read";
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
@@ -811,6 +821,12 @@ export type SettingOption = {
|
||||
|
||||
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
|
||||
|
||||
export type SharedTerminalSelection = {
|
||||
text: string;
|
||||
handle: string;
|
||||
expiresAt: SafePositiveInteger;
|
||||
};
|
||||
|
||||
export type StreamChunkByteLength = number;
|
||||
|
||||
export type StreamChunkData = ({
|
||||
@@ -1029,6 +1045,10 @@ export type TerminalInterceptorReadyFrame = {
|
||||
|
||||
export type TerminalInterceptorWindowBytes = number;
|
||||
|
||||
export type TerminalSelectionSharingFeature = "terminal.selection-sharing";
|
||||
|
||||
export type TerminalSelectionSharingLimits = {"textBytes":4096,"handleTtlMs":120000,"handlesPerRuntime":4,"handlesTotal":64};
|
||||
|
||||
export type TerminalSessionSnapshot = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
|
||||
@@ -60,3 +60,5 @@ export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const
|
||||
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
|
||||
|
||||
export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satisfies import("./generated/plugin-contract.js").ThemePackFeature;
|
||||
|
||||
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
SyncWriteObjectPayload,
|
||||
SyncWriteObjectResult,
|
||||
TerminalSessionSnapshot,
|
||||
SharedTerminalSelection,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
export type * from "@netcatty/plugin-contract";
|
||||
@@ -626,6 +627,8 @@ export type SyncProviderHandler = Readonly<{
|
||||
}>;
|
||||
|
||||
export interface PluginTerminalSessions {
|
||||
/** Explicit host Preview then Share; no screen/history query or automatic read. */
|
||||
shareSelection(title: string): Promise<SharedTerminalSelection | undefined>;
|
||||
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
@@ -935,3 +938,5 @@ export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
|
||||
Reference in new issue
Block a user