@xingular/harness 0.1.6 → 2.0.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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod'
2
+
3
+ export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
4
+ export const HUB_MAX_BYTES = 16 * 1024 * 1024
5
+
6
+ const httpsUrl = z.string().max(2000).url().refine((value) => {
7
+ const url = new URL(value)
8
+ return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash && !url.port
9
+ }, 'Use a public HTTPS URL without credentials, queries, or fragments.')
10
+ const payload = {
11
+ url: httpsUrl,
12
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
13
+ bundled: z.string().max(HUB_MAX_BYTES).optional()
14
+ }
15
+
16
+ export const hubDeliverySchema = z.discriminatedUnion('kind', [
17
+ z.object({ kind: z.literal('registry'), registryKind: z.enum(['skills', 'prompts', 'rules']), ...payload }).strict(),
18
+ z.object({ kind: z.literal('document'), ...payload }).strict(),
19
+ z.object({ kind: z.literal('pi-package'), source: z.string().max(2000).refine((source) =>
20
+ /^npm:(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+@\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(source)
21
+ || /^git:https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:\.git)?@[a-f0-9]{40}$/.test(source),
22
+ 'Packages must use an exact npm version or a GitHub commit.') }).strict()
23
+ ])
24
+
25
+ export const hubEntrySchema = z.object({
26
+ id: z.string().regex(/^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/),
27
+ title: z.string().min(1).max(100),
28
+ description: z.string().min(1).max(500),
29
+ publisher: z.string().min(1).max(100),
30
+ license: z.string().min(1).max(100),
31
+ version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/),
32
+ category: z.string().regex(/^[a-z][a-z-]{0,40}$/),
33
+ channel: z.enum(['official', 'community']),
34
+ tags: z.array(z.string().max(60)).max(20),
35
+ requires: z.record(z.string(), z.string().regex(/^\d+\.\d+\.\d+$/)),
36
+ sourceUrl: httpsUrl,
37
+ delivery: hubDeliverySchema
38
+ }).strict()
39
+
40
+ export const hubCatalogSchema = z.object({
41
+ schemaVersion: z.literal(1),
42
+ entries: z.array(hubEntrySchema).max(2000)
43
+ }).strict().superRefine((catalog, ctx) => {
44
+ const keys = new Set<string>()
45
+ catalog.entries.forEach((entry, index) => {
46
+ const key = `${entry.id}@${entry.version}`
47
+ if (keys.has(key)) ctx.addIssue({ code: 'custom', path: ['entries', index, 'id'], message: 'Duplicate catalog item version.' })
48
+ keys.add(key)
49
+ })
50
+ })
51
+
52
+ export const hubReceiptSchema = z.object({
53
+ entry: hubEntrySchema,
54
+ installedAt: z.string(),
55
+ resourceIds: z.array(z.string()),
56
+ document: z.string().nullable()
57
+ }).strict()
58
+
59
+ export const hubOverviewSchema = z.object({
60
+ entries: z.array(hubEntrySchema),
61
+ installed: z.array(hubReceiptSchema),
62
+ origin: z.enum(['bundled', 'cached', 'remote']),
63
+ refreshedAt: z.string().nullable(),
64
+ error: z.string().nullable(),
65
+ dataRoot: z.string().nullable(),
66
+ stateDir: z.string()
67
+ })
68
+
69
+ export type HubEntry = z.infer<typeof hubEntrySchema>
70
+ export type HubCatalog = z.infer<typeof hubCatalogSchema>
71
+ export type HubReceipt = z.infer<typeof hubReceiptSchema>
72
+ export type HubOverview = z.infer<typeof hubOverviewSchema>
73
+
74
+ export function meetsMinimum(actual: string, minimum: string): boolean {
75
+ const left = actual.split(/[.-]/).slice(0, 3).map(Number)
76
+ const right = minimum.split('.').map(Number)
77
+ for (let index = 0; index < 3; index += 1) {
78
+ if (left[index] !== right[index]) return left[index] > right[index]
79
+ }
80
+ return true
81
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // providers, a tool policy, a secret store, a directory of its own names, and a description of
8
8
  // its own parts as plugins. Everything else is reachable by path, e.g. `@xingular/harness/memory/wiki`.
9
9
  export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
10
+ export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './hub/schema'
10
11
 
11
12
  export { Bus, log, type Notice } from './core/bus'
12
13
  export { EMPTY_DIRECTORY, type Directory, type HarnessEnv } from './core/env'
@@ -15,7 +16,7 @@ export { findBinary, searchPath } from './core/path'
15
16
 
16
17
  export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from './accounts/secrets'
17
18
  export type { Account, AccountsOverview, Pool } from './accounts/store'
18
- export type { ModelOption } from './accounts/accounts'
19
+ export type { AccountLoginSnapshot, ModelOption } from './accounts/accounts'
19
20
  export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
20
21
  export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
21
22
 
@@ -46,11 +47,15 @@ export type { Effort } from './sessions/runner'
46
47
 
47
48
  export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
48
49
  export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
50
+ export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
51
+ export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
52
+ export type { CurationJob } from './memory/curation'
49
53
  export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
54
+ export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, type RegistryToolOptions } from './tools/registryWrite'
50
55
  export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
