@xingular/harness 0.0.0-stage → 0.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 (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ // @xingular/harness: a Pi agent harness. Multi-account Pi sessions, a compiled system prompt
2
+ // with stable-prefix delivery, a memory lifecycle (Wiki, blocks, facts, recall, sleep-time
3
+ // consolidation), tools with a gate and standing approvals, connected apps over MCP, and
4
+ // skills (a person's own and built-in).
5
+ //
6
+ // An application embeds it with `createHarness` and extends it through prompt sections, tool
7
+ // providers, a tool policy, a secret store, a directory of its own names, and a description of
8
+ // its own parts as plugins. Everything else is reachable by path, e.g. `@xingular/harness/memory/wiki`.
9
+ export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
10
+
11
+ export { Bus, log, type Notice } from './core/bus'
12
+ export { EMPTY_DIRECTORY, type Directory, type HarnessEnv } from './core/env'
13
+ export { HarnessError, invalid, notFound, toErrorShape, type ErrorShape } from './core/errors'
14
+ export { findBinary, searchPath } from './core/path'
15
+
16
+ export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from './accounts/secrets'
17
+ export type { Account, AccountsOverview, Pool } from './accounts/store'
18
+ export type { ModelOption } from './accounts/accounts'
19
+ export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
20
+
21
+ export type { CallerContext } from './memory/caller'
22
+ export { dispatch as memoryCall } from './memory/dispatch'
23
+
24
+ export {
25
+ compile,
26
+ render,
27
+ SECTION_ORDER,
28
+ CONTEXT_MESSAGE_TYPE,
29
+ CONTEXT_SNAPSHOT_KEY,
30
+ type ContextPack,
31
+ type ContextProfile,
32
+ type HostSection,
33
+ type HostToolGroup,
34
+ type Launch,
35
+ type Reference,
36
+ type Section
37
+ } from './prompt/compile'
38
+
39
+ export { runEventSchema, runRecordSchema, isActiveStatus, type RunEvent, type RunEventType, type RunRecord } from './sessions/contracts'
40
+ export type { ApprovalFacts, ApprovalPolicy, AutomaticReason } from './sessions/approvals'
41
+ export type { ScopeDecision, ToolCall, ToolPolicy } from './sessions/policy'
42
+ export type { ActiveRun, ApprovalDecision, ApprovalInput, QuestionAnswer, QuestionInput } from './sessions/registry'
43
+ export type { RunFields, SessionRunInput } from './sessions/sessions'
44
+ export type { Effort } from './sessions/runner'
45
+
46
+ export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
47
+ export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
48
+ export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
49
+ export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
50
+
51
+ export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
52
+ export type { Connection, ConnectionInput } from './mcp/types'
53
+
54
+ export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
55
+
56
+ export type { TerminalAssets, TerminalLaunch, TerminalPrepareInput } from './terminal/terminals'
57
+
58
+ export { attach as attachPlugin, inventoryFor as pluginInventory, list as listPlugins, type AttachParams, type InventoryParams, type PluginScope } from './plugins/api'
59
+ export type { Attachment, AttachScope } from './plugins/attachments'
60
+ export { HARNESS_PLUGINS } from './plugins/builtin'
61
+ export { FIRST_PARTY_PLUGINS } from './plugins/firstParty'
62
+ export type { Found, Resolver } from './plugins/discover'
63
+ export type { Loading, Registered } from './plugins/plugins'
64
+ export { CONTRIBUTION_KINDS, PLUGIN_SOURCES, type Contribution, type ContributionKind, type ContributionState, type Delivery, type Inventory, type InventoryItem, type Plugin, type PluginDescriptor, type PluginManage, type PluginSetting, type PluginSource } from './plugins/types'
65
+
66
+ export { methods } from './server/methods'
67
+ export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
68
+ export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
69
+ export { attach, serve, type Served } from './server/serve'
@@ -0,0 +1,441 @@
1
+ // Device-local connected apps: the saved connections, who may reach each one, how each is
2
+ // launched, and what must never be persisted from their results.
3
+ import { chmodSync } from 'node:fs'
4
+ import { isAbsolute, join } from 'node:path'
5
+
6
+ import { secretKey, type SecretStore } from '../accounts/secrets'
7
+ import type { Bus } from '../core/bus'
8
+ import { HarnessError, invalid, notFound } from '../core/errors'
9
+ import { newId, readJson, safeId, writeJson } from '../core/fs'
10
+ import { findBinary, searchPath } from '../core/path'
11
+ import { isDir } from '../memory/store'
12
+ import type { RunEvent } from '../sessions/contracts'
13
+ import { connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
14
+
15
+ const OAUTH_STATE = 'oauth'
16
+ const OAUTH_CLIENT_ID = 'oauth_client_id'
17
+ const OAUTH_CLIENT_SECRET = 'oauth_client_secret'
18
+ const headerItem = (name: string): string => `header:${name}`
19
+
20
+ /** Remote servers and sign-in pages must use TLS; plain HTTP is only for servers on this machine. */
21
+ export function webUrl(text: string | null | undefined): URL | null {
22
+ try {
23
+ const url = new URL(text ?? '')
24
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
25
+ return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** Whether a session with this scope may reach the connection. */
32
+ export function allowed(connection: Connection, scope: Scope): boolean {
33
+ if (!connection.enabled) return false
34
+ if (scope.agentId && connection.excludedAgentIds.includes(scope.agentId)) return false
35
+ if (scope.projectId && connection.excludedProjectIds.includes(scope.projectId)) return false
36
+ return connection.global || Boolean(scope.agentId && connection.agentIds.includes(scope.agentId)) || Boolean(scope.projectId && connection.projectIds.includes(scope.projectId))
37
+ }
38
+
39
+ /** Gives one agent exactly these connections, leaving every other agent's access as it was. */
40
+ export function setAgentScope(all: Connection[], agentId: string, ids: readonly string[]): void {
41
+ for (const connection of all) {
42
+ connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
43
+ connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
44
+ if (ids.includes(connection.id)) {
45
+ if (!connection.global) connection.agentIds.push(agentId)
46
+ } else if (connection.global) {
47
+ connection.excludedAgentIds.push(agentId)
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Switches one connection on or off for everyone, for one agent, or for one project, leaving
54
+ * every other agent's and project's access as it was.
55
+ */
56
+ export function setScope(connection: Connection, scope: Scope, on: boolean): void {
57
+ const without = (ids: string[], id: string): string[] => ids.filter((item) => item !== id)
58
+ if (scope.agentId) {
59
+ connection.agentIds = without(connection.agentIds, scope.agentId)
60
+ connection.excludedAgentIds = without(connection.excludedAgentIds, scope.agentId)
61
+ if (on && !connection.global) connection.agentIds.push(scope.agentId)
62
+ // Off must hold in a project that has the connection too.
63
+ else if (!on && (connection.global || connection.projectIds.length)) connection.excludedAgentIds.push(scope.agentId)
64
+ } else if (scope.projectId) {
65
+ connection.projectIds = without(connection.projectIds, scope.projectId)
66
+ connection.excludedProjectIds = without(connection.excludedProjectIds, scope.projectId)
67
+ if (on && !connection.global) connection.projectIds.push(scope.projectId)
68
+ else if (!on && connection.global) connection.excludedProjectIds.push(scope.projectId)
69
+ } else {
70
+ connection.global = on
71
+ }
72
+ }
73
+
74
+ /** Why these connections cannot be given to a session with this scope, or nothing. */
75
+ export function validateScopeIds(all: readonly Connection[], scope: Scope, ids: readonly string[]): void {
76
+ for (const id of ids) {
77
+ const connection = all.find((item) => item.id === id)
78
+ if (!connection) throw notFound('Connection')
79
+ if (!allowed(connection, scope)) throw new HarnessError('required_connections_unavailable', `${connection.name} is disabled or outside your connection scope.`)
80
+ }
81
+ }
82
+
83
+ // ---- Redaction ----------------------------------------------------------------------------------
84
+
85
+ const SENSITIVE_FIELDS = new Set(['approvaltoken', 'token', 'apikey', 'accesstoken', 'refreshtoken', 'idtoken', 'clientsecret', 'secret', 'password', 'passwd', 'authorization', 'cookie', 'setcookie', 'env', 'credentials', 'locators'])
86
+ const SENSITIVE_KEYS = ['approvalToken', 'token', 'apiKey', 'api_key', 'accessToken', 'access_token', 'refreshToken', 'refresh_token', 'clientSecret', 'client_secret', 'password', 'authorization', 'cookie']
87
+
88
+ function sensitiveField(key: string): boolean {
89
+ return SENSITIVE_FIELDS.has(key.replace(/[^A-Za-z0-9]/g, '').toLowerCase())
90
+ }
91
+
92
+ /** Replaces the value of every `"key": "…"` in text that is not valid JSON (a truncated summary). */
93
+ export function redactJsonField(text: string, key: string): string {
94
+ const marker = `"${key}"`
95
+ let output = text
96
+ let offset = 0
97
+ for (;;) {
98
+ const found = output.indexOf(marker, offset)
99
+ if (found < 0) return output
100
+ const start = found + marker.length
101
+ const match = /^\s*:\s*"/.exec(output.slice(start))
102
+ if (!match) {
103
+ offset = start
104
+ continue
105
+ }
106
+ const valueStart = start + match[0].length
107
+ const quote = output.indexOf('"', valueStart)
108
+ const end = quote < 0 ? output.length : quote
109
+ output = `${output.slice(0, valueStart)}[redacted]${output.slice(end)}`
110
+ offset = valueStart + '[redacted]'.length
111
+ }
112
+ }
113
+
114
+ function parsedContainer(text: string): unknown {
115
+ try {
116
+ const parsed = JSON.parse(text) as unknown
117
+ return parsed && typeof parsed === 'object' ? parsed : undefined
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ /** Drops credential-like fields from a value, including from JSON carried as text. */
124
+ export function scrubSensitive(value: unknown): unknown {
125
+ return scrubWith(value, [])
126
+ }
127
+
128
+ /** `scrubSensitive`, and every one of `secrets` replaced wherever it appears in text. */
129
+ export function scrubWith(value: unknown, secrets: readonly string[]): unknown {
130
+ if (Array.isArray(value)) return value.map((item) => scrubWith(item, secrets))
131
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([key]) => !sensitiveField(key)).map(([key, item]) => [key, scrubWith(item, secrets)]))
132
+ if (typeof value !== 'string') return value
133
+ let output = value
134
+ for (const secret of secrets) if (secret) output = output.replaceAll(secret, '[redacted]')
135
+ // Text representations of structured previews must not persist approval tokens.
136
+ const parsed = parsedContainer(output)
137
+ if (parsed !== undefined) return JSON.stringify(scrubWith(parsed, secrets))
138
+ // Summaries can contain truncated JSON, which cannot be parsed above.
139
+ for (const key of SENSITIVE_KEYS) output = redactJsonField(output, key)
140
+ return output
141
+ }
142
+
143
+ export function revisionOf(all: readonly Connection[]): unknown[] {
144
+ return all.map((item) => ({ id: item.id, enabled: item.enabled, global: item.global, agents: item.agentIds, projects: item.projectIds, excludedAgents: item.excludedAgentIds, excludedProjects: item.excludedProjectIds, checkedAt: item.checkedAt, error: item.error }))
145
+ }
146
+
147
+ // ---- The store --------------------------------------------------------------------------------
148
+
149
+ export class Connections {
150
+ private readonly path: string
151
+ /** Every secret value a connection holds, so results can be redacted without waiting on the store. */
152
+ private readonly known = new Map<string, string[]>()
153
+
154
+ constructor(
155
+ stateDir: string,
156
+ private readonly secrets: SecretStore,
157
+ private readonly bus: Bus
158
+ ) {
159
+ this.path = join(stateDir, 'connections.json')
160
+ }
161
+
162
+ list(): Connection[] {
163
+ const stored = readJson<unknown[]>(this.path) ?? []
164
+ return stored.flatMap((item) => {
165
+ const parsed = connectionSchema.safeParse(item)
166
+ if (!parsed.success) return []
167
+ const connection = parsed.data
168
+ // Records saved before remote servers existed have no `auth`.
169
+ if (!connection.auth) connection.auth = 'none'
170
+ // The preset was saved as "X Workspace" before the product was renamed.
171
+ if (connection.kind === 'workspace' && connection.name === 'X Workspace') connection.name = 'Xingular Workspace'
172
+ return [connection]
173
+ })
174
+ }
175
+
176
+ find(id: string): Connection {
177
+ const connection = this.list().find((item) => item.id === id)
178
+ if (!connection) throw notFound('Connection')
179
+ return connection
180
+ }
181
+
182
+ forScope(scope: Scope): Connection[] {
183
+ return this.list().filter((connection) => allowed(connection, scope))
184
+ }
185
+
186
+ revision(): unknown[] {
187
+ return revisionOf(this.list())
188
+ }
189
+
190
+ private write(all: Connection[]): void {
191
+ writeJson(this.path, all)
192
+ try {
193
+ chmodSync(this.path, 0o600)
194
+ } catch {
195
+ // The file was written; a mode that cannot be set is not worth failing the change.
196
+ }
197
+ }
198
+
199
+ changed(): void {
200
+ this.bus.emit({ type: 'connections.changed' })
201
+ }
202
+
203
+ /** Read-modify-write of one connection. */
204
+ update(id: string, change: (connection: Connection) => void): Connection {
205
+ const all = this.list()
206
+ const connection = all.find((item) => item.id === id)
207
+ if (!connection) throw notFound('Connection')
208
+ change(connection)
209
+ this.write(all)
210
+ return connection
211
+ }
212
+
213
+ /** Read-modify-write of every connection. */
214
+ updateAll(change: (all: Connection[]) => void): void {
215
+ const all = this.list()
216
+ change(all)
217
+ this.write(all)
218
+ this.changed()
219
+ }
220
+
221
+ private secret(connectionId: string, name: string): Promise<string | null> {
222
+ return this.secrets.get(secretKey(`mcp--${connectionId}`, name))
223
+ }
224
+
225
+ private setSecret(connectionId: string, name: string, value: string): Promise<void> {
226
+ return this.secrets.set(secretKey(`mcp--${connectionId}`, name), value)
227
+ }
228
+
229
+ private deleteSecret(connectionId: string, name: string): Promise<void> {
230
+ return this.secrets.delete(secretKey(`mcp--${connectionId}`, name))
231
+ }
232
+
233
+ /** Everything the secret store holds for a connection: environment and header values, and OAuth credentials. */
234
+ private async secretValues(connection: Connection): Promise<string[]> {
235
+ const values: string[] = []
236
+ for (const key of connection.envKeys) {
237
+ const value = await this.secret(connection.id, key)
238
+ if (value) values.push(value)
239
+ }
240
+ for (const key of connection.headerKeys) {
241
+ const value = await this.secret(connection.id, headerItem(key))
242
+ if (!value) continue
243
+ // "Authorization: Bearer <key>" must not leak the key on its own either.
244
+ const space = value.indexOf(' ')
245
+ if (space >= 0) values.push(value.slice(space + 1).trim())
246
+ values.push(value)
247
+ }
248
+ if (connection.auth === 'oauth') {
249
+ const client = await this.secret(connection.id, OAUTH_CLIENT_SECRET)
250
+ if (client) values.push(client)
251
+ const saved = (parsedContainer((await this.secret(connection.id, OAUTH_STATE)) ?? '') ?? {}) as { tokens?: Record<string, unknown> }
252
+ for (const field of ['access_token', 'refresh_token', 'id_token']) if (typeof saved.tokens?.[field] === 'string') values.push(saved.tokens[field] as string)
253
+ }
254
+ return values.filter(Boolean)
255
+ }
256
+
257
+ /** Reloads the secret values used for redaction. Called at start and whenever a connection's secrets change. */
258
+ async refreshSecrets(): Promise<void> {
259
+ const next = new Map<string, string[]>()
260
+ for (const connection of this.list()) next.set(connection.id, await this.secretValues(connection).catch(() => []))
261
+ this.known.clear()
262
+ for (const [id, values] of next) this.known.set(id, values)
263
+ }
264
+
265
+ /** A connection's result, safe to persist. */
266
+ scrub(value: unknown, connectionId: string): unknown {
267
+ return scrubWith(value, this.known.get(connectionId) ?? [])
268
+ }
269
+
270
+ /** Removes credentials from the evidence a run event carries. */
271
+ redactEvent(event: RunEvent): void {
272
+ if (!event.type.startsWith('tool.') && !['context.snapshot', 'retry.started', 'retry.ended', 'compaction.started', 'compaction.ended'].includes(event.type)) return
273
+ const secrets = [...this.known.values()].flat()
274
+ event.payload = scrubWith(event.payload, secrets)
275
+ if (event.native !== undefined) event.native = scrubWith(event.native, secrets)
276
+ }
277
+
278
+ // ---- Launching ----------------------------------------------------------------------------
279
+
280
+ async launch(connection: Connection): Promise<LaunchConfig> {
281
+ if (connection.kind === 'remote') {
282
+ const headers: Record<string, string> = {}
283
+ for (const key of connection.headerKeys) {
284
+ const value = await this.secret(connection.id, headerItem(key))
285
+ if (value !== null) headers[key] = value
286
+ }
287
+ return { id: connection.id, name: connection.name, transport: 'http', url: connection.url ?? '', headers, auth: connection.auth as 'none' | 'headers' | 'oauth', scopes: connection.oauthScopes }
288
+ }
289
+ let command = findBinary(connection.command) ?? (isAbsolute(connection.command) ? connection.command : null)
290
+ if (!command) throw new HarnessError('mcp_unavailable', 'MCP executable was not found. Select its absolute path.')
291
+ const env: Record<string, string> = { PATH: searchPath() }
292
+ for (const key of ['HOME', 'TMPDIR', 'LANG']) if (process.env[key]) env[key] = process.env[key]!
293
+ for (const key of connection.envKeys) {
294
+ const value = await this.secret(connection.id, key)
295
+ if (value !== null) env[key] = value
296
+ }
297
+ const args = [...connection.args]
298
+ if (connection.kind === 'workspace') {
299
+ // The first-party launcher is JavaScript; run it on this process's Node even where none is on PATH.
300
+ args.unshift(command)
301
+ command = process.execPath
302
+ if (connection.expectedVaultId && !args.includes('--expected-vault-id')) args.push('--expected-vault-id', connection.expectedVaultId)
303
+ }
304
+ return { id: connection.id, name: connection.name, transport: 'stdio', command, args, cwd: connection.cwd, env }
305
+ }
306
+
307
+ // ---- OAuth state --------------------------------------------------------------------------
308
+
309
+ private requireOauth(id: string): void {
310
+ if (!this.list().some((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')) throw notFound('Connection')
311
+ }
312
+
313
+ /** The saved sign-in of a connection, with the client the user registered, if any. */
314
+ async oauthLoad(id: string): Promise<Record<string, unknown>> {
315
+ this.requireOauth(id)
316
+ const saved = (parsedContainer((await this.secret(id, OAUTH_STATE)) ?? '') ?? {}) as Record<string, unknown>
317
+ const state = Array.isArray(saved) ? {} : saved
318
+ const clientId = await this.secret(id, OAUTH_CLIENT_ID)
319
+ if (clientId) {
320
+ const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
321
+ state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
322
+ }
323
+ return state
324
+ }
325
+
326
+ async oauthSave(id: string, state: Record<string, unknown>): Promise<void> {
327
+ this.requireOauth(id)
328
+ const { staticClient: _static, ...saved } = state ?? {}
329
+ await this.setSecret(id, OAUTH_STATE, JSON.stringify(saved))
330
+ await this.refreshSecrets()
331
+ }
332
+
333
+ async hasOauthClient(id: string): Promise<boolean> {
334
+ return (await this.secret(id, OAUTH_CLIENT_ID)) !== null
335
+ }
336
+
337
+ private forgetSignIn(id: string): Promise<void> {
338
+ return this.deleteSecret(id, OAUTH_STATE)
339
+ }
340
+
341
+ // ---- Changes ------------------------------------------------------------------------------
342
+
343
+ async save(input: ConnectionInput): Promise<Connection> {
344
+ const parsed = connectionInputSchema.safeParse(input)
345
+ if (!parsed.success) throw invalid('A name, executable, and supported connection kind are required.')
346
+ const { env, removeEnv, headers, removeHeaders, oauthCredentials, ...connection } = parsed.data
347
+ if (!connection.id) connection.id = newId()
348
+ safeId(connection.id)
349
+ const remote = connection.kind === 'remote'
350
+ if (!connection.name.trim() || !['workspace', 'local', 'remote'].includes(connection.kind) || (!remote && !connection.command.trim())) {
351
+ throw invalid('A name, executable, and supported connection kind are required.')
352
+ }
353
+ if (remote) {
354
+ if (!webUrl(connection.url)) throw invalid('Server URL must be an https:// address.')
355
+ if (!['none', 'headers', 'oauth'].includes(connection.auth)) throw invalid('Unsupported sign-in method.')
356
+ if (connection.oauthClient && !['dynamic', 'user'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
357
+ Object.assign(connection, { command: '', args: [], cwd: null })
358
+ if (connection.auth !== 'oauth') Object.assign(connection, { oauthClient: null, oauthScopes: [] })
359
+ } else {
360
+ Object.assign(connection, { url: null, auth: 'none', oauthClient: null, oauthScopes: [] })
361
+ }
362
+ if (connection.args.some((value) => value.includes('\0')) || connection.command.includes('\0')) throw invalid('Invalid executable or argument.')
363
+ if (connection.cwd && (!isAbsolute(connection.cwd) || !isDir(connection.cwd))) throw invalid('Working directory must be an existing absolute directory.')
364
+
365
+ const all = this.list()
366
+ const existing = all.find((item) => item.id === connection.id)
367
+ connection.envKeys = [...(existing?.envKeys ?? [])]
368
+ connection.headerKeys = [...(existing?.headerKeys ?? [])]
369
+ // A sign-in belongs to one server and one client; never carry it to another.
370
+ const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && !oauthCredentials
371
+ if (!sameServer || connection.auth !== 'oauth') await this.forgetSignIn(connection.id)
372
+ connection.authState = connection.auth === 'oauth' ? (sameServer ? (existing?.authState ?? 'signed_out') : 'signed_out') : null
373
+ if (oauthCredentials) {
374
+ if (!oauthCredentials.clientId.trim()) throw invalid('An OAuth client ID is required.')
375
+ await this.setSecret(connection.id, OAUTH_CLIENT_ID, oauthCredentials.clientId.trim())
376
+ if (oauthCredentials.clientSecret) await this.setSecret(connection.id, OAUTH_CLIENT_SECRET, oauthCredentials.clientSecret)
377
+ else await this.deleteSecret(connection.id, OAUTH_CLIENT_SECRET)
378
+ }
379
+ for (const [key, value] of Object.entries(headers ?? {})) {
380
+ if (!/^[A-Za-z0-9_-]+$/.test(key) || /[\r\n\0]/.test(value)) throw invalid('Invalid header name or value.')
381
+ await this.setSecret(connection.id, headerItem(key), value)
382
+ if (!connection.headerKeys.includes(key)) connection.headerKeys.push(key)
383
+ }
384
+ for (const key of removeHeaders ?? []) {
385
+ await this.deleteSecret(connection.id, headerItem(key))
386
+ connection.headerKeys = connection.headerKeys.filter((value) => value !== key)
387
+ }
388
+ for (const [key, value] of Object.entries(env ?? {})) {
389
+ if (!/^[A-Za-z0-9_]+$/.test(key)) throw invalid('Invalid environment variable name.')
390
+ await this.setSecret(connection.id, key, value)
391
+ if (!connection.envKeys.includes(key)) connection.envKeys.push(key)
392
+ }
393
+ for (const key of removeEnv ?? []) {
394
+ await this.deleteSecret(connection.id, key)
395
+ connection.envKeys = connection.envKeys.filter((value) => value !== key)
396
+ }
397
+ Object.assign(connection, { checkedAt: null, tools: [], serverName: null, error: null })
398
+ this.write([...all.filter((item) => item.id !== connection.id), connection])
399
+ await this.refreshSecrets()
400
+ this.changed()
401
+ return connection
402
+ }
403
+
404
+ /**
405
+ * Adds one tool to a connection's no-approval list. Unlike a full save this keeps the
406
+ * connection's pending approvals, so the request the user is answering can still be approved.
407
+ */
408
+ allowTool(connectionId: string, tool: string): Connection {
409
+ if (!tool.trim()) throw invalid('A tool is required.')
410
+ if (this.list().some((item) => item.id === connectionId && item.kind === 'workspace')) throw invalid('Changes to Xingular Workspace are approved one at a time.')
411
+ const connection = this.update(connectionId, (item) => {
412
+ if (!item.readTools.includes(tool)) item.readTools.push(tool)
413
+ })
414
+ this.changed()
415
+ return connection
416
+ }
417
+
418
+ async remove(connectionId: string): Promise<void> {
419
+ const all = this.list()
420
+ const connection = all.find((item) => item.id === connectionId)
421
+ if (connection) {
422
+ for (const key of connection.envKeys) await this.deleteSecret(connection.id, key)
423
+ for (const key of connection.headerKeys) await this.deleteSecret(connection.id, headerItem(key))
424
+ for (const name of [OAUTH_STATE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET]) await this.deleteSecret(connection.id, name)
425
+ }
426
+ this.write(all.filter((item) => item.id !== connectionId))
427
+ this.known.delete(connectionId)
428
+ this.changed()
429
+ }
430
+
431
+ async signOut(connectionId: string): Promise<Connection> {
432
+ await this.forgetSignIn(connectionId)
433
+ const connection = this.update(connectionId, (item) => {
434
+ if (item.auth === 'oauth') item.authState = 'signed_out'
435
+ Object.assign(item, { checkedAt: null, tools: [], error: null })
436
+ })
437
+ await this.refreshSecrets()
438
+ this.changed()
439
+ return connection
440
+ }
441
+ }
@@ -0,0 +1,130 @@
1
+ // Live MCP client sessions: one per (session, connection), opened on first use and closed when
2
+ // the session's run ends or the connection changes.
3
+ import { Client, SSEClientTransport, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'
4
+ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ import { HarnessError } from '../core/errors'
8
+ import { StoredOAuthProvider, type OAuthHost } from './oauth'
9
+ import type { HttpConfig, LaunchConfig, StdioConfig } from './types'
10
+
11
+ interface Live {
12
+ client: Client
13
+ transport: { close(): Promise<void> }
14
+ config: string
15
+ }
16
+
17
+ export const signInRequired = (config: Pick<LaunchConfig, 'name'>): HarnessError => new HarnessError('mcp_unauthorized', `${config.name} needs you to sign in again in Connections.`)
18
+ export const isSignInRequired = (error: unknown): boolean => error instanceof UnauthorizedError || (error instanceof HarnessError && error.code === 'mcp_unauthorized')
19
+
20
+ export class McpManager {
21
+ private readonly sessions = new Map<string, Promise<Live>>()
22
+
23
+ constructor(private readonly oauth: OAuthHost) {}
24
+
25
+ async close(connectionId?: string, sessionId?: string): Promise<void> {
26
+ const closing = [...this.sessions].filter(([key]) => (!connectionId || key.endsWith(`:${connectionId}`)) && (!sessionId || key.startsWith(`${sessionId}:`)))
27
+ for (const [key] of closing) this.sessions.delete(key)
28
+ await Promise.allSettled(
29
+ closing.map(async ([, session]) => {
30
+ const live = await session
31
+ await live.client.close()
32
+ await live.transport.close()
33
+ })
34
+ )
35
+ }
36
+
37
+ /** The client for a connection in one scope, reconnecting when the connection's configuration changed. */
38
+ async client(scope: string, config: LaunchConfig): Promise<Client> {
39
+ const key = `${scope}:${config.id}`
40
+ const fingerprint = JSON.stringify(config)
41
+ const existing = this.sessions.get(key)
42
+ if (existing) {
43
+ const live = await existing.catch(() => null)
44
+ if (live?.config === fingerprint) return live.client
45
+ await live?.client.close().catch(() => undefined)
46
+ this.sessions.delete(key)
47
+ }
48
+ const pending: Promise<Live> = (async () => {
49
+ const { client, transport } = config.transport === 'http' ? await this.connectHttp(config) : await this.connectStdio(config)
50
+ client.onclose = () => {
51
+ if (this.sessions.get(key) === pending) this.sessions.delete(key)
52
+ }
53
+ return { client, transport, config: fingerprint }
54
+ })()
55
+ this.sessions.set(key, pending)
56
+ try {
57
+ return (await pending).client
58
+ } catch (error) {
59
+ this.sessions.delete(key)
60
+ throw error
61
+ }
62
+ }
63
+
64
+ private async connectStdio(config: StdioConfig) {
65
+ const client = new Client({ name: 'agent-x', version: '0.1.0' })
66
+ const transport = new StdioClientTransport({ command: config.command, args: config.args, cwd: config.cwd ?? undefined, env: config.env, stderr: 'pipe' })
67
+ try {
68
+ await client.connect(transport, { timeout: 30000 })
69
+ } catch {
70
+ await transport.close()
71
+ throw new HarnessError('mcp_unavailable', `${config.name} could not start or initialize. Check its executable and arguments.`)
72
+ }
73
+ // Servers' diagnostics may contain secrets; do not persist or forward stderr.
74
+ transport.stderr?.on('data', () => undefined)
75
+ return { client, transport }
76
+ }
77
+
78
+ private async connectHttp(config: HttpConfig) {
79
+ const authProvider = config.auth === 'oauth' ? new StoredOAuthProvider(this.oauth, config, false) : undefined
80
+ // Without saved tokens the only way forward is the browser, which a session may not open.
81
+ if (authProvider && !(await authProvider.tokens())) throw signInRequired(config)
82
+ const options = { authProvider, requestInit: { headers: config.headers } }
83
+ // Servers that predate Streamable HTTP only answer on SSE.
84
+ for (const Transport of [StreamableHTTPClientTransport, SSEClientTransport]) {
85
+ const client = new Client({ name: 'agent-x', version: '0.1.0' })
86
+ const transport = new Transport(new URL(config.url), options)
87
+ try {
88
+ await client.connect(transport, { timeout: 30000 })
89
+ return { client, transport }
90
+ } catch (error) {
91
+ await transport.close().catch(() => undefined)
92
+ if (isSignInRequired(error)) throw signInRequired(config)
93
+ }
94
+ }
95
+ throw new HarnessError('mcp_unavailable', `${config.name} could not be reached. Check its address and credentials.`)
96
+ }
97
+
98
+ /** Runs `work` on a connection opened only for it. */
99
+ private async once<T>(prefix: string, config: LaunchConfig, work: (client: Client) => Promise<T>): Promise<T> {
100
+ const scope = `${prefix}-${randomUUID()}`
101
+ try {
102
+ return await work(await this.client(scope, config))
103
+ } finally {
104
+ const key = `${scope}:${config.id}`
105
+ const session = this.sessions.get(key)
106
+ this.sessions.delete(key)
107
+ if (session) await (await session).client.close().catch(() => undefined)
108
+ }
109
+ }
110
+
111
+ /** What a connection offers, read on a connection of its own. */
112
+ probe(config: LaunchConfig): Promise<{ serverName: string; tools: unknown[]; vaultId: string | null }> {
113
+ return this.once('probe', config, async (client) => {
114
+ const tools = (await client.listTools()).tools
115
+ let vaultId: string | null = null
116
+ if (tools.some((tool) => tool.name === 'x_workspace_status')) {
117
+ const status = await client.callTool({ name: 'x_workspace_status', arguments: {} })
118
+ const envelope = status.structuredContent as { ok?: boolean; vaultId?: string; error?: { message?: string } } | undefined
119
+ if (!envelope?.ok) throw new HarnessError('mcp_unavailable', envelope?.error?.message ?? 'Workspace vault is not bound.')
120
+ vaultId = envelope.vaultId ?? null
121
+ }
122
+ return { serverName: client.getServerVersion()?.name ?? config.name, tools, vaultId }
123
+ })
124
+ }
125
+
126
+ /** One read on a connection of its own, outside any session. */
127
+ readSource(config: LaunchConfig, tool: string, args: Record<string, unknown>): Promise<unknown> {
128
+ return this.once('source', config, (client) => client.callTool({ name: tool, arguments: args }, { timeout: 60000 }))
129
+ }
130
+ }