@moretea-labs/forge 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/DESIGN.md +7 -2
- package/README.md +7 -7
- package/README.zh-CN.md +5 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/forge-chatgpt-mcp-setup.md +27 -3
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +6 -4
- package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +31 -20
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +52 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +161 -18
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- package/src/runtime/safe-tooling/browser-safe.ts +0 -133
|
@@ -104,24 +104,6 @@ export function buildModelControlPlaneSummary(env: NodeJS.ProcessEnv = process.e
|
|
|
104
104
|
|
|
105
105
|
export function deepSeekFunctionToolManifest(): Array<Record<string, unknown>> {
|
|
106
106
|
return [
|
|
107
|
-
{
|
|
108
|
-
type: 'function',
|
|
109
|
-
function: {
|
|
110
|
-
name: 'forge_web_target_snapshot',
|
|
111
|
-
description: 'Create a read-only snapshot for a pre-allowed web target by target_key and path. Does not accept arbitrary URLs.',
|
|
112
|
-
parameters: {
|
|
113
|
-
type: 'object',
|
|
114
|
-
properties: {
|
|
115
|
-
target_key: { type: 'string' },
|
|
116
|
-
path: { type: 'string' },
|
|
117
|
-
capture: { type: 'string', enum: ['title', 'text', 'screenshot'] },
|
|
118
|
-
max_chars: { type: 'number' },
|
|
119
|
-
},
|
|
120
|
-
required: ['target_key'],
|
|
121
|
-
additionalProperties: false,
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
107
|
{
|
|
126
108
|
type: 'function',
|
|
127
109
|
function: {
|
|
@@ -267,20 +249,6 @@ export function prepareDeepSeekToolCall(functionName: string, functionArguments:
|
|
|
267
249
|
opaquePayloadAccepted: false as const,
|
|
268
250
|
};
|
|
269
251
|
switch (functionName) {
|
|
270
|
-
case 'forge_web_target_snapshot':
|
|
271
|
-
return {
|
|
272
|
-
provider: 'deepseek',
|
|
273
|
-
accepted: true,
|
|
274
|
-
functionName,
|
|
275
|
-
mappedOperation: 'web_target_snapshot',
|
|
276
|
-
mappedArguments: {
|
|
277
|
-
target_key: functionArguments.target_key,
|
|
278
|
-
path: functionArguments.path,
|
|
279
|
-
capture: functionArguments.capture,
|
|
280
|
-
max_chars: functionArguments.max_chars,
|
|
281
|
-
},
|
|
282
|
-
safety,
|
|
283
|
-
};
|
|
284
252
|
case 'forge_plugin_config_summary':
|
|
285
253
|
return {
|
|
286
254
|
provider: 'deepseek',
|
|
@@ -69,7 +69,6 @@ interface BrowserPluginConfig {
|
|
|
69
69
|
nativeAttachMode?: BrowserNativeAttachMode;
|
|
70
70
|
nativeBrowserCandidates?: MacOsBrowserProduct[];
|
|
71
71
|
defaultTimeoutMs?: number;
|
|
72
|
-
allowedDomains?: string[];
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
interface BrowserTabResumeState {
|
|
@@ -130,7 +129,6 @@ type BrowserContextLike = {
|
|
|
130
129
|
pages(): PageLike[];
|
|
131
130
|
newPage(): Promise<PageLike>;
|
|
132
131
|
close(): Promise<void>;
|
|
133
|
-
route(pattern: string, handler: (route: RouteLike) => Promise<void> | void): Promise<void>;
|
|
134
132
|
};
|
|
135
133
|
|
|
136
134
|
type BrowserLike = {
|
|
@@ -168,13 +166,6 @@ type PageLike = {
|
|
|
168
166
|
keyboard?: { press(key: string): Promise<void> };
|
|
169
167
|
};
|
|
170
168
|
|
|
171
|
-
type RequestLike = { url(): string };
|
|
172
|
-
type RouteLike = {
|
|
173
|
-
request(): RequestLike;
|
|
174
|
-
continue(): Promise<void>;
|
|
175
|
-
abort(errorCode?: string): Promise<void>;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
169
|
type PlaywrightRuntime = {
|
|
179
170
|
chromium: {
|
|
180
171
|
launchPersistentContext(userDataDir: string, options: Record<string, unknown>): Promise<BrowserContextLike>;
|
|
@@ -280,13 +271,6 @@ function browserProductList(value: unknown): MacOsBrowserProduct[] | undefined {
|
|
|
280
271
|
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
|
|
281
272
|
}
|
|
282
273
|
|
|
283
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
284
|
-
if (!Array.isArray(value)) return undefined;
|
|
285
|
-
const normalized = value
|
|
286
|
-
.map((entry) => String(entry).trim().toLowerCase())
|
|
287
|
-
.filter(Boolean);
|
|
288
|
-
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
|
|
289
|
-
}
|
|
290
274
|
|
|
291
275
|
function stringList(value: unknown): string[] | undefined {
|
|
292
276
|
if (!Array.isArray(value)) return undefined;
|
|
@@ -362,7 +346,6 @@ function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig
|
|
|
362
346
|
nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
|
|
363
347
|
nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['vivaldi', 'chrome'],
|
|
364
348
|
defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
|
|
365
|
-
allowedDomains: stringArray(raw.allowedDomains),
|
|
366
349
|
};
|
|
367
350
|
}
|
|
368
351
|
|
|
@@ -383,9 +366,9 @@ function loadConfig(repoRoot: string): BrowserPluginConfig {
|
|
|
383
366
|
return normalizeConfig(readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot)) ?? {});
|
|
384
367
|
}
|
|
385
368
|
|
|
386
|
-
export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean
|
|
369
|
+
export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
|
|
387
370
|
const config = loadConfig(repoRoot);
|
|
388
|
-
return { enabled: config.enabled
|
|
371
|
+
return { enabled: config.enabled };
|
|
389
372
|
}
|
|
390
373
|
|
|
391
374
|
function saveConfig(repoRoot: string, patch: Partial<BrowserPluginConfig>): BrowserPluginConfig {
|
|
@@ -429,27 +412,6 @@ function normalizedUrl(value: unknown): string {
|
|
|
429
412
|
return parsed.toString();
|
|
430
413
|
}
|
|
431
414
|
|
|
432
|
-
function isRemoteHttpUrl(url: string): boolean {
|
|
433
|
-
try {
|
|
434
|
-
const parsed = new URL(url);
|
|
435
|
-
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
436
|
-
} catch {
|
|
437
|
-
return false;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function isUrlAllowed(url: string, config: BrowserPluginConfig): boolean {
|
|
442
|
-
if (!config.allowedDomains || config.allowedDomains.length === 0) return true;
|
|
443
|
-
if (!isRemoteHttpUrl(url)) return true;
|
|
444
|
-
const hostname = new URL(url).hostname.toLowerCase();
|
|
445
|
-
return config.allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function assertUrlAllowed(url: string, config: BrowserPluginConfig): void {
|
|
449
|
-
if (isUrlAllowed(url, config)) return;
|
|
450
|
-
const hostname = new URL(url).hostname.toLowerCase();
|
|
451
|
-
throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', `Domain is not allowed: ${hostname}`, { retryable: false });
|
|
452
|
-
}
|
|
453
415
|
|
|
454
416
|
function waitUntil(value: unknown): WaitUntil {
|
|
455
417
|
return value === 'load' || value === 'networkidle' || value === 'domcontentloaded' ? value : 'domcontentloaded';
|
|
@@ -561,9 +523,6 @@ function validateConfig(config: BrowserPluginConfig): string[] {
|
|
|
561
523
|
|
|
562
524
|
function configWarnings(config: BrowserPluginConfig): string[] {
|
|
563
525
|
const warnings: string[] = [];
|
|
564
|
-
if (!config.allowedDomains || config.allowedDomains.length === 0) {
|
|
565
|
-
warnings.push('allowedDomains is empty; browser actions can target any domain.');
|
|
566
|
-
}
|
|
567
526
|
if (config.profileMode === 'custom') {
|
|
568
527
|
warnings.push('Custom profile mode uses the configured browser profile directly. If the browser reports the profile is in use, fully close the matching browser instance first.');
|
|
569
528
|
if (!config.executablePath && (config.browserChannel ?? 'chromium') === 'chromium') {
|
|
@@ -695,17 +654,6 @@ async function delay(ms: number): Promise<void> {
|
|
|
695
654
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
696
655
|
}
|
|
697
656
|
|
|
698
|
-
async function applyDomainGuard(context: BrowserContextLike, config: BrowserPluginConfig): Promise<void> {
|
|
699
|
-
if (!config.allowedDomains || config.allowedDomains.length === 0) return;
|
|
700
|
-
await context.route('**/*', async (route) => {
|
|
701
|
-
const url = route.request().url();
|
|
702
|
-
if (isUrlAllowed(url, config)) {
|
|
703
|
-
await route.continue();
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
await route.abort('blockedbyclient');
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
657
|
|
|
710
658
|
function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMode: BrowserMode = 'managed_persistent', sessionId?: string): BrowserProfileSelection {
|
|
711
659
|
if (activeMode === 'isolated') {
|
|
@@ -848,6 +796,14 @@ function comparableUrl(value: string): string {
|
|
|
848
796
|
}
|
|
849
797
|
}
|
|
850
798
|
|
|
799
|
+
function sameOrigin(left: string, right: string): boolean {
|
|
800
|
+
try {
|
|
801
|
+
return new URL(left).origin === new URL(right).origin;
|
|
802
|
+
} catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
851
807
|
function isBlankPage(url: string): boolean {
|
|
852
808
|
return !url || url === 'about:blank' || url === 'chrome://newtab/';
|
|
853
809
|
}
|
|
@@ -1103,7 +1059,6 @@ async function openManagedContext(
|
|
|
1103
1059
|
assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
|
|
1104
1060
|
mkdirSync(profile.profileDir, { recursive: true });
|
|
1105
1061
|
const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
|
|
1106
|
-
await applyDomainGuard(context, config);
|
|
1107
1062
|
const selected = await selectPage(context, target);
|
|
1108
1063
|
const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1109
1064
|
let response: { status?: () => number } | null | undefined;
|
|
@@ -1168,7 +1123,6 @@ async function openAttachedContext(
|
|
|
1168
1123
|
try {
|
|
1169
1124
|
const context = browser.contexts()[0] ?? (browser.newContext ? await browser.newContext() : undefined);
|
|
1170
1125
|
if (!context) throw new Error('CDP connection did not expose a browser context.');
|
|
1171
|
-
await applyDomainGuard(context, config);
|
|
1172
1126
|
const selected = await selectPage(context, target, { allowBlank: false, allowNewPage: false, requireOwnedToken: true });
|
|
1173
1127
|
const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1174
1128
|
let response: { status?: () => number } | null | undefined;
|
|
@@ -1275,7 +1229,11 @@ async function openNativeAttachedContext(
|
|
|
1275
1229
|
}
|
|
1276
1230
|
if (comparableUrl(page.url()) !== comparableUrl(target.url)) {
|
|
1277
1231
|
const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
1278
|
-
|
|
1232
|
+
const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
|
|
1233
|
+
const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
|
|
1234
|
+
if (preserveOwnedSameOriginDrift) {
|
|
1235
|
+
sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Plugin-owned tab navigated within the same origin; preserved the live tab and refreshed its session URL.', savedTab };
|
|
1236
|
+
} else if (!currentRef) {
|
|
1279
1237
|
await page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
|
|
1280
1238
|
} else {
|
|
1281
1239
|
const replacement = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
|
|
@@ -1292,7 +1250,6 @@ async function openNativeAttachedContext(
|
|
|
1292
1250
|
await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
|
|
1293
1251
|
}
|
|
1294
1252
|
const pageUrl = normalizedUrl(page.url());
|
|
1295
|
-
assertUrlAllowed(pageUrl, config);
|
|
1296
1253
|
const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
1297
1254
|
if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
|
|
1298
1255
|
const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
|
|
@@ -1386,7 +1343,6 @@ async function withPage<T>(
|
|
|
1386
1343
|
run: (page: PageLike, diagnostics: PageDiagnostics, connection: BrowserConnectionSummary) => Promise<T>,
|
|
1387
1344
|
options: { persistSession?: boolean } = {},
|
|
1388
1345
|
): Promise<T> {
|
|
1389
|
-
assertUrlAllowed(target.url, config);
|
|
1390
1346
|
const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
|
|
1391
1347
|
let lastError: unknown;
|
|
1392
1348
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
@@ -1396,7 +1352,6 @@ async function withPage<T>(
|
|
|
1396
1352
|
const result = await run(handle.page, handle.diagnostics, handle.connection);
|
|
1397
1353
|
if (options.persistSession) {
|
|
1398
1354
|
const identity = await readPageIdentity(handle.page, handle.connection);
|
|
1399
|
-
assertUrlAllowed(identity.url, config);
|
|
1400
1355
|
saveSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
|
|
1401
1356
|
}
|
|
1402
1357
|
return result;
|
|
@@ -1590,7 +1545,6 @@ async function finalizeInteractiveAction(
|
|
|
1590
1545
|
const identity = await readPageIdentity(page, connection);
|
|
1591
1546
|
const title = identity.title;
|
|
1592
1547
|
const pageUrl = identity.url;
|
|
1593
|
-
assertUrlAllowed(pageUrl, config);
|
|
1594
1548
|
const session = sessionFromPage(target, pageUrl, title, connection);
|
|
1595
1549
|
saveSession(repoRoot, session);
|
|
1596
1550
|
let screenshot: BrowserActionScreenshot | undefined;
|
|
@@ -1671,7 +1625,7 @@ function capabilities(): AssistantPluginCapability[] {
|
|
|
1671
1625
|
{
|
|
1672
1626
|
capabilityId: 'browser-interaction',
|
|
1673
1627
|
title: 'Browser Interaction',
|
|
1674
|
-
description: 'Perform explicit form and pointer interactions on
|
|
1628
|
+
description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
|
|
1675
1629
|
scopes: ['browser.interact', 'browser.profile'],
|
|
1676
1630
|
actions: [
|
|
1677
1631
|
'click', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
|
|
@@ -1749,8 +1703,6 @@ function actions(): AssistantPluginActionDescriptor[] {
|
|
|
1749
1703
|
native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
|
|
1750
1704
|
native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
|
|
1751
1705
|
default_timeout_ms: { type: 'number' },
|
|
1752
|
-
allowed_domains: { type: 'array', items: { type: 'string' } },
|
|
1753
|
-
clear_allowed_domains: { type: 'boolean' },
|
|
1754
1706
|
},
|
|
1755
1707
|
additionalProperties: false,
|
|
1756
1708
|
},
|
|
@@ -1758,7 +1710,7 @@ function actions(): AssistantPluginActionDescriptor[] {
|
|
|
1758
1710
|
{
|
|
1759
1711
|
actionId: 'create_session',
|
|
1760
1712
|
title: 'Create browser session',
|
|
1761
|
-
description: 'Open an
|
|
1713
|
+
description: 'Open an HTTP(S) URL and persist a reusable session id.',
|
|
1762
1714
|
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1763
1715
|
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
|
|
1764
1716
|
argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
|
|
@@ -1826,7 +1778,7 @@ function actions(): AssistantPluginActionDescriptor[] {
|
|
|
1826
1778
|
{
|
|
1827
1779
|
actionId: 'open_page',
|
|
1828
1780
|
title: 'Open page',
|
|
1829
|
-
description: 'Open an
|
|
1781
|
+
description: 'Open an HTTP(S) URL with the persistent profile and save a lightweight session.',
|
|
1830
1782
|
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1831
1783
|
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1832
1784
|
argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
|
|
@@ -1834,7 +1786,7 @@ function actions(): AssistantPluginActionDescriptor[] {
|
|
|
1834
1786
|
{
|
|
1835
1787
|
actionId: 'navigate',
|
|
1836
1788
|
title: 'Navigate',
|
|
1837
|
-
description: 'Navigate an existing session or open a new page to an
|
|
1789
|
+
description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
|
|
1838
1790
|
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1839
1791
|
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1840
1792
|
argumentsSchema: sessionTargetSchema({}, ['url']),
|
|
@@ -2089,7 +2041,6 @@ function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, se
|
|
|
2089
2041
|
if (!config.enabled) return 'disabled';
|
|
2090
2042
|
if (!ready) return 'not ready';
|
|
2091
2043
|
if (sessionCount > 0) return 'session active';
|
|
2092
|
-
if (config.allowedDomains && config.allowedDomains.length > 0) return 'domain restricted';
|
|
2093
2044
|
return 'ready';
|
|
2094
2045
|
}
|
|
2095
2046
|
|
|
@@ -2212,7 +2163,6 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2212
2163
|
details: {
|
|
2213
2164
|
...baseDetails,
|
|
2214
2165
|
nativeAttachObservation: nativeObservation,
|
|
2215
|
-
allowedDomains: config.allowedDomains,
|
|
2216
2166
|
provider: config.browserMode === 'attach_preferred'
|
|
2217
2167
|
? 'cdp-or-macos-active-browser-or-persistent-context'
|
|
2218
2168
|
: 'playwright-persistent-context',
|
|
@@ -2310,7 +2260,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2310
2260
|
defaultTimeoutMs: typeof args.default_timeout_ms === 'number'
|
|
2311
2261
|
? positiveNumber(args.default_timeout_ms, DEFAULT_TIMEOUT_MS)
|
|
2312
2262
|
: current.defaultTimeoutMs,
|
|
2313
|
-
allowedDomains: args.clear_allowed_domains === true ? undefined : stringArray(args.allowed_domains) ?? current.allowedDomains,
|
|
2314
2263
|
});
|
|
2315
2264
|
const configErrors = validateConfig(config);
|
|
2316
2265
|
if (configErrors.length > 0) {
|
|
@@ -2366,7 +2315,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2366
2315
|
}
|
|
2367
2316
|
case 'request_human_handoff': {
|
|
2368
2317
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2369
|
-
assertUrlAllowed(target.url, current);
|
|
2370
2318
|
const nativeSession = target.existingSession;
|
|
2371
2319
|
const nativeTab = nativeSession?.browser?.tab;
|
|
2372
2320
|
const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
|
|
@@ -2398,7 +2346,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2398
2346
|
profileDirectory: profile.profileDirectory,
|
|
2399
2347
|
browserChannel: current.browserChannel,
|
|
2400
2348
|
executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
|
|
2401
|
-
allowedDomains: current.allowedDomains,
|
|
2402
2349
|
defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
2403
2350
|
nativeBrowser,
|
|
2404
2351
|
reason: stringValue(input.args.reason) ?? 'manual_review',
|
|
@@ -19,16 +19,10 @@ function delay(ms: number): Promise<void> {
|
|
|
19
19
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function
|
|
23
|
-
if (!allowedDomains || allowedDomains.length === 0) return true;
|
|
24
|
-
const normalized = hostname.toLowerCase();
|
|
25
|
-
return allowedDomains.some((domain) => normalized === domain || normalized.endsWith(`.${domain}`));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function assertAllowed(url: string, allowedDomains?: string[]): void {
|
|
22
|
+
function assertHttpUrl(url: string): void {
|
|
29
23
|
const parsed = new URL(url);
|
|
30
|
-
if (!['http:', 'https:'].includes(parsed.protocol)
|
|
31
|
-
throw new Error(`
|
|
24
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
25
|
+
throw new Error(`Browser handoff only supports HTTP(S) URLs: ${url}`);
|
|
32
26
|
}
|
|
33
27
|
}
|
|
34
28
|
|
|
@@ -105,7 +99,7 @@ async function runNativeHandoff(specPath: string, spec: BrowserHandoffLaunchSpec
|
|
|
105
99
|
if (resume) {
|
|
106
100
|
removeInteractionCommand(spec.repoRoot, 'browser', spec.interactionId, 'resume');
|
|
107
101
|
metadata = await readMacOsBrowserOwnedTabMetadata(native.product, native.ref, spec.defaultTimeoutMs);
|
|
108
|
-
|
|
102
|
+
assertHttpUrl(metadata.url);
|
|
109
103
|
const existing = existsSync(spec.sessionPath)
|
|
110
104
|
? readJsonFile<Record<string, unknown>>(spec.sessionPath, {})
|
|
111
105
|
: {};
|
|
@@ -146,7 +140,7 @@ async function main(): Promise<void> {
|
|
|
146
140
|
const specPath = process.argv[2];
|
|
147
141
|
if (!specPath) throw new Error('browser handoff launch spec path is required');
|
|
148
142
|
const spec = readJsonFile<BrowserHandoffLaunchSpec>(specPath);
|
|
149
|
-
|
|
143
|
+
assertHttpUrl(spec.url);
|
|
150
144
|
if (spec.nativeBrowser) {
|
|
151
145
|
await runNativeHandoff(specPath, spec);
|
|
152
146
|
return;
|
|
@@ -166,11 +160,6 @@ async function main(): Promise<void> {
|
|
|
166
160
|
title(): Promise<string>;
|
|
167
161
|
bringToFront?(): Promise<void>;
|
|
168
162
|
}>;
|
|
169
|
-
route(pattern: string, handler: (route: {
|
|
170
|
-
request(): { url(): string };
|
|
171
|
-
continue(): Promise<void>;
|
|
172
|
-
abort(errorCode?: string): Promise<void>;
|
|
173
|
-
}) => Promise<void> | void): Promise<void>;
|
|
174
163
|
close(): Promise<void>;
|
|
175
164
|
}>;
|
|
176
165
|
};
|
|
@@ -205,20 +194,6 @@ async function main(): Promise<void> {
|
|
|
205
194
|
|
|
206
195
|
try {
|
|
207
196
|
context = await playwright.chromium.launchPersistentContext(spec.profileDir, launchOptions);
|
|
208
|
-
await context.route('**/*', async (route) => {
|
|
209
|
-
const requestUrl = route.request().url();
|
|
210
|
-
try {
|
|
211
|
-
const parsed = new URL(requestUrl);
|
|
212
|
-
if (['http:', 'https:'].includes(parsed.protocol) && !hostnameAllowed(parsed.hostname, spec.allowedDomains)) {
|
|
213
|
-
await route.abort('blockedbyclient');
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
await route.abort('blockedbyclient');
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
await route.continue();
|
|
221
|
-
});
|
|
222
197
|
let page = context.pages()[0] ?? await context.newPage();
|
|
223
198
|
await page.goto(spec.url, { waitUntil: 'domcontentloaded', timeout: spec.defaultTimeoutMs });
|
|
224
199
|
if (page.bringToFront) await page.bringToFront();
|
|
@@ -263,7 +238,7 @@ async function main(): Promise<void> {
|
|
|
263
238
|
const resume = readInteractionCommand(spec.repoRoot, 'browser', spec.interactionId, 'resume');
|
|
264
239
|
if (resume) {
|
|
265
240
|
removeInteractionCommand(spec.repoRoot, 'browser', spec.interactionId, 'resume');
|
|
266
|
-
|
|
241
|
+
assertHttpUrl(page.url());
|
|
267
242
|
const existing = existsSync(spec.sessionPath)
|
|
268
243
|
? readJsonFile<Record<string, unknown>>(spec.sessionPath, {})
|
|
269
244
|
: {};
|
|
@@ -38,7 +38,6 @@ export interface BrowserHandoffLaunchSpec {
|
|
|
38
38
|
profileDirectory?: string;
|
|
39
39
|
browserChannel?: string;
|
|
40
40
|
executablePath?: string;
|
|
41
|
-
allowedDomains?: string[];
|
|
42
41
|
defaultTimeoutMs: number;
|
|
43
42
|
nativeBrowser?: { product: MacOsBrowserProduct; ref: MacOsBrowserTabRef };
|
|
44
43
|
expiresAt: string;
|
|
@@ -57,7 +56,6 @@ export interface BrowserHandoffStartInput {
|
|
|
57
56
|
profileDirectory?: string;
|
|
58
57
|
browserChannel?: string;
|
|
59
58
|
executablePath?: string;
|
|
60
|
-
allowedDomains?: string[];
|
|
61
59
|
defaultTimeoutMs: number;
|
|
62
60
|
nativeBrowser?: { product: MacOsBrowserProduct; ref: MacOsBrowserTabRef };
|
|
63
61
|
reason: string;
|
|
@@ -326,7 +324,6 @@ export async function startBrowserHandoff(input: BrowserHandoffStartInput): Prom
|
|
|
326
324
|
profileDirectory: input.profileDirectory,
|
|
327
325
|
browserChannel: input.browserChannel,
|
|
328
326
|
executablePath: input.executablePath,
|
|
329
|
-
allowedDomains: input.allowedDomains,
|
|
330
327
|
defaultTimeoutMs: input.defaultTimeoutMs,
|
|
331
328
|
nativeBrowser: input.nativeBrowser,
|
|
332
329
|
expiresAt,
|
|
@@ -735,6 +735,14 @@ export class MacOsAppleEventsPage {
|
|
|
735
735
|
if (this.targetRef) {
|
|
736
736
|
try {
|
|
737
737
|
const metadata = await this.refreshMetadata();
|
|
738
|
+
const transitionalUrl = !metadata.url
|
|
739
|
+
|| metadata.url === 'about:blank'
|
|
740
|
+
|| metadata.url === 'chrome://newtab/'
|
|
741
|
+
|| metadata.url === 'vivaldi://newtab/';
|
|
742
|
+
if (transitionalUrl) {
|
|
743
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
738
746
|
if (metadata.loading === false) return;
|
|
739
747
|
} catch {
|
|
740
748
|
// Fall through to DOM readiness when native loading metadata is unavailable.
|
|
@@ -68,7 +68,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
68
68
|
{
|
|
69
69
|
actionId: 'desktop_session_open',
|
|
70
70
|
title: 'Open desktop session',
|
|
71
|
-
description: 'Launch or locate one application
|
|
71
|
+
description: 'Launch or locate one application in the background and create a bounded interaction session without stealing foreground focus.',
|
|
72
72
|
readOnly: false,
|
|
73
73
|
risk: 'workspace_write',
|
|
74
74
|
confirmation: 'authorization',
|
|
@@ -83,7 +83,6 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
83
83
|
bundle_id: { type: 'string' },
|
|
84
84
|
app_name: { type: 'string' },
|
|
85
85
|
launch: { type: 'boolean' },
|
|
86
|
-
activate: { type: 'boolean' },
|
|
87
86
|
},
|
|
88
87
|
anyOf: [{ required: ['bundle_id'] }, { required: ['app_name'] }],
|
|
89
88
|
additionalProperties: false,
|
|
@@ -119,7 +118,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
119
118
|
{
|
|
120
119
|
actionId: 'desktop_press',
|
|
121
120
|
title: 'Press desktop element',
|
|
122
|
-
description: 'Press one Accessibility element in
|
|
121
|
+
description: 'Press one Accessibility element semantically in the background; pointer/coordinate fallback is intentionally unavailable.',
|
|
123
122
|
readOnly: false,
|
|
124
123
|
risk: 'workspace_write',
|
|
125
124
|
confirmation: 'authorization',
|
|
@@ -133,8 +132,6 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
133
132
|
properties: {
|
|
134
133
|
interaction_id: { type: 'string' },
|
|
135
134
|
selector: SELECTOR_SCHEMA,
|
|
136
|
-
coordinate_fallback: { type: 'boolean' },
|
|
137
|
-
force_coordinate: { type: 'boolean' },
|
|
138
135
|
},
|
|
139
136
|
required: ['interaction_id', 'selector'],
|
|
140
137
|
additionalProperties: false,
|
|
@@ -143,7 +140,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
143
140
|
{
|
|
144
141
|
actionId: 'desktop_type_text',
|
|
145
142
|
title: 'Type desktop text',
|
|
146
|
-
description: '
|
|
143
|
+
description: 'Set text through Accessibility in the background without synthetic keyboard input or application activation.',
|
|
147
144
|
readOnly: false,
|
|
148
145
|
risk: 'workspace_write',
|
|
149
146
|
confirmation: 'authorization',
|
|
@@ -167,7 +164,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
167
164
|
{
|
|
168
165
|
actionId: 'desktop_key',
|
|
169
166
|
title: 'Press desktop keys',
|
|
170
|
-
description: 'Send one bounded key chord
|
|
167
|
+
description: 'Send one bounded key chord only when the explicit target session is already foreground; the operator never activates it.',
|
|
171
168
|
readOnly: false,
|
|
172
169
|
risk: 'workspace_write',
|
|
173
170
|
confirmation: 'authorization',
|
|
@@ -182,7 +179,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
182
179
|
interaction_id: { type: 'string' },
|
|
183
180
|
keys: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' } },
|
|
184
181
|
},
|
|
185
|
-
required: ['keys'],
|
|
182
|
+
required: ['interaction_id', 'keys'],
|
|
186
183
|
additionalProperties: false,
|
|
187
184
|
},
|
|
188
185
|
},
|
|
@@ -222,7 +219,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
222
219
|
{
|
|
223
220
|
actionId: 'desktop_copy',
|
|
224
221
|
title: 'Copy from desktop session',
|
|
225
|
-
description: '
|
|
222
|
+
description: 'Send Command+C only when the explicit target session is already foreground; the operator never activates it.',
|
|
226
223
|
readOnly: false,
|
|
227
224
|
risk: 'workspace_write',
|
|
228
225
|
confirmation: 'authorization',
|
|
@@ -241,7 +238,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
241
238
|
{
|
|
242
239
|
actionId: 'desktop_paste',
|
|
243
240
|
title: 'Paste into desktop session',
|
|
244
|
-
description: '
|
|
241
|
+
description: 'Send Command+V only when the explicit target session is already foreground; the operator never activates it.',
|
|
245
242
|
readOnly: false,
|
|
246
243
|
risk: 'workspace_write',
|
|
247
244
|
confirmation: 'authorization',
|
|
@@ -260,7 +257,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
|
|
|
260
257
|
{
|
|
261
258
|
actionId: 'desktop_open_url',
|
|
262
259
|
title: 'Open desktop URL',
|
|
263
|
-
description: 'Open one absolute URL through macOS
|
|
260
|
+
description: 'Open one absolute URL through macOS background routing without activating the destination application.',
|
|
264
261
|
readOnly: false,
|
|
265
262
|
risk: 'workspace_write',
|
|
266
263
|
confirmation: 'authorization',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync } from 'fs';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
|
3
3
|
import { isAbsolute, join } from 'path';
|
|
4
4
|
import { controllerSystemRoot, ensureControllerHome } from '../../cli/repositories/controller-home';
|
|
5
5
|
import { readJsonFile, sanitizeFileComponent, writeJsonAtomic } from '../shared/json-files';
|
|
@@ -284,6 +284,34 @@ export function listExternalPluginRegistrations(controllerHome: string): Externa
|
|
|
284
284
|
.sort((left, right) => left.pluginId.localeCompare(right.pluginId));
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
export interface ExternalPluginRegistrationPreview {
|
|
288
|
+
pluginId: string;
|
|
289
|
+
currentRevision: number;
|
|
290
|
+
nextRevision: number;
|
|
291
|
+
registrationFingerprint: string;
|
|
292
|
+
currentFingerprint?: string;
|
|
293
|
+
wouldChange: boolean;
|
|
294
|
+
registration: Omit<ExternalPluginRegistration, 'revision' | 'registrationFingerprint' | 'installedAt' | 'updatedAt'>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function previewExternalPluginRegistration(
|
|
298
|
+
controllerHome: string,
|
|
299
|
+
input: ExternalPluginRegistrationInput,
|
|
300
|
+
): ExternalPluginRegistrationPreview {
|
|
301
|
+
const normalized = normalizeRegistrationInput(input);
|
|
302
|
+
const existing = getExternalPluginRegistration(controllerHome, normalized.pluginId);
|
|
303
|
+
const fingerprint = registrationFingerprint(normalized);
|
|
304
|
+
return {
|
|
305
|
+
pluginId: normalized.pluginId,
|
|
306
|
+
currentRevision: existing?.revision ?? 0,
|
|
307
|
+
nextRevision: existing && existing.registrationFingerprint === fingerprint ? existing.revision : (existing?.revision ?? 0) + 1,
|
|
308
|
+
registrationFingerprint: fingerprint,
|
|
309
|
+
currentFingerprint: existing?.registrationFingerprint,
|
|
310
|
+
wouldChange: existing?.registrationFingerprint !== fingerprint,
|
|
311
|
+
registration: normalized,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
287
315
|
export function installExternalPluginRegistration(
|
|
288
316
|
controllerHome: string,
|
|
289
317
|
input: ExternalPluginRegistrationInput,
|
|
@@ -291,14 +319,19 @@ export function installExternalPluginRegistration(
|
|
|
291
319
|
): ExternalPluginRegistration {
|
|
292
320
|
const normalized = normalizeRegistrationInput(input);
|
|
293
321
|
const existing = getExternalPluginRegistration(controllerHome, normalized.pluginId);
|
|
294
|
-
|
|
295
|
-
|
|
322
|
+
const fingerprint = registrationFingerprint(normalized);
|
|
323
|
+
// Identical desired state is idempotent, including a replay carrying the old
|
|
324
|
+
// expected revision after the first write committed.
|
|
325
|
+
if (existing?.registrationFingerprint === fingerprint) return existing;
|
|
326
|
+
const currentRevision = existing?.revision ?? 0;
|
|
327
|
+
if (options.expectedRevision !== undefined && currentRevision !== options.expectedRevision) {
|
|
328
|
+
throw new Error(`EXTERNAL_PLUGIN_REGISTRATION_REVISION_CONFLICT: expected ${options.expectedRevision}, current ${currentRevision}`);
|
|
296
329
|
}
|
|
297
330
|
const at = (options.now ?? new Date()).toISOString();
|
|
298
331
|
const next: ExternalPluginRegistration = {
|
|
299
332
|
...normalized,
|
|
300
|
-
revision:
|
|
301
|
-
registrationFingerprint:
|
|
333
|
+
revision: currentRevision + 1,
|
|
334
|
+
registrationFingerprint: fingerprint,
|
|
302
335
|
installedAt: existing?.installedAt ?? at,
|
|
303
336
|
updatedAt: at,
|
|
304
337
|
};
|
|
@@ -306,3 +339,49 @@ export function installExternalPluginRegistration(
|
|
|
306
339
|
writeJsonAtomic(externalPluginRegistrationPath(controllerHome, next.pluginId), next);
|
|
307
340
|
return next;
|
|
308
341
|
}
|
|
342
|
+
|
|
343
|
+
function registrationInputFromStored(
|
|
344
|
+
registration: ExternalPluginRegistration,
|
|
345
|
+
patch: Partial<Pick<ExternalPluginRegistrationInput, 'enabled'>> = {},
|
|
346
|
+
): ExternalPluginRegistrationInput {
|
|
347
|
+
return {
|
|
348
|
+
pluginId: registration.pluginId,
|
|
349
|
+
providerPluginId: registration.providerPluginId,
|
|
350
|
+
displayName: registration.displayName,
|
|
351
|
+
provider: registration.provider,
|
|
352
|
+
pluginVersion: registration.pluginVersion,
|
|
353
|
+
protocolVersion: registration.protocolVersion,
|
|
354
|
+
scope: registration.scope,
|
|
355
|
+
enabled: patch.enabled ?? registration.enabled,
|
|
356
|
+
transport: structuredClone(registration.transport),
|
|
357
|
+
lifecycle: registration.lifecycle ? structuredClone(registration.lifecycle) : undefined,
|
|
358
|
+
permissions: structuredClone(registration.permissions),
|
|
359
|
+
capabilities: structuredClone(registration.capabilities),
|
|
360
|
+
actions: structuredClone(registration.actions),
|
|
361
|
+
legacyIdentities: [...(registration.legacyIdentities ?? [])],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function disableExternalPluginRegistration(
|
|
366
|
+
controllerHome: string,
|
|
367
|
+
pluginId: string,
|
|
368
|
+
options: { expectedRevision?: number; now?: Date } = {},
|
|
369
|
+
): ExternalPluginRegistration {
|
|
370
|
+
const existing = getExternalPluginRegistration(controllerHome, pluginId);
|
|
371
|
+
if (!existing) throw new Error(`EXTERNAL_PLUGIN_REGISTRATION_NOT_FOUND: ${pluginId}`);
|
|
372
|
+
return installExternalPluginRegistration(controllerHome, registrationInputFromStored(existing, { enabled: false }), options);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function removeExternalPluginRegistration(
|
|
376
|
+
controllerHome: string,
|
|
377
|
+
pluginId: string,
|
|
378
|
+
options: { expectedRevision?: number } = {},
|
|
379
|
+
): ExternalPluginRegistration {
|
|
380
|
+
const existing = getExternalPluginRegistration(controllerHome, pluginId);
|
|
381
|
+
if (!existing) throw new Error(`EXTERNAL_PLUGIN_REGISTRATION_NOT_FOUND: ${pluginId}`);
|
|
382
|
+
if (options.expectedRevision !== undefined && existing.revision !== options.expectedRevision) {
|
|
383
|
+
throw new Error(`EXTERNAL_PLUGIN_REGISTRATION_REVISION_CONFLICT: expected ${options.expectedRevision}, current ${existing.revision}`);
|
|
384
|
+
}
|
|
385
|
+
rmSync(externalPluginRegistrationPath(controllerHome, existing.pluginId));
|
|
386
|
+
return existing;
|
|
387
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { appStoreConnectPluginAdapter } from './app-store-connect-adapter';
|
|
2
2
|
import { browserPluginAdapter } from './browser-adapter';
|
|
3
|
-
import { desktopPluginAdapter } from './desktop-adapter';
|
|
4
3
|
import { githubPluginAdapter } from './github-adapter';
|
|
5
4
|
import { gmailPluginAdapter } from './gmail-adapter';
|
|
6
5
|
import { googleCalendarPluginAdapter } from './google-calendar-adapter';
|
|
7
6
|
import { googleTasksPluginAdapter } from './google-tasks-adapter';
|
|
8
7
|
import { iosPluginAdapter } from './ios-adapter';
|
|
9
8
|
import { localSystemPluginAdapter } from './local-system-adapter';
|
|
9
|
+
import { pluginManagementAdapter } from './plugin-management-adapter';
|
|
10
10
|
import { resendPluginAdapter } from './resend-adapter';
|
|
11
11
|
import type { AssistantPluginAdapter } from './types';
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ const FIRST_PARTY_PLUGIN_ADAPTERS: readonly AssistantPluginAdapter[] = [
|
|
|
20
20
|
googleTasksPluginAdapter,
|
|
21
21
|
resendPluginAdapter,
|
|
22
22
|
localSystemPluginAdapter,
|
|
23
|
-
|
|
23
|
+
pluginManagementAdapter,
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
export function listFirstPartyPluginAdapters(): readonly AssistantPluginAdapter[] {
|