@xingular/harness 0.1.7 → 2.1.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 (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +109 -13
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
package/src/harness.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The harness, assembled: Pi sessions on isolated accounts, a compiled prompt, a memory
2
2
  // lifecycle, tools, connected apps, and skills. An application embeds one of these and adds
3
3
  // its own prompt sections, tools, and policy; nothing here knows what that application is.
4
+ import { Subagents } from './sessions/subagentService'
4
5
  import { mkdirSync } from 'node:fs'
5
6
  import { join } from 'node:path'
6
7
 
@@ -16,6 +17,7 @@ import { readJsonOr } from './core/fs'
16
17
  import { closeIndex } from './core/index'
17
18
  import { projectsOrNone } from './core/projects'
18
19
  import { ConnectedApps } from './mcp/service'
20
+ import type { AppOAuthClients } from './mcp/types'
19
21
  import { migrateConnectionScopes } from './mcp/scopeMigration'
20
22
  import type { MaintainerFactory } from './memory/maintainer'
21
23
  import { Maintenance } from './memory/maintenance'
@@ -76,6 +78,11 @@ export interface HarnessOptions {
76
78
  plugins?: PluginDescriptor[]
77
79
  /** Replaces Pi's own search for extensions, skills, prompts and themes, for tests. */
78
80
  resolver?: Resolver
81
+ /**
82
+ * The application's own registered OAuth clients, so a connector it publishes needs no client
83
+ * from the person. Only a catalog entry listed in `ids` can use one, and nothing is stored.
84
+ */
85
+ oauthClients?: AppOAuthClients
79
86
  /** Replaces how a plugin's hooks file is read as a module, for tests. */
80
87
  hookImporter?: (address: string) => Promise<unknown>
81
88
  }
@@ -124,6 +131,7 @@ export class Harness {
124
131
  /** Every part described as a plugin: the harness's own, then the application's. */
125
132
  descriptors: readonly PluginDescriptor[] = HARNESS_PLUGINS
126
133
  applicationPlugins: ReadonlySet<string> = new Set()
134
+ readonly subagents: Subagents
127
135
  readonly sessions: Sessions
128
136
  readonly maintenance: Maintenance
129
137
  readonly terminals: Terminals
@@ -152,7 +160,7 @@ export class Harness {
152
160
  redact: (event) => this.apps.connections.redactEvent(event),
153
161
  ended: (runId, sessionId) => this.apps.revokeRun(runId, sessionId)
154
162
  })
155
- this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy })
163
+ this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy, oauthClients: options.oauthClients })
156
164
  this.plugins = new Plugins({ stateDir: this.stateDir, bus: this.bus, resolver: options.resolver, attachments: () => (this.env.dataRoot ? attachments.load(this.env.dataRoot) : null), packagesDir: () => (this.env.dataRoot ? join(this.env.dataRoot, 'plugins') : null) })
157
165
  this.hooks = new Hooks({
158
166
  stateDir: this.stateDir,
@@ -182,7 +190,9 @@ export class Harness {
182
190
  reloadTimer.unref?.()
183
191
  })
184
192
  this.describe(options.plugins ?? [])
193
+ this.subagents = new Subagents(this.env, this.runs, (input) => this.sessions.run(input))
185
194
  this.sessions = new Sessions({
195
+ subagents: this.subagents,
186
196
  env: this.env,
187
197
  accounts: this.accounts,
188
198
  runs: this.runs,
@@ -193,7 +203,7 @@ export class Harness {
193
203
  factory: options.sessionFactory
194
204
  })
195
205
  this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs, external: options.externalMaintenance, excludedTriggers: options.maintenanceExcludedTriggers, runTrigger: options.maintenanceRunTrigger })