51
56
 
52
57
  export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
53
- export type { Connection, ConnectionInput } from './mcp/types'
58
+ export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
54
59
 
55
60
  export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
56
61
 
@@ -68,3 +73,8 @@ export { methods } from './server/methods'
68
73
  export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
69
74
  export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
70
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
 
package/src/memory/api.ts CHANGED
@@ -11,6 +11,7 @@ import { findRun, readEvents } from '../sessions/runStore'
11
11
  import type { RunEvent } from '../sessions/contracts'
12
12
  import { takeChars } from '../core/text'
13
13
  import * as registry from '../skills/registry'
14
+ import { enqueueCuration } from './curation'
14
15
  import * as blocks from './blocks'
15
16
  import { actorOf, blockCaller, projectOf, type CallerContext } from './caller'
16
17
  import * as facts from './facts'
@@ -104,7 +105,7 @@ export function search(env: HarnessEnv, context: CallerContext, params: Params):
104
105
  // Another agent's blocks never appear; a block of the caller's own does.
105
106
  const own = ownBlocksPrefix(env, context)
106
107
  const hits = searchEntries(root, query, { kinds, namespace: textParam(params, 'namespace') || null, projectId: context.projectId, limit: limit + 6 })
107
- .filter((hit) => !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
108
+ .filter((hit) => context.vaultAccess === 'curator' || !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
108
109
  .slice(0, limit)
109
110
  if (!hits.length) return 'No matching Wiki entries.'
110
111
  const wiki = store.wikiDir(root)
@@ -126,7 +127,7 @@ export function read(env: HarnessEnv, context: CallerContext, params: Params): s
126
127
  // An agent's own blocks are not readable by other agents or by sessions.
127
128
  if (entry.kind === 'block' && entry.block.scope === 'agent') {
128
129
  const own = ownBlocksPrefix(env, context)
129
- if (own === null || `blocks/agents/${entry.block.owner}/` !== own) throw notFound('Wiki entry')
130
+ if (context.vaultAccess !== 'curator' && (own === null || `blocks/agents/${entry.block.owner}/` !== own)) throw notFound('Wiki entry')
130
131
  }
131
132
  const linking = backlinks(root, entryId(entry))
132
133
  const linked = linking.length ? linking.map((link) => `${link.fromId} (${link.type})`).join(', ') : 'none'
@@ -272,13 +273,26 @@ export function propose(env: HarnessEnv, context: CallerContext, params: Params)
272
273
  proposals = {
273
274
  memories: [{ id: optional(params, 'id'), title, kind: optional(params, 'type') ?? 'fact', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), supersedes: optional(params, 'supersedes'), volatile: typeof params?.volatile === 'boolean' ? params.volatile : null, sourceRefs: sourceRefs(params, context), links: links(params) }]
274
275
  }
276
+ } else if (kind === 'block') {
277
+ const id = textParam(params, 'id')
278
+ const block = blocks.find(wiki, id)
279
+ if (block.front.readOnly) throw invalid('This block is user-only; only the user can change it.')
280
+ proposals = { blocks: [{ block: id, body, sourceRefs: sourceRefs(params, context) }] }
275
281
  } else if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
