@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
@@ -14,6 +14,15 @@ import { CONTROL_PLANE_SCHEMA_VERSION } from '../control-plane/persistence/sqlit
14
14
  * `forge runtime service install` operation; staging alone never starts or
15
15
  * publishes anything.
16
16
  */
17
+ export const FORGE_MACOS_RUNTIME_SIGNING_IDENTIFIER = 'com.moretea.forge.runtime';
18
+
19
+ export interface MacOSRuntimeCodeSigning {
20
+ identifier: string;
21
+ teamIdentifier: string;
22
+ designatedRequirement: string;
23
+ authority: string;
24
+ }
25
+
17
26
  export interface StagedRuntimeRelease {
18
27
  releasePath: string;
19
28
  manifestPath: string;
@@ -22,7 +31,6 @@ export interface StagedRuntimeRelease {
22
31
  diagnosticArtifactIdentity?: string;
23
32
  browserNodeBridgeArtifactIdentity?: string;
24
33
  browserHandoffArtifactIdentity?: string;
25
- desktopHelperArtifactIdentity?: string;
26
34
  processRunnerArtifactIdentity?: string;
27
35
  checkRunnerArtifactIdentity?: string;
28
36
  externalPluginProbeArtifactIdentity?: string;
@@ -30,12 +38,15 @@ export interface StagedRuntimeRelease {
30
38
  codeGraphSidecarArtifactIdentity?: string;
31
39
  codeGraphLibraryArtifactIdentity?: string;
32
40
  controllerUiArtifactIdentity?: string;
41
+ macosCodeSigning?: MacOSRuntimeCodeSigning;
33
42
  manifestSha256: string;
34
43
  sourceCommit: string;
35
44
  }
36
45
 
37
46
  export interface RuntimeReleaseMaterializerDependencies {
38
47
  now?: () => number;
48
+ platform?: NodeJS.Platform;
49
+ signMacOSRuntime?: (input: { executable: string; controllerHome: string }) => MacOSRuntimeCodeSigning;
39
50
  uuid?: () => string;
40
51
  compileBinary?: (input: { sourceRoot: string; outputPath: string; entryPath?: string }) => { ok: boolean; stderr?: string; stdout?: string; error?: string };
41
52
  bundleNodeHost?: (input: { sourceRoot: string; outputPath: string; entryPath: string }) => { ok: boolean; stderr?: string; stdout?: string; error?: string };
@@ -59,6 +70,8 @@ export interface CandidateRuntimeStageReceiptV1 {
59
70
  }
60
71
 
61
72
  export interface CandidateRuntimeReleaseStagerDependencies {
73
+ platform?: NodeJS.Platform;
74
+ inspectMacOSRuntime?: (executable: string) => MacOSRuntimeCodeSigning;
62
75
  runCandidateStager?: (input: {
63
76
  bunExecutable: string;
64
77
  scriptPath: string;
@@ -78,6 +91,95 @@ function sha256(path: string): string {
78
91
  return createHash('sha256').update(readFileSync(path)).digest('hex');
79
92
  }
80
93
 
94
+ function parseMacOSRuntimeCodeSigning(value: unknown, context: string): MacOSRuntimeCodeSigning | undefined {
95
+ if (value === undefined) return undefined;
96
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${context}: macosCodeSigning must be an object`);
97
+ const record = value as Record<string, unknown>;
98
+ const field = (name: keyof MacOSRuntimeCodeSigning): string => {
99
+ const entry = record[name];
100
+ if (typeof entry !== 'string' || !entry.trim()) throw new Error(`${context}: macosCodeSigning.${name} is required`);
101
+ return entry.trim();
102
+ };
103
+ const signing = {
104
+ identifier: field('identifier'),
105
+ teamIdentifier: field('teamIdentifier'),
106
+ designatedRequirement: field('designatedRequirement'),
107
+ authority: field('authority'),
108
+ };
109
+ if (signing.identifier !== FORGE_MACOS_RUNTIME_SIGNING_IDENTIFIER) {
110
+ throw new Error(`${context}: macosCodeSigning.identifier must be ${FORGE_MACOS_RUNTIME_SIGNING_IDENTIFIER}`);
111
+ }
112
+ if (!/^[A-Z0-9]{10}$/.test(signing.teamIdentifier)) throw new Error(`${context}: macosCodeSigning.teamIdentifier is invalid`);
113
+ if (!signing.authority.startsWith('Developer ID Application: ')) throw new Error(`${context}: macosCodeSigning.authority must be Developer ID Application`);
114
+ if (!signing.designatedRequirement.includes(`identifier \"${FORGE_MACOS_RUNTIME_SIGNING_IDENTIFIER}\"`)
115
+ || !signing.designatedRequirement.includes('anchor apple generic')
116
+ || !signing.designatedRequirement.includes(`certificate leaf[subject.OU] = ${signing.teamIdentifier}`)) {
117
+ throw new Error(`${context}: macosCodeSigning.designatedRequirement is not the stable Developer ID contract`);
118
+ }
119
+ return signing;
120
+ }
121
+
122
+ function readActiveMacOSRuntimeTeam(controllerHome: string): string | undefined {
123
+ const authorityPath = join(resolve(controllerHome), 'runtime', 'releases', 'authority.json');
124
+ if (!existsSync(authorityPath)) return undefined;
125
+ try {
126
+ const authority = JSON.parse(readFileSync(authorityPath, 'utf8')) as { active?: { manifestPath?: string } };
127
+ if (!authority.active?.manifestPath || !existsSync(authority.active.manifestPath)) return undefined;
128
+ const manifest = JSON.parse(readFileSync(authority.active.manifestPath, 'utf8')) as Record<string, unknown>;
129
+ return parseMacOSRuntimeCodeSigning(manifest.macosCodeSigning, 'RUNTIME_RELEASE_ACTIVE_SIGNING_INVALID')?.teamIdentifier;
130
+ } catch (error) {
131
+ if (error instanceof Error && error.message.startsWith('RUNTIME_RELEASE_ACTIVE_SIGNING_INVALID')) throw error;
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ function inspectMacOSRuntimeCodeSigning(executable: string): MacOSRuntimeCodeSigning {
137
+ const verify = runProcess('codesign', ['--verify', '--strict', executable], { timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
138
+ if (!verify.ok) throw new Error(`RUNTIME_RELEASE_MACOS_SIGNATURE_INVALID: ${verify.stderr || verify.stdout || verify.error}`.slice(0, 2_000));
139
+ const detail = runProcess('codesign', ['-dv', '--verbose=4', executable], { timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
140
+ if (!detail.ok) throw new Error(`RUNTIME_RELEASE_MACOS_SIGNATURE_INSPECTION_FAILED: ${detail.stderr || detail.stdout || detail.error}`.slice(0, 2_000));
141
+ const detailText = `${detail.stdout}\n${detail.stderr}`;
142
+ const identifier = detailText.match(/^Identifier=(.+)$/m)?.[1]?.trim() ?? '';
143
+ const teamIdentifier = detailText.match(/^TeamIdentifier=(.+)$/m)?.[1]?.trim() ?? '';
144
+ const authority = detailText.match(/^Authority=(Developer ID Application:.+)$/m)?.[1]?.trim() ?? '';
145
+ const requirementResult = runProcess('codesign', ['-d', '-r-', executable], { timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
146
+ if (!requirementResult.ok) throw new Error(`RUNTIME_RELEASE_MACOS_REQUIREMENT_INSPECTION_FAILED: ${requirementResult.stderr || requirementResult.stdout || requirementResult.error}`.slice(0, 2_000));
147
+ const requirementText = `${requirementResult.stdout}\n${requirementResult.stderr}`;
148
+ const designatedRequirement = requirementText.match(/^designated => (.+)$/m)?.[1]?.trim() ?? '';
149
+ return parseMacOSRuntimeCodeSigning({ identifier, teamIdentifier, authority, designatedRequirement }, 'RUNTIME_RELEASE_MACOS_SIGNATURE_INVALID')!;
150
+ }
151
+
152
+ function defaultSignMacOSRuntime(input: { executable: string; controllerHome: string }): MacOSRuntimeCodeSigning {
153
+ const identities = runProcess('security', ['find-identity', '-v', '-p', 'codesigning'], { timeoutMs: 30_000, maxOutputBytes: 128 * 1024 });
154
+ if (!identities.ok) throw new Error(`RUNTIME_RELEASE_MACOS_SIGNING_IDENTITY_LOOKUP_FAILED: ${identities.stderr || identities.stdout || identities.error}`.slice(0, 2_000));
155
+ const candidates = identities.stdout.split(/\r?\n/).flatMap((line) => {
156
+ const match = line.match(/^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+\"(Developer ID Application: .+ \(([A-Z0-9]{10})\))\"/);
157
+ return match ? [{ hash: match[1]!, label: match[2]!, teamIdentifier: match[3]! }] : [];
158
+ });
159
+ const configured = process.env.FORGE_MACOS_RUNTIME_SIGNING_IDENTITY?.trim();
160
+ const expectedTeam = readActiveMacOSRuntimeTeam(input.controllerHome);
161
+ let selected = configured
162
+ ? candidates.find((candidate) => candidate.hash.toLowerCase() === configured.toLowerCase() || candidate.label === configured)
163
+ : undefined;
164
+ if (configured && !selected) throw new Error('RUNTIME_RELEASE_MACOS_SIGNING_IDENTITY_NOT_FOUND');
165
+ if (!selected) {
166
+ const eligible = expectedTeam ? candidates.filter((candidate) => candidate.teamIdentifier === expectedTeam) : candidates;
167
+ const teams = Array.from(new Set(eligible.map((candidate) => candidate.teamIdentifier)));
168
+ if (teams.length !== 1 || eligible.length === 0) {
169
+ throw new Error(expectedTeam ? 'RUNTIME_RELEASE_MACOS_SIGNING_IDENTITY_FOR_TEAM_REQUIRED' : 'RUNTIME_RELEASE_MACOS_SIGNING_IDENTITY_REQUIRED');
170
+ }
171
+ selected = eligible[0];
172
+ }
173
+ if (expectedTeam && selected.teamIdentifier !== expectedTeam) throw new Error('RUNTIME_RELEASE_MACOS_SIGNING_TEAM_CHANGED');
174
+ const signed = runProcess('codesign', [
175
+ '--force', '--sign', selected.hash, '--identifier', FORGE_MACOS_RUNTIME_SIGNING_IDENTIFIER, '--options', 'runtime', input.executable,
176
+ ], { timeoutMs: 120_000, maxOutputBytes: 128 * 1024 });
177
+ if (!signed.ok) throw new Error(`RUNTIME_RELEASE_MACOS_SIGNING_FAILED: ${signed.stderr || signed.stdout || signed.error}`.slice(0, 2_000));
178
+ const inspected = inspectMacOSRuntimeCodeSigning(input.executable);
179
+ if (inspected.teamIdentifier !== selected.teamIdentifier) throw new Error('RUNTIME_RELEASE_MACOS_SIGNING_TEAM_MISMATCH');
180
+ return inspected;
181
+ }
182
+
81
183
  function sha256Directory(root: string): string {
82
184
  const hash = createHash('sha256');
83
185
  const visit = (directory: string): void => {
@@ -203,12 +305,28 @@ export function stageRuntimeReleaseFromCandidateSource(input: {
203
305
  if (sha256(manifestPath) !== receipt.manifestSha256) {
204
306
  throw new Error('RUNTIME_RELEASE_CANDIDATE_MANIFEST_IDENTITY_MISMATCH');
205
307
  }
308
+ let rawManifest: Record<string, unknown>;
309
+ try { rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record<string, unknown>; }
310
+ catch (error) { throw new Error(`RUNTIME_RELEASE_CANDIDATE_MANIFEST_INVALID: ${error instanceof Error ? error.message : String(error)}`); }
311
+ const platform = dependencies.platform ?? process.platform;
312
+ const macosCodeSigning = parseMacOSRuntimeCodeSigning(rawManifest.macosCodeSigning, 'RUNTIME_RELEASE_CANDIDATE_SIGNING_INVALID');
313
+ if (platform === 'darwin') {
314
+ if (!macosCodeSigning) throw new Error('RUNTIME_RELEASE_CANDIDATE_MACOS_SIGNING_REQUIRED');
315
+ const actual = (dependencies.inspectMacOSRuntime ?? inspectMacOSRuntimeCodeSigning)(runtimePath);
316
+ if (actual.identifier !== macosCodeSigning.identifier
317
+ || actual.teamIdentifier !== macosCodeSigning.teamIdentifier
318
+ || actual.authority !== macosCodeSigning.authority
319
+ || actual.designatedRequirement !== macosCodeSigning.designatedRequirement) {
320
+ throw new Error('RUNTIME_RELEASE_CANDIDATE_MACOS_SIGNING_MISMATCH');
321
+ }
322
+ }
206
323
 
207
324
  return {
208
325
  releasePath,
209
326
  manifestPath,
210
327
  releaseId: receipt.releaseId,
211
328
  artifactIdentity: receipt.artifactIdentity,
329
+ ...(macosCodeSigning ? { macosCodeSigning } : {}),
212
330
  manifestSha256: receipt.manifestSha256,
213
331
  sourceCommit: receipt.sourceCommit,
214
332
  };
@@ -297,6 +415,14 @@ export function stageRuntimeRelease(input: {
297
415
  throw new Error(`RUNTIME_RELEASE_BUILD_FAILED: ${compile.stderr || compile.stdout || compile.error}`.slice(0, 2_000));
298
416
  }
299
417
  chmodSync(executable, 0o700);
418
+ const platform = dependencies.platform ?? process.platform;
419
+ const macosCodeSigning = platform === 'darwin'
420
+ ? (dependencies.signMacOSRuntime ?? defaultSignMacOSRuntime)({ executable, controllerHome: resolve(input.controllerHome) })
421
+ : undefined;
422
+ if (platform === 'darwin' && !macosCodeSigning) throw new Error('RUNTIME_RELEASE_MACOS_SIGNING_REQUIRED');
423
+ const normalizedMacOSCodeSigning = macosCodeSigning
424
+ ? parseMacOSRuntimeCodeSigning(macosCodeSigning, 'RUNTIME_RELEASE_MACOS_SIGNING_INVALID')
425
+ : undefined;
300
426
  const artifactIdentity = `sha256:${sha256(executable)}`;
301
427
 
302
428
  const diagnosticExecutable = join(staging, 'forge-cli');
@@ -407,28 +533,18 @@ export function stageRuntimeRelease(input: {
407
533
  cpSync(sourceControllerUiPath, controllerUiPath, { recursive: true, force: false });
408
534
  const controllerUiArtifactIdentity = `sha256:${sha256Directory(controllerUiPath)}`;
409
535
 
410
- const desktopHelperEntrypoint = 'forge-desktop-helper.mjs' as const;
411
- const sourceDesktopHelperPath = join(sourceRoot, 'bin', desktopHelperEntrypoint);
412
- if (!existsSync(sourceDesktopHelperPath)) {
413
- throw new Error(`RUNTIME_RELEASE_DESKTOP_HELPER_SOURCE_MISSING: ${sourceDesktopHelperPath}`);
414
- }
415
- const desktopHelperPath = join(staging, desktopHelperEntrypoint);
416
- copyFileSync(sourceDesktopHelperPath, desktopHelperPath);
417
- chmodSync(desktopHelperPath, 0o700);
418
- const desktopHelperArtifactIdentity = `sha256:${sha256(desktopHelperPath)}`;
419
536
  const manifest = {
420
537
  schemaVersion: 1,
421
538
  releaseId,
422
539
  artifactIdentity,
423
540
  entrypoint: 'forge-runtime',
541
+ ...(normalizedMacOSCodeSigning ? { macosCodeSigning: normalizedMacOSCodeSigning } : {}),
424
542
  diagnosticEntrypoint: 'forge-cli',
425
543
  diagnosticArtifactIdentity,
426
544
  browserNodeBridgeEntrypoint,
427
545
  browserNodeBridgeArtifactIdentity,
428
546
  browserHandoffEntrypoint,
429
547
  browserHandoffArtifactIdentity,
430
- desktopHelperEntrypoint,
431
- desktopHelperArtifactIdentity,
432
548
  processRunnerEntrypoint,
433
549
  processRunnerArtifactIdentity,
434
550
  checkRunnerEntrypoint,
@@ -464,10 +580,10 @@ export function stageRuntimeRelease(input: {
464
580
  manifestPath: join(releasePath, 'manifest.json'),
465
581
  releaseId,
466
582
  artifactIdentity,
583
+ ...(normalizedMacOSCodeSigning ? { macosCodeSigning: normalizedMacOSCodeSigning } : {}),
467
584
  diagnosticArtifactIdentity,
468
585
  browserNodeBridgeArtifactIdentity,
469
586
  browserHandoffArtifactIdentity,
470
- desktopHelperArtifactIdentity,
471
587
  processRunnerArtifactIdentity,
472
588
  checkRunnerArtifactIdentity,
473
589
  externalPluginProbeArtifactIdentity,
@@ -484,12 +600,23 @@ export function stageRuntimeRelease(input: {
484
600
  }
485
601
  }
486
602
 
487
- export function assertRuntimeReleaseFiles(release: StagedRuntimeRelease): void {
603
+ export function assertRuntimeReleaseFiles(release: StagedRuntimeRelease, dependencies: { platform?: NodeJS.Platform; inspectMacOSRuntime?: (executable: string) => MacOSRuntimeCodeSigning } = {}): void {
488
604
  if (!existsSync(release.releasePath) || !existsSync(release.manifestPath)) {
489
605
  throw new Error(`RUNTIME_RELEASE_FILES_MISSING: ${release.releasePath}`);
490
606
  }
491
- if (!existsSync(join(release.releasePath, 'forge-runtime'))) {
492
- throw new Error(`RUNTIME_RELEASE_ENTRYPOINT_MISSING: ${join(release.releasePath, 'forge-runtime')}`);
607
+ const runtimePath = join(release.releasePath, 'forge-runtime');
608
+ if (!existsSync(runtimePath)) {
609
+ throw new Error(`RUNTIME_RELEASE_ENTRYPOINT_MISSING: ${runtimePath}`);
610
+ }
611
+ const platform = dependencies.platform ?? process.platform;
612
+ if (platform === 'darwin' && release.macosCodeSigning) {
613
+ const actual = (dependencies.inspectMacOSRuntime ?? inspectMacOSRuntimeCodeSigning)(runtimePath);
614
+ if (actual.identifier !== release.macosCodeSigning.identifier
615
+ || actual.teamIdentifier !== release.macosCodeSigning.teamIdentifier
616
+ || actual.authority !== release.macosCodeSigning.authority
617
+ || actual.designatedRequirement !== release.macosCodeSigning.designatedRequirement) {
618
+ throw new Error('RUNTIME_RELEASE_MACOS_SIGNING_MISMATCH');
619
+ }
493
620
  }
494
621
  if (release.diagnosticArtifactIdentity && !existsSync(join(release.releasePath, 'forge-cli'))) {
495
622
  throw new Error(`RUNTIME_RELEASE_DIAGNOSTIC_ENTRYPOINT_MISSING: ${join(release.releasePath, 'forge-cli')}`);
@@ -500,9 +627,6 @@ export function assertRuntimeReleaseFiles(release: StagedRuntimeRelease): void {
500
627
  if (release.browserHandoffArtifactIdentity && !existsSync(join(release.releasePath, 'browser-handoff-host.js'))) {
501
628
  throw new Error(`RUNTIME_RELEASE_BROWSER_HANDOFF_HOST_MISSING: ${join(release.releasePath, 'browser-handoff-host.js')}`);
502
629
  }
503
- if (release.desktopHelperArtifactIdentity && !existsSync(join(release.releasePath, 'forge-desktop-helper.mjs'))) {
504
- throw new Error(`RUNTIME_RELEASE_DESKTOP_HELPER_MISSING: ${join(release.releasePath, 'forge-desktop-helper.mjs')}`);
505
- }
506
630
  if (release.processRunnerArtifactIdentity && !existsSync(join(release.releasePath, 'process-runner.js'))) {
507
631
  throw new Error(`RUNTIME_RELEASE_PROCESS_RUNNER_MISSING: ${join(release.releasePath, 'process-runner.js')}`);
508
632
  }
@@ -232,7 +232,7 @@ export function syncForgeRuntimeActiveEntrypoint(controllerHome: string): { path
232
232
  }
233
233
 
234
234
  export function ensureForgeRuntimeLaunchAgentContract(
235
- input: { controllerHome: string; bootstrapNodeExecutable?: string; bootstrapRunnerPath?: string },
235
+ input: { controllerHome: string; bootstrapNodeExecutable?: string; bootstrapRunnerPath?: string; installUserLaunchAgent?: boolean },
236
236
  ): { paths: ForgeRuntimeServicePaths; mode: 'release' | 'bootstrap'; changed: boolean } {
237
237
  const paths = forgeRuntimeServicePaths(input.controllerHome);
238
238
  const active = activeRuntimeEntrypoint(input.controllerHome);
@@ -250,10 +250,12 @@ export function ensureForgeRuntimeLaunchAgentContract(
250
250
  : { nodeExecutable: input.bootstrapNodeExecutable!, runnerPath: input.bootstrapRunnerPath! }),
251
251
  });
252
252
  const existingSource = existsSync(paths.sourcePlistPath) ? readFileSync(paths.sourcePlistPath, 'utf8') : undefined;
253
- const existingInstalled = existsSync(paths.installedPlistPath) ? readFileSync(paths.installedPlistPath, 'utf8') : undefined;
254
- const changed = existingSource !== plist || existingInstalled !== plist;
253
+ const existingInstalled = input.installUserLaunchAgent && existsSync(paths.installedPlistPath)
254
+ ? readFileSync(paths.installedPlistPath, 'utf8')
255
+ : undefined;
256
+ const changed = existingSource !== plist || (input.installUserLaunchAgent === true && existingInstalled !== plist);
255
257
  if (existingSource !== plist) atomicWrite(paths.sourcePlistPath, plist);
256
- if (existingInstalled !== plist) atomicWrite(paths.installedPlistPath, plist, 0o644);
258
+ if (input.installUserLaunchAgent === true && existingInstalled !== plist) atomicWrite(paths.installedPlistPath, plist, 0o644);
257
259
  return { paths, mode: useRelease ? 'release' : 'bootstrap', changed };
258
260
  }
259
261
 
@@ -1,6 +1,5 @@
1
1
  export * from './types';
2
2
  export * from './plugin-summary';
3
- export * from './browser-safe';
4
3
  export * from './job-summary';
5
4
  export * from './external-filesystem';
6
5
  export * from './workspace-auth';
@@ -44,36 +44,6 @@ export interface SafePluginSummary {
44
44
  };
45
45
  }
46
46
 
47
- export interface WebTarget {
48
- targetKey: string;
49
- domain: string;
50
- origin: string;
51
- allowed: true;
52
- defaultPath: '/';
53
- }
54
-
55
- export interface WebDomainAccessPreview {
56
- ticketId: string;
57
- normalizedDomain: string;
58
- domainKey: string;
59
- reason: string;
60
- expiresAt: string;
61
- alreadyAllowed: boolean;
62
- currentAllowedDomainCount: number;
63
- risk: 'workspace_write';
64
- confirmation: 'authorization';
65
- localOnly: true;
66
- willChange: {
67
- pluginId: 'browser';
68
- configField: 'allowedDomains';
69
- addDomain: string;
70
- };
71
- safety: {
72
- arbitraryUrlAccepted: false;
73
- domainOnly: true;
74
- sensitiveConfigReturned: false;
75
- };
76
- }
77
47
 
78
48
  export interface SafeJobResultSummary {
79
49
  jobId: string;
@@ -1740,7 +1740,7 @@ export async function activateRuntimeRelease(
1740
1740
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1741
1741
  }
1742
1742
  try {
1743
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
1743
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
1744
1744
  } catch (error) {
1745
1745
  const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
1746
1746
  audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
@@ -1821,7 +1821,7 @@ export async function activateRuntimeRelease(
1821
1821
  physicalHome: storageMigration.physicalHome,
1822
1822
  });
1823
1823
  }
1824
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
1824
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
1825
1825
  const previousRevision = previousActive?.releaseId ?? '';
1826
1826
  const previousIdentity = previousActive?.artifactIdentity;
1827
1827
  if (
@@ -0,0 +1,180 @@
1
+ import { createHash } from 'crypto';
2
+ import type { RepositoryRecord } from '../../../cli/repositories/types';
3
+ import { executeBrowserPluginAction } from '../../plugins/browser-adapter';
4
+
5
+ export type ScheduledBrowserProbeStatus = 'observed' | 'keepalive' | 'auth_required';
6
+
7
+ export interface ScheduledBrowserProbeResult {
8
+ status: ScheduledBrowserProbeStatus;
9
+ fingerprint?: string;
10
+ url?: string;
11
+ sessionId: string;
12
+ projectedLineCount: number;
13
+ observedChars: number;
14
+ truncated: boolean;
15
+ authReason?: string;
16
+ }
17
+
18
+ function stringValue(value: unknown): string | undefined {
19
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
20
+ }
21
+
22
+ function recordValue(value: unknown): Record<string, unknown> {
23
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
24
+ }
25
+
26
+ function stringList(value: unknown): string[] {
27
+ return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
28
+ }
29
+
30
+ function boundedNumber(value: unknown, fallback: number, min: number, max: number): number {
31
+ return typeof value === 'number' && Number.isFinite(value)
32
+ ? Math.max(min, Math.min(Math.trunc(value), max))
33
+ : fallback;
34
+ }
35
+
36
+ export function buildScheduledBrowserFingerprint(text: string, includeTerms: string[] = [], ignorePatterns: string[] = []): { fingerprint: string; lineCount: number } {
37
+ const terms = includeTerms.map((term) => term.toLocaleLowerCase());
38
+ let lines = text
39
+ .split(/\r?\n/)
40
+ .map((line) => line.replace(/\s+/g, ' ').trim())
41
+ .filter(Boolean);
42
+ if (terms.length > 0) {
43
+ lines = lines.filter((line) => {
44
+ const normalized = line.toLocaleLowerCase();
45
+ return terms.some((term) => normalized.includes(term));
46
+ });
47
+ }
48
+ for (const pattern of ignorePatterns) {
49
+ try {
50
+ const expression = new RegExp(pattern, 'giu');
51
+ lines = lines.map((line) => line.replace(expression, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
52
+ } catch {
53
+ throw new Error(`SCHEDULE_BROWSER_PROBE_IGNORE_PATTERN_INVALID: ${pattern}`);
54
+ }
55
+ }
56
+ lines = [...new Set(lines)].sort((left, right) => left.localeCompare(right));
57
+ return { fingerprint: createHash('sha256').update(lines.join('\n')).digest('hex'), lineCount: lines.length };
58
+ }
59
+
60
+ export function classifyScheduledBrowserObservation(
61
+ previousFingerprint: string | undefined,
62
+ currentFingerprint: string | undefined,
63
+ wakeOnFirstObservation = false,
64
+ ): { status: 'baseline' | 'unchanged' | 'changed'; shouldWake: boolean } {
65
+ const firstObservation = !previousFingerprint;
66
+ const changed = Boolean(previousFingerprint && currentFingerprint && previousFingerprint !== currentFingerprint);
67
+ return {
68
+ status: firstObservation ? 'baseline' : changed ? 'changed' : 'unchanged',
69
+ shouldWake: changed || (firstObservation && wakeOnFirstObservation),
70
+ };
71
+ }
72
+
73
+ function containsAny(value: string | undefined, terms: string[]): string | undefined {
74
+ if (!value) return undefined;
75
+ const normalized = value.toLocaleLowerCase();
76
+ return terms.find((term) => normalized.includes(term.toLocaleLowerCase()));
77
+ }
78
+
79
+ async function browserAction(
80
+ controllerHome: string,
81
+ repository: RepositoryRecord,
82
+ occurrenceId: string,
83
+ actionId: string,
84
+ args: Record<string, unknown>,
85
+ ): Promise<Record<string, unknown>> {
86
+ return executeBrowserPluginAction({
87
+ controllerHome,
88
+ repoId: repository.repoId,
89
+ repoRoot: repository.canonicalRoot,
90
+ pluginId: 'browser',
91
+ actionId,
92
+ requestId: `schedule-browser-probe:${occurrenceId}:${actionId}`,
93
+ args,
94
+ origin: { surface: 'schedule', actor: 'browser-probe', correlationId: occurrenceId },
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Bounded read-only browser observation for Schedule. It never interprets page
100
+ * meaning: it only refreshes/navigates, extracts bounded text, projects matching
101
+ * lines, detects configured login markers, and returns a stable fingerprint.
102
+ */
103
+ export async function executeScheduledBrowserProbe(input: {
104
+ controllerHome: string;
105
+ repository: RepositoryRecord;
106
+ occurrenceId: string;
107
+ args: Record<string, unknown>;
108
+ }): Promise<ScheduledBrowserProbeResult> {
109
+ const requestedSessionId = stringValue(input.args.probe_session_id) ?? stringValue(input.args.session_id);
110
+ const url = stringValue(input.args.probe_url) ?? stringValue(input.args.url);
111
+ if (!requestedSessionId && !url) throw new Error('SCHEDULE_BROWSER_PROBE_TARGET_REQUIRED');
112
+
113
+ const waitUntil = stringValue(input.args.wait_until) ?? 'domcontentloaded';
114
+ const timeoutMs = boundedNumber(input.args.timeout_ms, 60_000, 1_000, 120_000);
115
+ let navigation: Record<string, unknown>;
116
+ if (url) {
117
+ navigation = await browserAction(input.controllerHome, input.repository, input.occurrenceId, 'navigate', {
118
+ ...(requestedSessionId ? { session_id: requestedSessionId } : {}),
119
+ url,
120
+ wait_until: waitUntil,
121
+ timeout_ms: timeoutMs,
122
+ retries: 1,
123
+ });
124
+ } else {
125
+ navigation = await browserAction(input.controllerHome, input.repository, input.occurrenceId, 'reload', {
126
+ session_id: requestedSessionId!,
127
+ wait_until: waitUntil,
128
+ timeout_ms: timeoutMs,
129
+ });
130
+ }
131
+
132
+ const session = recordValue(navigation.session);
133
+ const sessionId = stringValue(navigation.sessionId) ?? stringValue(session.sessionId) ?? stringValue(session.id) ?? requestedSessionId;
134
+ if (!sessionId) throw new Error('SCHEDULE_BROWSER_PROBE_SESSION_UNAVAILABLE');
135
+ const observedUrl = stringValue(navigation.url) ?? stringValue(session.url) ?? url;
136
+ const loginUrlTerms = stringList(input.args.login_url_terms);
137
+ const urlLoginMarker = containsAny(observedUrl, loginUrlTerms);
138
+
139
+ const keepaliveOnly = input.args.keepalive_only === true;
140
+ const loginTextTerms = stringList(input.args.login_text_terms);
141
+ const mustExtractText = !keepaliveOnly || loginTextTerms.length > 0;
142
+ let rawText = '';
143
+ let truncated = false;
144
+ if (mustExtractText) {
145
+ const textResult = await browserAction(input.controllerHome, input.repository, input.occurrenceId, 'get_text', {
146
+ session_id: sessionId,
147
+ ...(stringValue(input.args.selector) ? { selector: stringValue(input.args.selector) } : {}),
148
+ max_chars: boundedNumber(input.args.max_chars, 20_000, 256, 100_000),
149
+ });
150
+ rawText = stringValue(textResult.text) ?? '';
151
+ truncated = textResult.truncated === true;
152
+ }
153
+ const textLoginMarker = containsAny(rawText, loginTextTerms);
154
+ const authMarker = urlLoginMarker ?? textLoginMarker;
155
+ if (authMarker) {
156
+ return {
157
+ status: 'auth_required',
158
+ url: observedUrl,
159
+ sessionId,
160
+ projectedLineCount: 0,
161
+ observedChars: rawText.length,
162
+ truncated,
163
+ authReason: `matched configured login marker: ${authMarker}`,
164
+ };
165
+ }
166
+ if (keepaliveOnly) {
167
+ return { status: 'keepalive', url: observedUrl, sessionId, projectedLineCount: 0, observedChars: rawText.length, truncated };
168
+ }
169
+
170
+ const projected = buildScheduledBrowserFingerprint(rawText, stringList(input.args.include_terms), stringList(input.args.ignore_patterns));
171
+ return {
172
+ status: 'observed',
173
+ fingerprint: projected.fingerprint,
174
+ url: observedUrl,
175
+ sessionId,
176
+ projectedLineCount: projected.lineCount,
177
+ observedChars: rawText.length,
178
+ truncated,
179
+ };
180
+ }
@@ -0,0 +1,40 @@
1
+ import type { RepositorySchedule } from './types';
2
+
3
+ export const CHATGPT_AUTOMATION_EXECUTION_PROFILE = 'chatgpt_browser_v1';
4
+ export const CHATGPT_AUTOMATION_DEFAULT_MODEL = 'gpt-5.6';
5
+ export const CHATGPT_AUTOMATION_DEFAULT_REASONING = 'high';
6
+ export const CHATGPT_AUTOMATION_DEFAULT_TAB_POLICY = 'auto';
7
+
8
+ export interface ChatgptAutomationScheduleMigrationResult {
9
+ changed: boolean;
10
+ schedule: RepositorySchedule;
11
+ }
12
+
13
+ function nonEmptyString(value: unknown): string | undefined {
14
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
15
+ }
16
+
17
+ export function migrateChatgptAutomationSchedule(schedule: RepositorySchedule): ChatgptAutomationScheduleMigrationResult {
18
+ if (schedule.action.operation !== 'external_controller_wake') return { changed: false, schedule };
19
+ const current = schedule.action.arguments ?? {};
20
+ const controllerType = nonEmptyString(current.controller_type) ?? 'chatgpt';
21
+ if (controllerType !== 'chatgpt') return { changed: false, schedule };
22
+
23
+ const argumentsNext: Record<string, unknown> = {
24
+ ...current,
25
+ controller_type: controllerType,
26
+ model: nonEmptyString(current.model) ?? CHATGPT_AUTOMATION_DEFAULT_MODEL,
27
+ reasoning: nonEmptyString(current.reasoning) ?? CHATGPT_AUTOMATION_DEFAULT_REASONING,
28
+ tab_policy: nonEmptyString(current.tab_policy) ?? CHATGPT_AUTOMATION_DEFAULT_TAB_POLICY,
29
+ execution_profile: nonEmptyString(current.execution_profile) ?? CHATGPT_AUTOMATION_EXECUTION_PROFILE,
30
+ };
31
+ const changed = Object.entries(argumentsNext).some(([key, value]) => current[key] !== value);
32
+ if (!changed) return { changed: false, schedule };
33
+ return {
34
+ changed: true,
35
+ schedule: {
36
+ ...schedule,
37
+ action: { ...schedule.action, arguments: argumentsNext },
38
+ },
39
+ };
40
+ }