196
- this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
206
+ this.terminals = new Terminals({ subagents: this.subagents, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
197
207
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
198
208
  this.runs.listen((event, run) => {
199
209
  this.accounts.onRunEvent(event, run)
@@ -40,6 +40,8 @@ describe('Hub installation', () => {
40
40
  expect(existsSync(join(root, 'config', 'plugin-attachments.json'))).toBe(false)
41
41
  writeFileSync(join(root, 'hub', 'documents', receipt.document!), '{"edited":true}')
42
42
  expect(() => hub.readDocument('example/review', '1.0.0')).toThrow('differs')
43
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'changed' })
44
+ await expect(hub.install('example/review', '1.0.0')).rejects.toThrow('Uninstall it before installing')
43
45
  })
44
46
  it('imports complete skills disabled and refuses overwriting local files', async () => {
45
47
  const { hub, root } = fixture([entry('registry', skill())])
@@ -90,12 +92,58 @@ describe('Hub installation', () => {
90
92
  expect(preview.delivery.kind !== 'pi-package' && preview.delivery.bundled).toContain('data only')
91
93
  expect(existsSync(join(root, 'hub'))).toBe(false)
92
94
  })
95
+ it('returns bounded UTF-8-safe preview pages and reuses the verified download for install', async () => {
96
+ const content = `"${'é'.repeat(20_000)}"`
97
+ const item = entry('document', content)
98
+ if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
99
+ const fetcher = vi.fn(async () => new Response(content)) as typeof fetch
100
+ const { hub, root } = fixture([item], fetcher)
101
+ const pages = []
102
+ let offset = 0
103
+ while (true) {
104
+ const page = await hub.preview(item.id, item.version, offset)
105
+ expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(16 * 1024)
106
+ pages.push(page.content)
107
+ if (page.done) break
108
+ offset = page.nextOffset
109
+ }
110
+ expect(pages.join('')).toBe(content)
111
+ expect(fetcher).toHaveBeenCalledTimes(1)
112
+ await hub.install(item.id, item.version)
113
+ expect(fetcher).toHaveBeenCalledTimes(1)
114
+ expect(existsSync(join(root, 'hub', 'documents'))).toBe(true)
115
+ })
116
+ it('uninstalls only its receipt-owned document, including local edits', async () => {
117
+ const first = entry()
118
+ const second = { ...entry(), id: 'example/second', title: 'Second' }
119
+ const { hub, root } = fixture([first, second])
120
+ const owned = await hub.install(first.id, first.version)
121
+ const other = await hub.install(second.id, second.version)
122
+ writeFileSync(join(root, 'hub', 'documents', owned.document!), '{"local":true}')
123
+ await hub.uninstall(first.id, first.version)
124
+ expect(existsSync(join(root, 'hub', 'documents', owned.document!))).toBe(false)
125
+ expect(existsSync(join(root, 'hub', 'documents', other.document!))).toBe(true)
126
+ expect((await hub.list()).installed.map((receipt) => receipt.entry.id)).toEqual([second.id])
127
+ })
128
+ it('uninstalls only receipt-owned registry resources and leaves unrelated files', async () => {
129
+ const { hub, root } = fixture([entry('registry', skill())])
130
+ const receipt = await hub.install('example/review', '1.0.0')
131
+ const unrelated = join(root, 'registry', 'skills', 'unrelated', 'SKILL.md')
132
+ mkdirSync(join(root, 'registry', 'skills', 'unrelated'), { recursive: true })
133
+ writeFileSync(unrelated, '---\nname: unrelated\ndescription: Local.\n---\n')
134
+ await hub.uninstall('example/review', '1.0.0')
135
+ expect(existsSync(join(root, 'registry', 'skills', 'review'))).toBe(false)
136
+ expect(readFileSync(unrelated, 'utf8')).toContain('unrelated')
137
+ expect((await hub.list()).installed).toEqual([])
138
+ expect(receipt.resourceIds).toEqual(['skills/review'])
139
+ })
93
140
  it('can reinstall deleted resources and clears stale attachment grants', async () => {
94
141
  const { hub, root } = fixture([entry('registry', skill())])
95
142
  await hub.install('example/review', '1.0.0')
96
143
  registry.setEnabled(root, null, ['skills/review'])
97
144
  rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
98
- expect((await hub.list()).installed).toEqual([])
145
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'incomplete' })
146
+ await hub.uninstall('example/review', '1.0.0')
99
147
  await hub.install('example/review', '1.0.0')
100
148
  expect(registry.enabledFor(root, null)).toEqual([])
101
149
  expect((await hub.list()).installed).toHaveLength(1)
package/src/hub/hub.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { watch } from 'node:fs'
5
5
 
@@ -12,7 +12,7 @@ import * as registry from '../skills/registry'
12
12
  import * as attachments from '../plugins/attachments'
13
13
  import { version as harnessVersion } from '../../package.json'
14
14
  import bundled from './catalog.json'
15
- import { HUB_CATALOG_URL, HUB_MAX_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
15
+ import { HUB_CATALOG_URL, HUB_MAX_BYTES, HUB_PREVIEW_PAGE_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
16
16
 
17
17
  export interface HubOptions {
18
18
  catalogUrl?: string
@@ -60,6 +60,7 @@ export class Hub {
60
60
  private watcher: ReturnType<typeof watch> | null = null
61
61
  private refresh: Promise<HubOverview> | null = null
62
62
  private installing = false
63
+ private cachedPayload: { key: string; bytes: Buffer; expiresAt: number } | null = null
63
64
  get busy(): boolean { return this.installing }
64
65
 
65
66
  constructor(private readonly harness: Harness, options: HubOptions = {}) {
@@ -80,12 +81,25 @@ export class Hub {
80
81
  const cached = readJsonOr<{ catalog: unknown; at: string } | null>(join(this.harness.stateDir, 'hub', 'catalog.json'), null)
81
82
  const parsed = hubCatalogSchema.safeParse(cached?.catalog)
82
83
  const catalog = parsed.success ? parsed.data : this.fallback
83
- const installed = this.receipts().filter((receipt) => this.present(receipt))
84
+ const installed = this.receipts().map((receipt) => ({ ...receipt, state: this.receiptState(receipt) }))
84
85
  const entries = new Map(catalog.entries.map((entry) => [`${entry.id}@${entry.version}`, entry]))
85
86
  for (const receipt of installed) if (!entries.has(`${receipt.entry.id}@${receipt.entry.version}`)) entries.set(`${receipt.entry.id}@${receipt.entry.version}`, receipt.entry)
86
87
  return { entries: [...entries.values()], installed, origin: parsed.success ? 'cached' : 'bundled', refreshedAt: parsed.success ? cached!.at : null, error: null, dataRoot: this.harness.dataRoot, stateDir: this.harness.stateDir }
87
88
  }
88
89
 
90
+ private receiptState(receipt: HubReceipt): 'installed' | 'changed' | 'incomplete' {
91
+ if (receipt.document && receipt.entry.delivery.kind === 'document') {
92
+ if (receipt.document !== `${receiptName(receipt.entry)}.json`) return 'incomplete'
93
+ try {
94
+ const path = join(this.harness.dataRoot!, 'hub', 'documents', receipt.document)
95
+ const stat = lstatSync(path)
96
+ if (!stat.isFile() || stat.size > HUB_MAX_BYTES) return 'incomplete'
97
+ return digest(readFileSync(path)) === receipt.entry.delivery.sha256 ? 'installed' : 'changed'
98
+ } catch { return 'incomplete' }
99
+ }
100
+ return this.present(receipt) ? 'installed' : 'incomplete'
101
+ }
102
+
89
103
  private present(receipt: HubReceipt): boolean {
90
104
  const root = this.harness.dataRoot
91
105
  if (!root) return false
@@ -169,6 +183,28 @@ export class Hub {
169
183
  return { ...entry, delivery: { ...entry.delivery, bundled: this.decode(bytes) } }
170
184
  }
171
185
 
186
+ private async verifiedPayload(entry: HubEntry): Promise<Buffer> {
187
+ if (entry.delivery.kind === 'pi-package') throw invalid('Packages do not have a JSON payload preview.')
188
+ const key = `${entry.id}@${entry.version}`
189
+ if (this.cachedPayload?.key === key && this.cachedPayload.expiresAt > Date.now()) return this.cachedPayload.bytes
190
+ const bytes = entry.delivery.bundled === undefined ? await this.bytes(entry.delivery.url) : Buffer.from(entry.delivery.bundled)
191
+ if (digest(bytes) !== entry.delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
192
+ this.decode(bytes)
193
+ this.cachedPayload = { key, bytes, expiresAt: Date.now() + 5 * 60 * 1000 }
194
+ return bytes
195
+ }
196
+
197
+ async preview(id: string, version: string, offset = 0) {
198
+ if (!Number.isSafeInteger(offset) || offset < 0) throw invalid('Preview offset must be a non-negative byte position.')
199
+ const entry = this.find(id, version)
200
+ const bytes = await this.verifiedPayload(entry)
201
+ if (offset > bytes.length || offset < bytes.length && (bytes[offset] & 0xc0) === 0x80) throw invalid('Preview offset is outside the payload or splits a UTF-8 character.')
202
+ let end = Math.min(offset + HUB_PREVIEW_PAGE_BYTES, bytes.length)
203
+ while (end > offset && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1
204
+ const content = this.decode(bytes.subarray(offset, end))
205
+ return { entry, content, offset, nextOffset: end, totalBytes: bytes.length, done: end >= bytes.length }
206
+ }
207
+
172
208
  async install(id: string, version: string): Promise<HubReceipt> {
173
209
  const root = this.harness.env.root
174
210
  const entry = hubEntrySchema.parse(this.find(id, version))
@@ -181,6 +217,7 @@ export class Hub {
181
217
  const records = this.receipts()
182
218
  const existing = records.find((receipt) => receipt.entry.id === id && receipt.entry.version === version)
183
219
  if (existing && this.present(existing)) return existing
220
+ if (existing) throw invalid('This Hub receipt is changed or incomplete. Uninstall it before installing this version again.')
184
221
  const remaining = records.filter((record) => record !== existing)
185
222
  const receipt: HubReceipt = { entry, installedAt: now(), resourceIds: [], document: null }
186
223
  const choicesBefore = attachments.load(root)
@@ -195,13 +232,14 @@ export class Hub {
195
232
  if (!receipt.resourceIds.length) throw invalid('The package did not add an installable plugin. No hub installation was recorded.')
196
233
  } else {
197
234
  const delivery = entry.delivery
198
- const bytes = delivery.bundled === undefined ? await this.bytes(delivery.url) : Buffer.from(delivery.bundled)
199
- if (digest(bytes) !== delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
235
+ const bytes = await this.verifiedPayload(entry)
200
236
  const text = this.decode(bytes)
201
237
  if (delivery.kind === 'document') {
202
238
  JSON.parse(text)
203
239
  receipt.document = `${receiptName(entry)}.json`
204
- atomicWrite(join(root, 'hub', 'documents', receipt.document), bytes)
240
+ const documentPath = join(root, 'hub', 'documents', receipt.document)
241
+ try { lstatSync(documentPath); throw invalid('A file already exists at the Hub document path. Review or remove it before installing.') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
242
+ atomicWrite(documentPath, bytes)
205
243
  } else {
206
244
  const payload = JSON.parse(text)
207
245
  const previous = new Set(registry.list(root).map((item) => item.id))
@@ -232,6 +270,84 @@ export class Hub {
232
270
  } finally { this.installing = false; releaseData?.(); releaseState() }
233
271
  }
234
272
 
273
+ async uninstall(id: string, version: string): Promise<HubReceipt> {
274
+ const root = this.harness.env.root
275
+ const releaseState = acquire(join(this.harness.stateDir, 'hub', 'install.lock'))
276
+ this.installing = true
277
+ let releaseData: (() => void) | null = null
278
+ try {
279
+ releaseData = acquire(join(root, 'hub', 'install.lock'))
280
+ const records = this.receipts()
281
+ const receipt = records.find((item) => item.entry.id === id && item.entry.version === version)
282
+ if (!receipt) throw invalid('This Hub installation receipt no longer exists.')
283
+ const staged = mkdtempSync(join(root, 'hub', '.uninstall-'))
284
+ const moves: Array<{ source: string; staged: string }> = []
285
+ let choicesBefore: ReturnType<typeof attachments.load> | null = null
286
+ let committed = false
287
+ let rollbackComplete = true
288
+ try {
289
+ const ownedPaths: string[] = []
290
+ if (receipt.document) {
291
+ if (receipt.entry.delivery.kind !== 'document' || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('The Hub receipt has an invalid document path.')
292
+ const path = join(root, 'hub', 'documents', receipt.document)
293
+ try {
294
+ const stat = lstatSync(path)
295
+ if (!stat.isFile()) throw invalid('The receipt-owned document path is not a regular file; it was left untouched.')
296
+ ownedPaths.push(path)
297
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
298
+ }
299
+ if (receipt.entry.delivery.kind === 'registry') {
300
+ const expectedKind = receipt.entry.delivery.registryKind
301
+ for (const id of receipt.resourceIds) {
302
+ if (!/^(skills|prompts|rules)\/[a-z0-9-]{1,64}$/.test(id)) throw invalid('The Hub receipt contains an invalid registry resource path.')
303
+ const [kind, name] = id.split('/')
304
+ if (kind !== expectedKind) throw invalid('The Hub receipt contains a resource outside its registry kind.')
305
+ const path = join(root, 'registry', kind, kind === 'skills' ? name : `${name}.md`)
306
+ try { lstatSync(path); ownedPaths.push(path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
307
+ }
308
+ choicesBefore = attachments.load(root)
309
+ const choices = structuredClone(choicesBefore)
310
+ const owned = new Set(receipt.resourceIds)
311
+ for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
312
+ scope.on = scope.on.filter((value) => !owned.has(value))
313
+ scope.off = scope.off.filter((value) => !owned.has(value))
314
+ }
315
+ for (let index = 0; index < ownedPaths.length; index += 1) {
316
+ const source = ownedPaths[index]
317
+ const destination = join(staged, String(index))
318
+ renameSync(source, destination)
319
+ moves.push({ source, staged: destination })
320
+ }
321
+ attachments.save(root, choices)
322
+ } else {
323
+ for (let index = 0; index < ownedPaths.length; index += 1) {
324
+ const source = ownedPaths[index]
325
+ const destination = join(staged, String(index))
326
+ renameSync(source, destination)
327
+ moves.push({ source, staged: destination })
328
+ }
329
+ }
330
+ if (receipt.entry.delivery.kind === 'pi-package') for (const pluginId of receipt.resourceIds) await packages.remove(this.harness, { pluginId })
331
+ writeJson(join(root, 'hub', 'installed.json'), records.filter((item) => item !== receipt))
332
+ committed = true
333
+ try { rmSync(staged, { recursive: true, force: true }) } catch { /* The receipt is removed; leave the staged copy for disk recovery. */ }
334
+ this.cachedPayload = this.cachedPayload?.key === `${id}@${version}` ? null : this.cachedPayload
335
+ this.harness.bus.emit({ type: 'hub.changed' })
336
+ this.harness.bus.emit({ type: 'plugins.changed' })
337
+ return receipt
338
+ } catch (error) {
339
+ if (!committed) {
340
+ if (choicesBefore) try { attachments.save(root, choicesBefore) } catch { /* Preserve the original failure and restore the owned files below. */ }
341
+ for (const move of moves.reverse()) {
342
+ try { lstatSync(move.staged); renameSync(move.staged, move.source) } catch { rollbackComplete = false }
343
+ }
344
+ if (rollbackComplete) try { rmSync(staged, { recursive: true, force: true }) } catch { /* Keep the staging directory for recovery. */ }
345
+ }
346
+ throw error
347
+ }
348
+ } finally { this.installing = false; releaseData?.(); releaseState() }
349
+ }
350
+
235
351
  readDocument(id: string, version: string): { entry: HubEntry; content: string } {
236
352
  const receipt = this.receipts().find((item) => item.entry.id === id && item.entry.version === version)
237
353
  if (!receipt?.document || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('Install this document before opening it.')
package/src/hub/schema.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
 
3
3
  export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
4
4
  export const HUB_MAX_BYTES = 16 * 1024 * 1024
5
+ export const HUB_PREVIEW_PAGE_BYTES = 16 * 1024
5
6
 
6
7
  const httpsUrl = z.string().max(2000).url().refine((value) => {
7
8
  const url = new URL(value)
@@ -53,7 +54,17 @@ export const hubReceiptSchema = z.object({
53
54
  entry: hubEntrySchema,
54
55
  installedAt: z.string(),
55
56
  resourceIds: z.array(z.string()),
56
- document: z.string().nullable()
57
+ document: z.string().nullable(),
58
+ state: z.enum(['installed', 'changed', 'incomplete']).optional()
59
+ }).strict()
60
+
61
+ export const hubPreviewPageSchema = z.object({
62
+ entry: hubEntrySchema,
63
+ content: z.string(),
64
+ offset: z.number().int().nonnegative(),
65
+ nextOffset: z.number().int().nonnegative(),
66
+ totalBytes: z.number().int().nonnegative(),
67
+ done: z.boolean()
57
68
  }).strict()
58
69
 
59
70
  export const hubOverviewSchema = z.object({
@@ -69,6 +80,7 @@ export const hubOverviewSchema = z.object({
69
80
  export type HubEntry = z.infer<typeof hubEntrySchema>
70
81
  export type HubCatalog = z.infer<typeof hubCatalogSchema>
71
82
  export type HubReceipt = z.infer<typeof hubReceiptSchema>
83
+ export type HubPreviewPage = z.infer<typeof hubPreviewPageSchema>
72
84
  export type HubOverview = z.infer<typeof hubOverviewSchema>
73
85
 
74
86
  export function meetsMinimum(actual: string, minimum: string): boolean {
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ export type { Effort } from './sessions/runner'
47
47
 
48
48
  export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
49
49
  export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
50
- export { SPAWN_SUBAGENT, SUBAGENT_RELATION, DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions } from './tools/spawnSubagent'
50
+ export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
51
51
  export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
52
52
  export type { CurationJob } from './memory/curation'
53
53
  export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
@@ -55,7 +55,7 @@ export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, typ
55
55
  export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
56
56
 
57
57
  export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
58
- export type { Connection, ConnectionInput } from './mcp/types'
58
+ export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
59
59
 
60
60
  export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
61
61
 
@@ -73,3 +73,8 @@ export { methods } from './server/methods'
73
73
  export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
74
74
  export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
75
75
  export { attach, serve, type Served } from './server/serve'
76
+
77
+ export { DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions, type SubagentInput } from './sessions/subagents'
78
+
79
+ export { Subagents } from './sessions/subagentService'
80
+ export { WAIT_SUBAGENTS, type SubagentResult, type SubagentSelection } from './sessions/subagents'
@@ -12,7 +12,7 @@ import { isDir } from '../memory/store'
12
12
  import type { RunEvent } from '../sessions/contracts'
13
13
  import * as attachments from '../plugins/attachments'
14
14
  import { connectionScopeMigrationComplete } from './scopeMigration'
15
- import { connectionCapabilityId, connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
15
+ import { connectionCapabilityId, connectionInputSchema, connectionSchema, type AppOAuthClients, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
16
16
 
17
17
  const OAUTH_STATE = 'oauth'
18
18
  const OAUTH_CLIENT_ID = 'oauth_client_id'
@@ -175,7 +175,8 @@ export class Connections {
175
175
  stateDir: string,
176
176
  private readonly secrets: SecretStore,
177
177
  private readonly bus: Bus,
178
- private readonly attachmentRoot: () => string | null = () => null
178
+ private readonly attachmentRoot: () => string | null = () => null,
179
+ private readonly appClients: AppOAuthClients | null = null
179
180
  ) {
180
181
  this.path = join(stateDir, 'connections.json')
181
182
  }
@@ -468,23 +469,49 @@ export class Connections {
468
469
 
469
470
  // ---- OAuth state --------------------------------------------------------------------------
470
471
 
471
- private requireOauth(id: string): void {
472
- if (!this.list().some((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')) throw notFound('Connection')
472
+ private requireOauth(id: string): Connection {
473
+ const connection = this.list().find((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')
474
+ if (!connection) throw notFound('Connection')
475
+ return connection
473
476
  }
474
477
 
475
- /** The saved sign-in of a connection, with the client the user registered, if any. */
478
+ /** The saved sign-in of a connection, with the client the user registered, or the application's own. */
476
479
  async oauthLoad(id: string): Promise<Record<string, unknown>> {
477
- this.requireOauth(id)
480
+ const connection = this.requireOauth(id)
478
481
  const saved = (parsedContainer((await this.secret(id, OAUTH_STATE)) ?? '') ?? {}) as Record<string, unknown>
479
482
  const state = Array.isArray(saved) ? {} : saved
480
- const clientId = await this.secret(id, OAUTH_CLIENT_ID)
481
- if (clientId) {
482
- const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
483
- state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
483
+ delete state.staticClient
484
+ if (connection.oauthClient === 'app') {
485
+ const client = await this.appClient(connection)
486
+ if (client) state.staticClient = { client_id: client.clientId, ...(client.clientSecret ? { client_secret: client.clientSecret } : {}) }
487
+ } else if (connection.oauthClient === 'user' || connection.oauthClient === null) {
488
+ const clientId = await this.secret(id, OAUTH_CLIENT_ID)
489
+ if (clientId) {
490
+ const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
491
+ state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
492
+ }
484
493
  }
485
494
  return state
486
495
  }
487
496
 
497
+ /** The client this application registered for a catalog entry, if it has one. */
498
+ private async appClient(connection: Connection): Promise<{ clientId: string; clientSecret?: string } | null> {
499
+ if (!connection.catalogId) return null
500
+ return (await this.appClients?.get(connection.catalogId)) ?? null
501
+ }
502
+
503
+ /** Whether a sign-in can be started: a dynamic server registers itself, the others need a client. */
504
+ async hasSignInClient(connection: Connection): Promise<boolean> {
505
+ if (connection.oauthClient === 'user') return (await this.secret(connection.id, OAUTH_CLIENT_ID)) !== null
506
+ if (connection.oauthClient === 'app') return (await this.appClient(connection)) !== null
507
+ return true
508
+ }
509
+
510
+ /** The catalog entries this application can sign in for without asking for a client. */
511
+ availableAppClients(): string[] {
512
+ return this.appClients?.ids() ?? []
513
+ }
514
+
488
515
  async oauthSave(id: string, state: Record<string, unknown>): Promise<void> {
489
516
  this.requireOauth(id)
490
517
  const { staticClient: _static, ...saved } = state ?? {}
@@ -515,7 +542,7 @@ export class Connections {
515
542
  if (remote) {
516
543
  if (!webUrl(connection.url)) throw invalid('Server URL must be an https:// address.')
517
544
  if (!['none', 'headers', 'oauth'].includes(connection.auth)) throw invalid('Unsupported sign-in method.')
518
- if (connection.oauthClient && !['dynamic', 'user'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
545
+ if (connection.oauthClient && !['dynamic', 'user', 'app'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
519
546
  Object.assign(connection, { command: '', args: [], cwd: null })
520
547
  if (connection.auth !== 'oauth') Object.assign(connection, { oauthClient: null, oauthScopes: [] })
521
548
  } else {
@@ -533,7 +560,7 @@ export class Connections {
533
560
  connection.envKeys = [...(existing?.envKeys ?? [])]
534
561
  connection.headerKeys = [...(existing?.headerKeys ?? [])]
535
562
  // A sign-in belongs to one server and one client; never carry it to another.
536
- const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && !oauthCredentials
563
+ const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && existing?.oauthClient === connection.oauthClient && existing?.catalogId === connection.catalogId && !oauthCredentials
537
564
  if (!sameServer || connection.auth !== 'oauth') await this.forgetSignIn(connection.id)
538
565
  connection.authState = connection.auth === 'oauth' ? (sameServer ? (existing?.authState ?? 'signed_out') : 'signed_out') : null
539
566
  if (oauthCredentials) {
@@ -8,7 +8,7 @@ import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelconte
8
8
  import { afterEach, describe, expect, it } from 'vitest'
9
9
 
10
10
  import { memorySecretStore } from '../accounts/secrets'
11
- import { createHarness, type Harness } from '../harness'
11
+ import { createHarness, type Harness, type HarnessOptions } from '../harness'
12
12
  import type { RunEvent } from '../sessions/contracts'
13
13
  import type { ToolPolicy } from '../sessions/policy'
14
14
  import type { PiSessionLike } from '../sessions/runner'
@@ -106,12 +106,12 @@ afterEach(async () => {
106
106
  const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
107
107
 
108
108
  /** A harness with one live run whose session keeps going until the test releases it. */
109
- async function live(policy: ToolPolicy = {}) {
109
+ async function live(policy: ToolPolicy = {}, extra: Partial<HarnessOptions> = {}) {
110
110
  let release: () => void = () => undefined
111
111
  const stateDir = mkdtempSync(join(tmpdir(), 'harness-mcp-state-'))
112
112
  const secrets = memorySecretStore()
113
113
  const session: PiSessionLike = { sessionId: 'native', subscribe: () => () => undefined, prompt: () => new Promise<void>((resolve) => (release = resolve)), sendCustomMessage: async () => undefined, abort: async () => release(), dispose: () => undefined }
114
- const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session })
114
+ const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session, ...extra })
115
115
  open.push(harness)
116
116
  harness.accounts.create('Main', 'apiKey')
117
117
  const events: RunEvent[] = []
@@ -277,6 +277,7 @@ describe('remote connected apps', () => {
277
277
  await expect(harness.apps.save({ name: '', kind: 'local', command: 'x' })).rejects.toThrow('A name, executable')
278
278
  await expect(harness.apps.save({ ...local, env: { 'BAD NAME': 'x' } })).rejects.toThrow('environment variable')
279
279
  await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'headers', headers: { 'X-Key': 'a\nb' } })).rejects.toThrow('header')
280
+ await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'oauth', oauthClient: 'whoever' })).rejects.toThrow('Unsupported OAuth client')
280
281
  const workspace = await harness.apps.save({ name: 'Xingular Workspace', kind: 'workspace', command: process.execPath, enabled: true, global: true })
281
282
  expect(() => harness.apps.connections.allowTool(workspace.id, 'x_workspace_mutation_apply')).toThrow('one at a time')
282
283
  await harness.apps.remove(workspace.id)
@@ -373,4 +374,49 @@ describe('signing in to a remote connected app', () => {
373
374
  expect(server.seen.tokenRequests[0]).toContain('fixture-code')
374
375
  expect(server.seen.tokenRequests[0]).toMatch(/own-client|b3duLWNsaWVudDpvd24tc2VjcmV0/)
375
376
  })
377
+
378
+ it('signs in with the application\'s own client, keeps it out of storage, and offers it only for that catalog entry', async () => {
379
+ const server = await oauthFixture()
380
+ const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
381
+ harness.bus.on((notice) => {
382
+ if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
383
+ })
384
+ expect(harness.apps.appClients()).toEqual({ catalogIds: ['fixture-app'] })
385
+ const saved = await harness.apps.save({ name: 'App client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'app', catalogId: 'fixture-app', oauthScopes: ['read'] })
386
+ expect((await harness.apps.signIn(saved.id)).authState).toBe('signed_in')
387
+ // No client of its own is registered, and the application's client is what the provider sees.
388
+ expect(server.seen.registered).toBe(0)
389
+ expect(server.seen.authorizeUrls[0].searchParams.get('client_id')).toBe('app-client')
390
+ // Neither the saved connection nor the secret store ever holds the application's client secret.
391
+ expect(JSON.stringify(harness.apps.connections.list())).not.toContain('app-secret')
392
+ expect(JSON.stringify([...secrets.values.entries()])).not.toContain('app-secret')
393
+ expect(JSON.parse(secrets.values.get(`mcp--${saved.id}/oauth`)!).tokens).toMatchObject({ refresh_token: 'fixture-refresh' })
394
+ })
395
+
396
+ it('uses the application client and resets sign-in when switching from a saved user client', async () => {
397
+ const server = await oauthFixture()
398
+ const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
399
+ harness.bus.on((notice) => {
400
+ if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
401
+ })
402
+ const user = await harness.apps.save({ name: 'Switched client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'user', oauthCredentials: { clientId: 'user-client', clientSecret: 'user-secret' } })
403
+ const signedInUser = await harness.apps.signIn(user.id)
404
+ expect(signedInUser.authState).toBe('signed_in')
405
+
406
+ const app = await harness.apps.save({ ...user, oauthClient: 'app', catalogId: 'fixture-app' })
407
+ expect(app.authState).toBe('signed_out')
408
+ expect(secrets.values.has(`mcp--${user.id}/oauth`)).toBe(false)
409
+ await harness.apps.signIn(user.id)
410
+
411
+ expect(server.seen.authorizeUrls.map((url) => url.searchParams.get('client_id'))).toEqual(['user-client', 'app-client'])
412
+ expect(server.seen.tokenRequests[1]).toMatch(/app-client|YXBwLWNsaWVudDphcHAtc2VjcmV0/)
413
+ })
414
+
415
+ it('refuses an application sign-in this build has no client for, naming the reason', async () => {
416
+ const { harness } = await live()
417
+ const saved = await harness.apps.save({ name: 'No client', kind: 'remote', enabled: true, global: true, url: 'https://example.com/mcp', auth: 'oauth', oauthClient: 'app', catalogId: 'google-drive', oauthScopes: [] })
418
+ expect(harness.apps.appClients()).toEqual({ catalogIds: [] })
419
+ await expect(harness.apps.signIn(saved.id)).rejects.toThrow(/no app sign-in/)
420
+ expect(harness.apps.connections.find(saved.id).authState).toBe('signed_out')
421
+ })
376
422
  })
@@ -20,7 +20,7 @@ import type { ToolSession, ToolSpec } from '../tools/types'
20
20
  import { allowed, Connections, webUrl } from './connections'
21
21
  import { isSignInRequired, McpManager, signInRequired } from './manager'
22
22
  import { signIn, type OAuthHost } from './oauth'
23
- import type { Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
23
+ import type { AppOAuthClients, Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
24
24
 
25
25
  /** A session's access to the broker. A terminal session presents `token`; an SDK session holds the object. */
26
26
  export interface BrokerSession {
@@ -118,6 +118,8 @@ export interface ConnectedAppsOptions {
118
118
  bus: Bus
119
119
  runs: RunRegistry
120
120
  policy: ToolPolicy
121
+ /** The application's own registered OAuth clients, if it has any. */
122
+ oauthClients?: AppOAuthClients
121
123
  }
122
124
 
123
125
  /** The scope a plugin hook's connections are opened under: they belong to no session. */
@@ -134,7 +136,7 @@ export class ConnectedApps implements SessionBroker {
134
136
  private brokered: BrokeredTools | null = null
135
137
 
136
138
  constructor(private readonly options: ConnectedAppsOptions) {
137
- this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root)
139
+ this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root, options.oauthClients ?? null)
138
140
  this.oauth = {
139
141
  clientName: options.env.productName,
140
142
  load: (id) => this.connections.oauthLoad(id),
@@ -469,7 +471,11 @@ export class ConnectedApps implements SessionBroker {
469
471
  async signIn(connectionId: string): Promise<Connection> {
470
472
  const connection = this.connections.find(connectionId)
471
473
  if (connection.kind !== 'remote' || connection.auth !== 'oauth') throw invalid('This connection does not use browser sign-in.')
472
- if (connection.oauthClient === 'user' && !(await this.connections.hasOauthClient(connection.id))) throw invalid('Add your OAuth client ID before signing in.')
474
+ if (!(await this.connections.hasSignInClient(connection))) {
475
+ throw invalid(connection.oauthClient === 'app'
476
+ ? 'This connector has no app sign-in in this build. Enable its OAuth client or use your own client.'
477
+ : 'Add your OAuth client ID before signing in.')
478
+ }
473
479
  await signIn(this.oauth, (await this.connections.launch(connection)) as HttpConfig)
474
480
  const updated = this.connections.update(connectionId, (item) => Object.assign(item, { authState: 'signed_in', error: null }))
475
481
  await this.manager.close(connectionId)
@@ -477,6 +483,11 @@ export class ConnectedApps implements SessionBroker {
477
483
  return updated
478
484
  }
479
485
 
486
+ /** The catalog entries this application can sign in for, so a client need not be added by hand. */
487
+ appClients(): { catalogIds: string[] } {
488
+ return { catalogIds: this.connections.availableAppClients() }
489
+ }
490
+
480
491
  async signOut(connectionId: string): Promise<Connection> {
481
492
  const updated = await this.connections.signOut(connectionId)
482
493
  this.cancelConnection(connectionId)
package/src/mcp/types.ts CHANGED
@@ -7,6 +7,16 @@ import { z } from 'zod'
7
7
  export const MCP_OAUTH_PORT = 47823
8
8
  export const MCP_OAUTH_REDIRECT_URL = `http://127.0.0.1:${MCP_OAUTH_PORT}/oauth/callback`
9
9
 
10
+ /**
11
+ * The application's own registered OAuth clients, by catalog entry. A client supplied here is
12
+ * used for that sign-in only and is never written to the connection or the secret store.
13
+ */
14
+ export interface AppOAuthClients {
15
+ /** The catalog entries this application can sign in for. */
16
+ ids(): string[]
17
+ get(catalogId: string): Promise<{ clientId: string; clientSecret?: string } | null>
18
+ }
19
+
10
20
  /** Stable attachment key for a connected app's capability switch. */
11
21
  export const connectionCapabilityId = (id: string): string => `connected-apps/connection/${id}`
12
22