282
+ if (context.vaultAccess) {
283
+ const job = enqueueCuration(root, { kind: 'registry', proposal: { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') }, caller: context })
284
+ return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
285
+ }
276
286
  const id = parkRegistryProposal(wiki, trigger, { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') })
277
287
  return `The ${kind} change (${id}) is waiting in the Wiki Inbox. Skills, prompts, and rules change only after the user accepts them.`
278
288
  } else {
279
289
  throw invalid('kind must be page, fact, skill, rule, or prompt.')
280
290
  }
281
291
  // The caller's own run is evidence only for what it can quote from it.
292
+ if (context.vaultAccess) {
293
+ const job = enqueueCuration(root, { kind: 'proposal', namespace: defaultNamespace, proposals: parseProposalSet(proposals), caller: context })
294
+ return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
295
+ }
282
296
  const outcome = apply(wiki, defaultNamespace, trigger, parseProposalSet(proposals), captureHashes(wiki), { runText: context.runId ? evidenceText(root, context.runId) : null })
283
297
  let text = outcome.notes.join('\n')
284
298
  if (outcome.operation) {
@@ -52,7 +52,7 @@ export interface Block {
52
52
  export type Caller =
53
53
  | { kind: 'user' }
54
54
  /** An agent with blocks of its own, and the name recorded on its revisions. */
55
- | { kind: 'agent'; id: string; name: string }
55
+ | { kind: 'agent'; id: string; name: string; vaultAccess?: 'self' | 'propose' | 'curator' }
56
56
  /** A session without blocks of its own, and its project, if any. */
57
57
  | { kind: 'session'; projectSlug: string | null }
58
58
 
@@ -192,6 +192,8 @@ export function editRefusal(caller: Caller, block: Block): string | null {
192
192
  const label = block.front.label
193
193
  if (caller.kind === 'user') return null
194
194
  if (block.front.readOnly) return `The ${label} block is read-only; only the user can change it.`
195
+ if (caller.kind === 'agent' && caller.vaultAccess === 'propose') return 'Memory changes are proposals in this session. Submit the block change with wiki_propose; the next memory merge applies it.'
196
+ if (caller.kind === 'agent' && caller.vaultAccess === 'self' && (block.scope !== 'agent' || block.owner !== caller.id || !['persona', 'notes'].includes(label))) return 'This field is outside your writable memory. Submit a block change with wiki_propose for the curator to review.'
195
197
  if (caller.kind === 'agent') {
196
198
  if (block.scope === 'agent') return block.owner === caller.id ? null : 'That block belongs to another agent.'
197
199
  if (block.scope === 'shared') return !block.front.agents.length || block.front.agents.includes(caller.id) ? null : `The ${label} block is not attached to you.`
@@ -10,11 +10,23 @@ import * as blocks from './blocks'
10
10
  * never limits what can be read.
11
11
  */
12
12
  export interface CallerContext {
13
+ /** Opaque, host-owned policy data frozen when this session starts. The harness only forwards it to the embedding app's tool policy. */
14
+ policyContext?: unknown
15
+ /**
16
+ * Trusted, frozen vault mutation capability supplied by an embedding host. `self` writes its own
17
+ * persona and notes directly and proposes the rest; `propose` writes nothing directly; `curator`
18
+ * applies proposals.
19
+ */
20
+ vaultAccess?: 'self' | 'propose' | 'curator'
21
+ maintenanceJobId?: string | null
22
+ maintenanceJobIds?: string[]
13
23
  runId?: string | null
14
24
  sessionId?: string | null
15
25
  projectId?: string | null
16
26
  /** The agent calling; it decides which memory blocks are attached and editable. */
17
27
  agentId?: string | null
28
+ /** Agents whose conversations this caller may not recall (opaque ids from the embedding host). */
29
+ recallExcludeAgents?: string[] | null
18
30
  /** The agent's name, recorded on the revisions it makes. */
19
31
  agentName?: string | null
20
32
  /** Projects whose briefs the agent carries, most recently active first. */
@@ -42,7 +54,7 @@ export function projectOf(root: string, context: CallerContext): Project | null
42
54
  export function blockCaller(env: HarnessEnv, context: CallerContext): blocks.Caller {
43
55
  if (isSession(context)) return { kind: 'session', projectSlug: projectOf(env.root, context)?.slug ?? null }
44
56
  if (!context.agentId) throw invalid('Memory blocks need an agent or a Harness session.')
45
- return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId }
57
+ return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId, vaultAccess: context.vaultAccess === 'propose' ? 'propose' : context.vaultAccess ? 'self' : undefined }
46
58
  }
47
59
 
48
60
  /** The blocks in the caller's context: an agent also carries the briefs of its active projects. */