@moretea-labs/forge 1.5.1 → 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.
Files changed (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. 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; allowedDomains: string[] } {
369
+ export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
387
370
  const config = loadConfig(repoRoot);
388
- return { enabled: config.enabled, allowedDomains: [...(config.allowedDomains ?? [])] };
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
- if (!currentRef) {
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 allowed domains through the persistent Playwright profile.',
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 allowed URL and persist a reusable session id.',
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 allowed URL with the persistent profile and save a lightweight session.',
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 allowed URL.',
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 hostnameAllowed(hostname: string, allowedDomains?: string[]): boolean {
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) || !hostnameAllowed(parsed.hostname, allowedDomains)) {
31
- throw new Error(`URL is outside the browser handoff allowlist: ${url}`);
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
- assertAllowed(metadata.url, spec.allowedDomains);
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
- assertAllowed(spec.url, spec.allowedDomains);
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
- assertAllowed(page.url(), spec.allowedDomains);
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, optionally activate it, and create a bounded interaction session.',
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 an existing desktop interaction session.',
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: 'Type text into one selected Accessibility element in an existing interaction session.',
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, optionally activating an existing interaction session first.',
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: 'Activate one explicit desktop session and send Command+C. This foregrounds the target application by design.',
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: 'Activate one explicit desktop session and send Command+V. This foregrounds the target application by design.',
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 application routing.',
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
- if (options.expectedRevision !== undefined && existing?.revision !== options.expectedRevision) {
295
- throw new Error(`EXTERNAL_PLUGIN_REGISTRATION_REVISION_CONFLICT: expected ${options.expectedRevision}, current ${existing?.revision ?? 0}`);
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: (existing?.revision ?? 0) + 1,
301
- registrationFingerprint: registrationFingerprint(normalized),
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
- desktopPluginAdapter,
23
+ pluginManagementAdapter,
24
24
  ];
25
25
 
26
26
  export function listFirstPartyPluginAdapters(): readonly AssistantPluginAdapter[] {