@xingular/harness 0.0.0-stage → 0.1.1
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.
- package/README.md +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,614 @@
|
|
|
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 * as attachments from '../plugins/attachments'
|
|
14
|
+
import { connectionScopeMigrationComplete } from './scopeMigration'
|
|
15
|
+
import { connectionCapabilityId, connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
|
|
16
|
+
|
|
17
|
+
const OAUTH_STATE = 'oauth'
|
|
18
|
+
const OAUTH_CLIENT_ID = 'oauth_client_id'
|
|
19
|
+
const OAUTH_CLIENT_SECRET = 'oauth_client_secret'
|
|
20
|
+
const headerItem = (name: string): string => `header:${name}`
|
|
21
|
+
|
|
22
|
+
/** Remote servers and sign-in pages must use TLS; plain HTTP is only for servers on this machine. */
|
|
23
|
+
export function webUrl(text: string | null | undefined): URL | null {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(text ?? '')
|
|
26
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
|
|
27
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a session with this scope may reach the connection. */
|
|
34
|
+
export function allowed(connection: Connection, scope: Scope): boolean {
|
|
35
|
+
if (!connection.enabled) return false
|
|
36
|
+
if (scope.agentId && connection.excludedAgentIds.includes(scope.agentId)) return false
|
|
37
|
+
if (scope.projectId && connection.excludedProjectIds.includes(scope.projectId)) return false
|
|
38
|
+
return connection.global || Boolean(scope.agentId && connection.agentIds.includes(scope.agentId)) || Boolean(scope.projectId && connection.projectIds.includes(scope.projectId))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Gives one agent exactly these connections, leaving every other agent's access as it was. */
|
|
42
|
+
export function setAgentScope(all: Connection[], agentId: string, ids: readonly string[]): void {
|
|
43
|
+
for (const connection of all) {
|
|
44
|
+
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
45
|
+
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
46
|
+
if (ids.includes(connection.id)) {
|
|
47
|
+
if (!connection.global) connection.agentIds.push(agentId)
|
|
48
|
+
} else if (connection.global) {
|
|
49
|
+
connection.excludedAgentIds.push(agentId)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Switches one connection on or off for everyone, for one agent, or for one project, leaving
|
|
56
|
+
* every other agent's and project's access as it was.
|
|
57
|
+
*/
|
|
58
|
+
export function setScope(connection: Connection, scope: Scope, on: boolean): void {
|
|
59
|
+
const without = (ids: string[], id: string): string[] => ids.filter((item) => item !== id)
|
|
60
|
+
if (scope.agentId) {
|
|
61
|
+
connection.agentIds = without(connection.agentIds, scope.agentId)
|
|
62
|
+
connection.excludedAgentIds = without(connection.excludedAgentIds, scope.agentId)
|
|
63
|
+
if (on && !connection.global) connection.agentIds.push(scope.agentId)
|
|
64
|
+
// Off must hold in a project that has the connection too.
|
|
65
|
+
else if (!on && (connection.global || connection.projectIds.length)) connection.excludedAgentIds.push(scope.agentId)
|
|
66
|
+
} else if (scope.projectId) {
|
|
67
|
+
connection.projectIds = without(connection.projectIds, scope.projectId)
|
|
68
|
+
connection.excludedProjectIds = without(connection.excludedProjectIds, scope.projectId)
|
|
69
|
+
if (on && !connection.global) connection.projectIds.push(scope.projectId)
|
|
70
|
+
else if (!on && connection.global) connection.excludedProjectIds.push(scope.projectId)
|
|
71
|
+
} else {
|
|
72
|
+
connection.global = on
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Why these connections cannot be given to a session with this scope, or nothing. */
|
|
77
|
+
export function validateScopeIds(all: readonly Connection[], scope: Scope, ids: readonly string[]): void {
|
|
78
|
+
for (const id of ids) {
|
|
79
|
+
const connection = all.find((item) => item.id === id)
|
|
80
|
+
if (!connection) throw notFound('Connection')
|
|
81
|
+
if (!allowed(connection, scope)) throw new HarnessError('required_connections_unavailable', `${connection.name} is disabled or outside your connection scope.`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- Redaction ----------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const SENSITIVE_FIELDS = new Set(['approvaltoken', 'token', 'apikey', 'accesstoken', 'refreshtoken', 'idtoken', 'clientsecret', 'secret', 'password', 'passwd', 'authorization', 'cookie', 'setcookie', 'env', 'credentials', 'locators'])
|
|
88
|
+
const SENSITIVE_KEYS = ['approvalToken', 'token', 'apiKey', 'api_key', 'accessToken', 'access_token', 'refreshToken', 'refresh_token', 'clientSecret', 'client_secret', 'password', 'authorization', 'cookie']
|
|
89
|
+
|
|
90
|
+
function sensitiveField(key: string): boolean {
|
|
91
|
+
return SENSITIVE_FIELDS.has(key.replace(/[^A-Za-z0-9]/g, '').toLowerCase())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Replaces the value of every `"key": "…"` in text that is not valid JSON (a truncated summary). */
|
|
95
|
+
export function redactJsonField(text: string, key: string): string {
|
|
96
|
+
const marker = `"${key}"`
|
|
97
|
+
let output = text
|
|
98
|
+
let offset = 0
|
|
99
|
+
for (;;) {
|
|
100
|
+
const found = output.indexOf(marker, offset)
|
|
101
|
+
if (found < 0) return output
|
|
102
|
+
const start = found + marker.length
|
|
103
|
+
const match = /^\s*:\s*"/.exec(output.slice(start))
|
|
104
|
+
if (!match) {
|
|
105
|
+
offset = start
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const valueStart = start + match[0].length
|
|
109
|
+
const quote = output.indexOf('"', valueStart)
|
|
110
|
+
const end = quote < 0 ? output.length : quote
|
|
111
|
+
output = `${output.slice(0, valueStart)}[redacted]${output.slice(end)}`
|
|
112
|
+
offset = valueStart + '[redacted]'.length
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parsedContainer(text: string): unknown {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(text) as unknown
|
|
119
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Drops credential-like fields from a value, including from JSON carried as text. */
|
|
126
|
+
export function scrubSensitive(value: unknown): unknown {
|
|
127
|
+
return scrubWith(value, [])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `scrubSensitive`, and every one of `secrets` replaced wherever it appears in text. */
|
|
131
|
+
export function scrubWith(value: unknown, secrets: readonly string[]): unknown {
|
|
132
|
+
if (Array.isArray(value)) return value.map((item) => scrubWith(item, secrets))
|
|
133
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([key]) => !sensitiveField(key)).map(([key, item]) => [key, scrubWith(item, secrets)]))
|
|
134
|
+
if (typeof value !== 'string') return value
|
|
135
|
+
let output = value
|
|
136
|
+
for (const secret of secrets) if (secret) output = output.replaceAll(secret, '[redacted]')
|
|
137
|
+
// Text representations of structured previews must not persist approval tokens.
|
|
138
|
+
const parsed = parsedContainer(output)
|
|
139
|
+
if (parsed !== undefined) return JSON.stringify(scrubWith(parsed, secrets))
|
|
140
|
+
// Summaries can contain truncated JSON, which cannot be parsed above.
|
|
141
|
+
for (const key of SENSITIVE_KEYS) output = redactJsonField(output, key)
|
|
142
|
+
return output
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function revisionOf(all: readonly Connection[]): unknown[] {
|
|
146
|
+
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 }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sameScope(left: Connection, right: Connection): boolean {
|
|
150
|
+
const ids = (values: string[]): string[] => [...values].sort()
|
|
151
|
+
return left.global === right.global
|
|
152
|
+
&& JSON.stringify(ids(left.agentIds)) === JSON.stringify(ids(right.agentIds))
|
|
153
|
+
&& JSON.stringify(ids(left.projectIds)) === JSON.stringify(ids(right.projectIds))
|
|
154
|
+
&& JSON.stringify(ids(left.excludedAgentIds)) === JSON.stringify(ids(right.excludedAgentIds))
|
|
155
|
+
&& JSON.stringify(ids(left.excludedProjectIds)) === JSON.stringify(ids(right.excludedProjectIds))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasScope(connection: Connection): boolean {
|
|
159
|
+
return connection.global || connection.agentIds.length > 0 || connection.projectIds.length > 0
|
|
160
|
+
|| connection.excludedAgentIds.length > 0 || connection.excludedProjectIds.length > 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function scopeMigrationError(): HarnessError {
|
|
164
|
+
return new HarnessError('connection_scope_migration_pending', 'Connector access settings cannot be changed until their scope migration succeeds. Reopen the data root to retry it.')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- The store --------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export class Connections {
|
|
170
|
+
private readonly path: string
|
|
171
|
+
/** Every secret value a connection holds, so results can be redacted without waiting on the store. */
|
|
172
|
+
private readonly known = new Map<string, string[]>()
|
|
173
|
+
|
|
174
|
+
constructor(
|
|
175
|
+
stateDir: string,
|
|
176
|
+
private readonly secrets: SecretStore,
|
|
177
|
+
private readonly bus: Bus,
|
|
178
|
+
private readonly attachmentRoot: () => string | null = () => null
|
|
179
|
+
) {
|
|
180
|
+
this.path = join(stateDir, 'connections.json')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
list(): Connection[] {
|
|
184
|
+
const stored = readJson<unknown[]>(this.path) ?? []
|
|
185
|
+
const root = this.attachmentRoot()
|
|
186
|
+
const choices = root ? attachments.load(root) : null
|
|
187
|
+
return stored.flatMap((item) => {
|
|
188
|
+
const parsed = connectionSchema.safeParse(item)
|
|
189
|
+
if (!parsed.success) return []
|
|
190
|
+
const connection = parsed.data
|
|
191
|
+
// Records saved before remote servers existed have no `auth`.
|
|
192
|
+
if (!connection.auth) connection.auth = 'none'
|
|
193
|
+
// The preset was saved as "X Workspace" before the product was renamed.
|
|
194
|
+
if (connection.kind === 'workspace' && connection.name === 'X Workspace') connection.name = 'Xingular Workspace'
|
|
195
|
+
if (choices) {
|
|
196
|
+
const id = connectionCapabilityId(connection.id)
|
|
197
|
+
const hasChoice = choices.global.on.includes(id) || choices.global.off.includes(id)
|
|
198
|
+
|| Object.values(choices.agents).some((entry) => entry.on.includes(id) || entry.off.includes(id))
|
|
199
|
+
|| Object.values(choices.projects).some((entry) => entry.on.includes(id) || entry.off.includes(id))
|
|
200
|
+
// Before the first successful root open, keep old records readable. After migration the
|
|
201
|
+
// returned fields are a view over attachments; they are never written back to connections.json.
|
|
202
|
+
if (hasChoice || !(connection.global || connection.agentIds.length || connection.projectIds.length || connection.excludedAgentIds.length || connection.excludedProjectIds.length)) {
|
|
203
|
+
const attachment = attachments.describe(choices, id, connection.global)
|
|
204
|
+
connection.global = attachment.global
|
|
205
|
+
connection.agentIds = attachment.on.agentIds
|
|
206
|
+
connection.projectIds = attachment.on.projectIds
|
|
207
|
+
connection.excludedAgentIds = attachment.off.agentIds
|
|
208
|
+
connection.excludedProjectIds = attachment.off.projectIds
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return [connection]
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
find(id: string): Connection {
|
|
216
|
+
const connection = this.list().find((item) => item.id === id)
|
|
217
|
+
if (!connection) throw notFound('Connection')
|
|
218
|
+
return connection
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
forScope(scope: Scope): Connection[] {
|
|
222
|
+
return this.list().filter((connection) => allowed(connection, scope))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
revision(): unknown[] {
|
|
226
|
+
return revisionOf(this.list())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private write(all: Connection[]): void {
|
|
230
|
+
const root = this.attachmentRoot()
|
|
231
|
+
let stored: Connection[] | Omit<Connection, 'global' | 'agentIds' | 'projectIds' | 'excludedAgentIds' | 'excludedProjectIds'>[] = all
|
|
232
|
+
if (root && connectionScopeMigrationComplete(root)) {
|
|
233
|
+
stored = all.map(({ global: _global, agentIds: _agentIds, projectIds: _projectIds, excludedAgentIds: _excludedAgentIds, excludedProjectIds: _excludedProjectIds, ...connection }) => connection)
|
|
234
|
+
} else if (root) {
|
|
235
|
+
// A non-scope edit is allowed while migration is pending, but its legacy scopes must stay
|
|
236
|
+
// in connections.json until the attachment store has been verified.
|
|
237
|
+
const previous = new Map<string, Connection>()
|
|
238
|
+
for (const item of readJson<unknown[]>(this.path) ?? []) {
|
|
239
|
+
const parsed = connectionSchema.safeParse(item)
|
|
240
|
+
if (parsed.success) previous.set(parsed.data.id, parsed.data)
|
|
241
|
+
}
|
|
242
|
+
stored = all.map((connection) => {
|
|
243
|
+
const legacy = previous.get(connection.id)
|
|
244
|
+
return legacy ? {
|
|
245
|
+
...connection,
|
|
246
|
+
global: legacy.global,
|
|
247
|
+
agentIds: legacy.agentIds,
|
|
248
|
+
projectIds: legacy.projectIds,
|
|
249
|
+
excludedAgentIds: legacy.excludedAgentIds,
|
|
250
|
+
excludedProjectIds: legacy.excludedProjectIds
|
|
251
|
+
} : connection
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
writeJson(this.path, stored)
|
|
255
|
+
try {
|
|
256
|
+
chmodSync(this.path, 0o600)
|
|
257
|
+
} catch {
|
|
258
|
+
// The file was written; a mode that cannot be set is not worth failing the change.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private writeScope(root: string, connection: Connection): void {
|
|
263
|
+
const choices = attachments.load(root)
|
|
264
|
+
const id = connectionCapabilityId(connection.id)
|
|
265
|
+
attachments.forgetId(choices, id)
|
|
266
|
+
attachments.set(choices, [id], {}, connection.global, false)
|
|
267
|
+
for (const agentId of new Set([...connection.agentIds, ...connection.excludedAgentIds])) {
|
|
268
|
+
const on = connection.agentIds.includes(agentId) && !connection.excludedAgentIds.includes(agentId)
|
|
269
|
+
attachments.set(choices, [id], { agentId }, on, connection.global)
|
|
270
|
+
}
|
|
271
|
+
for (const projectId of new Set([...connection.projectIds, ...connection.excludedProjectIds])) {
|
|
272
|
+
const on = connection.projectIds.includes(projectId) && !connection.excludedProjectIds.includes(projectId)
|
|
273
|
+
attachments.set(choices, [id], { projectId }, on, connection.global)
|
|
274
|
+
}
|
|
275
|
+
attachments.save(root, choices)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
changed(): void {
|
|
279
|
+
this.bus.emit({ type: 'connections.changed' })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Read-modify-write of one connection. */
|
|
283
|
+
update(id: string, change: (connection: Connection) => void): Connection {
|
|
284
|
+
const all = this.list()
|
|
285
|
+
const connection = all.find((item) => item.id === id)
|
|
286
|
+
if (!connection) throw notFound('Connection')
|
|
287
|
+
const before = { ...connection, agentIds: [...connection.agentIds], projectIds: [...connection.projectIds], excludedAgentIds: [...connection.excludedAgentIds], excludedProjectIds: [...connection.excludedProjectIds] }
|
|
288
|
+
change(connection)
|
|
289
|
+
const root = this.attachmentRoot()
|
|
290
|
+
if (root && !connectionScopeMigrationComplete(root) && !sameScope(before, connection)) throw scopeMigrationError()
|
|
291
|
+
this.write(all)
|
|
292
|
+
return connection
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Read-modify-write of every connection. */
|
|
296
|
+
updateAll(change: (all: Connection[]) => void): void {
|
|
297
|
+
const all = this.list()
|
|
298
|
+
const before = new Map<string, Connection>(all.map((connection): [string, Connection] => [connection.id, {
|
|
299
|
+
...connection,
|
|
300
|
+
agentIds: [...connection.agentIds],
|
|
301
|
+
projectIds: [...connection.projectIds],
|
|
302
|
+
excludedAgentIds: [...connection.excludedAgentIds],
|
|
303
|
+
excludedProjectIds: [...connection.excludedProjectIds]
|
|
304
|
+
}]))
|
|
305
|
+
change(all)
|
|
306
|
+
const root = this.attachmentRoot()
|
|
307
|
+
if (root && !connectionScopeMigrationComplete(root)) {
|
|
308
|
+
const remainingIds = new Set(all.map((connection) => connection.id))
|
|
309
|
+
const scopeChanged = all.some((connection) => {
|
|
310
|
+
const previous = before.get(connection.id)
|
|
311
|
+
return previous ? !sameScope(previous, connection) : hasScope(connection)
|
|
312
|
+
}) || [...before].some(([id, connection]) => !remainingIds.has(id) && hasScope(connection))
|
|
313
|
+
if (scopeChanged) throw scopeMigrationError()
|
|
314
|
+
}
|
|
315
|
+
this.write(all)
|
|
316
|
+
this.changed()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Switches one connector in the shared attachment store, preserving all other scopes. */
|
|
320
|
+
setScope(connectionId: string, scope: Scope, on: boolean): Connection[] {
|
|
321
|
+
const connection = this.find(connectionId)
|
|
322
|
+
const root = this.attachmentRoot()
|
|
323
|
+
if (root && !connectionScopeMigrationComplete(root)) throw scopeMigrationError()
|
|
324
|
+
if (!root) {
|
|
325
|
+
this.update(connectionId, (item) => setScope(item, scope, on))
|
|
326
|
+
this.changed()
|
|
327
|
+
return this.forScope(scope)
|
|
328
|
+
}
|
|
329
|
+
const choices = attachments.load(root)
|
|
330
|
+
const fallback = connection.global
|
|
331
|
+
attachments.set(choices, [connectionCapabilityId(connectionId)], scope, on, fallback)
|
|
332
|
+
attachments.save(root, choices)
|
|
333
|
+
this.changed()
|
|
334
|
+
return this.forScope(scope)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Gives one agent an exact connector set without changing project or global choices. */
|
|
338
|
+
setAgentScope(agentId: string, ids: readonly string[]): Connection[] {
|
|
339
|
+
const all = this.list()
|
|
340
|
+
const root = this.attachmentRoot()
|
|
341
|
+
if (root && !connectionScopeMigrationComplete(root)) throw scopeMigrationError()
|
|
342
|
+
if (!root) {
|
|
343
|
+
setAgentScope(all, agentId, ids)
|
|
344
|
+
this.write(all)
|
|
345
|
+
this.changed()
|
|
346
|
+
return this.forScope({ agentId })
|
|
347
|
+
}
|
|
348
|
+
const choices = attachments.load(root)
|
|
349
|
+
for (const connection of all) {
|
|
350
|
+
attachments.set(choices, [connectionCapabilityId(connection.id)], { agentId }, ids.includes(connection.id), connection.global)
|
|
351
|
+
}
|
|
352
|
+
attachments.save(root, choices)
|
|
353
|
+
this.changed()
|
|
354
|
+
return this.forScope({ agentId })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Removes only connector choices when an agent is deleted; other capability choices are separate. */
|
|
358
|
+
removeAgentScope(agentId: string): void {
|
|
359
|
+
const root = this.attachmentRoot()
|
|
360
|
+
// This is cleanup for a deleted caller. Leave stale ids inert until migration can safely
|
|
361
|
+
// move the remaining scopes; failing helper cleanup must not leave an orphan agent behind.
|
|
362
|
+
if (root && !connectionScopeMigrationComplete(root)) return
|
|
363
|
+
if (!root) {
|
|
364
|
+
this.updateAll((all) => {
|
|
365
|
+
for (const connection of all) {
|
|
366
|
+
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
367
|
+
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
const choices = attachments.load(root)
|
|
373
|
+
const entry = choices.agents[agentId]
|
|
374
|
+
if (!entry) return
|
|
375
|
+
const isConnector = (id: string): boolean => id.startsWith('connected-apps/connection/')
|
|
376
|
+
entry.on = entry.on.filter((id) => !isConnector(id))
|
|
377
|
+
entry.off = entry.off.filter((id) => !isConnector(id))
|
|
378
|
+
if (!entry.on.length && !entry.off.length) delete choices.agents[agentId]
|
|
379
|
+
attachments.save(root, choices)
|
|
380
|
+
this.changed()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private secret(connectionId: string, name: string): Promise<string | null> {
|
|
384
|
+
return this.secrets.get(secretKey(`mcp--${connectionId}`, name))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private setSecret(connectionId: string, name: string, value: string): Promise<void> {
|
|
388
|
+
return this.secrets.set(secretKey(`mcp--${connectionId}`, name), value)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private deleteSecret(connectionId: string, name: string): Promise<void> {
|
|
392
|
+
return this.secrets.delete(secretKey(`mcp--${connectionId}`, name))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Everything the secret store holds for a connection: environment and header values, and OAuth credentials. */
|
|
396
|
+
private async secretValues(connection: Connection): Promise<string[]> {
|
|
397
|
+
const values: string[] = []
|
|
398
|
+
for (const key of connection.envKeys) {
|
|
399
|
+
const value = await this.secret(connection.id, key)
|
|
400
|
+
if (value) values.push(value)
|
|
401
|
+
}
|
|
402
|
+
for (const key of connection.headerKeys) {
|
|
403
|
+
const value = await this.secret(connection.id, headerItem(key))
|
|
404
|
+
if (!value) continue
|
|
405
|
+
// "Authorization: Bearer <key>" must not leak the key on its own either.
|
|
406
|
+
const space = value.indexOf(' ')
|
|
407
|
+
if (space >= 0) values.push(value.slice(space + 1).trim())
|
|
408
|
+
values.push(value)
|
|
409
|
+
}
|
|
410
|
+
if (connection.auth === 'oauth') {
|
|
411
|
+
const client = await this.secret(connection.id, OAUTH_CLIENT_SECRET)
|
|
412
|
+
if (client) values.push(client)
|
|
413
|
+
const saved = (parsedContainer((await this.secret(connection.id, OAUTH_STATE)) ?? '') ?? {}) as { tokens?: Record<string, unknown> }
|
|
414
|
+
for (const field of ['access_token', 'refresh_token', 'id_token']) if (typeof saved.tokens?.[field] === 'string') values.push(saved.tokens[field] as string)
|
|
415
|
+
}
|
|
416
|
+
return values.filter(Boolean)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Reloads the secret values used for redaction. Called at start and whenever a connection's secrets change. */
|
|
420
|
+
async refreshSecrets(): Promise<void> {
|
|
421
|
+
const next = new Map<string, string[]>()
|
|
422
|
+
for (const connection of this.list()) next.set(connection.id, await this.secretValues(connection).catch(() => []))
|
|
423
|
+
this.known.clear()
|
|
424
|
+
for (const [id, values] of next) this.known.set(id, values)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A connection's result, safe to persist. */
|
|
428
|
+
scrub(value: unknown, connectionId: string): unknown {
|
|
429
|
+
return scrubWith(value, this.known.get(connectionId) ?? [])
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Removes credentials from the evidence a run event carries. */
|
|
433
|
+
redactEvent(event: RunEvent): void {
|
|
434
|
+
if (!event.type.startsWith('tool.') && !['context.snapshot', 'retry.started', 'retry.ended', 'compaction.started', 'compaction.ended'].includes(event.type)) return
|
|
435
|
+
const secrets = [...this.known.values()].flat()
|
|
436
|
+
event.payload = scrubWith(event.payload, secrets)
|
|
437
|
+
if (event.native !== undefined) event.native = scrubWith(event.native, secrets)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- Launching ----------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
async launch(connection: Connection): Promise<LaunchConfig> {
|
|
443
|
+
if (connection.kind === 'remote') {
|
|
444
|
+
const headers: Record<string, string> = {}
|
|
445
|
+
for (const key of connection.headerKeys) {
|
|
446
|
+
const value = await this.secret(connection.id, headerItem(key))
|
|
447
|
+
if (value !== null) headers[key] = value
|
|
448
|
+
}
|
|
449
|
+
return { id: connection.id, name: connection.name, transport: 'http', url: connection.url ?? '', headers, auth: connection.auth as 'none' | 'headers' | 'oauth', scopes: connection.oauthScopes }
|
|
450
|
+
}
|
|
451
|
+
let command = findBinary(connection.command) ?? (isAbsolute(connection.command) ? connection.command : null)
|
|
452
|
+
if (!command) throw new HarnessError('mcp_unavailable', 'MCP executable was not found. Select its absolute path.')
|
|
453
|
+
const env: Record<string, string> = { PATH: searchPath() }
|
|
454
|
+
for (const key of ['HOME', 'TMPDIR', 'LANG']) if (process.env[key]) env[key] = process.env[key]!
|
|
455
|
+
for (const key of connection.envKeys) {
|
|
456
|
+
const value = await this.secret(connection.id, key)
|
|
457
|
+
if (value !== null) env[key] = value
|
|
458
|
+
}
|
|
459
|
+
const args = [...connection.args]
|
|
460
|
+
if (connection.kind === 'workspace') {
|
|
461
|
+
// The first-party launcher is JavaScript; run it on this process's Node even where none is on PATH.
|
|
462
|
+
args.unshift(command)
|
|
463
|
+
command = process.execPath
|
|
464
|
+
if (connection.expectedVaultId && !args.includes('--expected-vault-id')) args.push('--expected-vault-id', connection.expectedVaultId)
|
|
465
|
+
}
|
|
466
|
+
return { id: connection.id, name: connection.name, transport: 'stdio', command, args, cwd: connection.cwd, env }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---- OAuth state --------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
private requireOauth(id: string): void {
|
|
472
|
+
if (!this.list().some((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')) throw notFound('Connection')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The saved sign-in of a connection, with the client the user registered, if any. */
|
|
476
|
+
async oauthLoad(id: string): Promise<Record<string, unknown>> {
|
|
477
|
+
this.requireOauth(id)
|
|
478
|
+
const saved = (parsedContainer((await this.secret(id, OAUTH_STATE)) ?? '') ?? {}) as Record<string, unknown>
|
|
479
|
+
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 } : {}) }
|
|
484
|
+
}
|
|
485
|
+
return state
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async oauthSave(id: string, state: Record<string, unknown>): Promise<void> {
|
|
489
|
+
this.requireOauth(id)
|
|
490
|
+
const { staticClient: _static, ...saved } = state ?? {}
|
|
491
|
+
await this.setSecret(id, OAUTH_STATE, JSON.stringify(saved))
|
|
492
|
+
await this.refreshSecrets()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async hasOauthClient(id: string): Promise<boolean> {
|
|
496
|
+
return (await this.secret(id, OAUTH_CLIENT_ID)) !== null
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private forgetSignIn(id: string): Promise<void> {
|
|
500
|
+
return this.deleteSecret(id, OAUTH_STATE)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---- Changes ------------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
async save(input: ConnectionInput): Promise<Connection> {
|
|
506
|
+
const parsed = connectionInputSchema.safeParse(input)
|
|
507
|
+
if (!parsed.success) throw invalid('A name, executable, and supported connection kind are required.')
|
|
508
|
+
const { env, removeEnv, headers, removeHeaders, oauthCredentials, ...connection } = parsed.data
|
|
509
|
+
if (!connection.id) connection.id = newId()
|
|
510
|
+
safeId(connection.id)
|
|
511
|
+
const remote = connection.kind === 'remote'
|
|
512
|
+
if (!connection.name.trim() || !['workspace', 'local', 'remote'].includes(connection.kind) || (!remote && !connection.command.trim())) {
|
|
513
|
+
throw invalid('A name, executable, and supported connection kind are required.')
|
|
514
|
+
}
|
|
515
|
+
if (remote) {
|
|
516
|
+
if (!webUrl(connection.url)) throw invalid('Server URL must be an https:// address.')
|
|
517
|
+
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.')
|
|
519
|
+
Object.assign(connection, { command: '', args: [], cwd: null })
|
|
520
|
+
if (connection.auth !== 'oauth') Object.assign(connection, { oauthClient: null, oauthScopes: [] })
|
|
521
|
+
} else {
|
|
522
|
+
Object.assign(connection, { url: null, auth: 'none', oauthClient: null, oauthScopes: [] })
|
|
523
|
+
}
|
|
524
|
+
if (connection.args.some((value) => value.includes('\0')) || connection.command.includes('\0')) throw invalid('Invalid executable or argument.')
|
|
525
|
+
if (connection.cwd && (!isAbsolute(connection.cwd) || !isDir(connection.cwd))) throw invalid('Working directory must be an existing absolute directory.')
|
|
526
|
+
|
|
527
|
+
const all = this.list()
|
|
528
|
+
const existing = all.find((item) => item.id === connection.id)
|
|
529
|
+
const root = this.attachmentRoot()
|
|
530
|
+
if (root && !connectionScopeMigrationComplete(root) && (existing ? !sameScope(existing, connection) : hasScope(connection))) {
|
|
531
|
+
throw scopeMigrationError()
|
|
532
|
+
}
|
|
533
|
+
connection.envKeys = [...(existing?.envKeys ?? [])]
|
|
534
|
+
connection.headerKeys = [...(existing?.headerKeys ?? [])]
|
|
535
|
+
// 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
|
|
537
|
+
if (!sameServer || connection.auth !== 'oauth') await this.forgetSignIn(connection.id)
|
|
538
|
+
connection.authState = connection.auth === 'oauth' ? (sameServer ? (existing?.authState ?? 'signed_out') : 'signed_out') : null
|
|
539
|
+
if (oauthCredentials) {
|
|
540
|
+
if (!oauthCredentials.clientId.trim()) throw invalid('An OAuth client ID is required.')
|
|
541
|
+
await this.setSecret(connection.id, OAUTH_CLIENT_ID, oauthCredentials.clientId.trim())
|
|
542
|
+
if (oauthCredentials.clientSecret) await this.setSecret(connection.id, OAUTH_CLIENT_SECRET, oauthCredentials.clientSecret)
|
|
543
|
+
else await this.deleteSecret(connection.id, OAUTH_CLIENT_SECRET)
|
|
544
|
+
}
|
|
545
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
546
|
+
if (!/^[A-Za-z0-9_-]+$/.test(key) || /[\r\n\0]/.test(value)) throw invalid('Invalid header name or value.')
|
|
547
|
+
await this.setSecret(connection.id, headerItem(key), value)
|
|
548
|
+
if (!connection.headerKeys.includes(key)) connection.headerKeys.push(key)
|
|
549
|
+
}
|
|
550
|
+
for (const key of removeHeaders ?? []) {
|
|
551
|
+
await this.deleteSecret(connection.id, headerItem(key))
|
|
552
|
+
connection.headerKeys = connection.headerKeys.filter((value) => value !== key)
|
|
553
|
+
}
|
|
554
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
555
|
+
if (!/^[A-Za-z0-9_]+$/.test(key)) throw invalid('Invalid environment variable name.')
|
|
556
|
+
await this.setSecret(connection.id, key, value)
|
|
557
|
+
if (!connection.envKeys.includes(key)) connection.envKeys.push(key)
|
|
558
|
+
}
|
|
559
|
+
for (const key of removeEnv ?? []) {
|
|
560
|
+
await this.deleteSecret(connection.id, key)
|
|
561
|
+
connection.envKeys = connection.envKeys.filter((value) => value !== key)
|
|
562
|
+
}
|
|
563
|
+
Object.assign(connection, { checkedAt: null, tools: [], serverName: null, error: null })
|
|
564
|
+
if (root && connectionScopeMigrationComplete(root)) this.writeScope(root, connection as Connection)
|
|
565
|
+
this.write([...all.filter((item) => item.id !== connection.id), connection])
|
|
566
|
+
await this.refreshSecrets()
|
|
567
|
+
this.changed()
|
|
568
|
+
return connection
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Adds one tool to a connection's no-approval list. Unlike a full save this keeps the
|
|
573
|
+
* connection's pending approvals, so the request the user is answering can still be approved.
|
|
574
|
+
*/
|
|
575
|
+
allowTool(connectionId: string, tool: string): Connection {
|
|
576
|
+
if (!tool.trim()) throw invalid('A tool is required.')
|
|
577
|
+
if (this.list().some((item) => item.id === connectionId && item.kind === 'workspace')) throw invalid('Changes to Xingular Workspace are approved one at a time.')
|
|
578
|
+
const connection = this.update(connectionId, (item) => {
|
|
579
|
+
if (!item.readTools.includes(tool)) item.readTools.push(tool)
|
|
580
|
+
})
|
|
581
|
+
this.changed()
|
|
582
|
+
return connection
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async remove(connectionId: string): Promise<void> {
|
|
586
|
+
const all = this.list()
|
|
587
|
+
const connection = all.find((item) => item.id === connectionId)
|
|
588
|
+
if (connection) {
|
|
589
|
+
for (const key of connection.envKeys) await this.deleteSecret(connection.id, key)
|
|
590
|
+
for (const key of connection.headerKeys) await this.deleteSecret(connection.id, headerItem(key))
|
|
591
|
+
for (const name of [OAUTH_STATE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET]) await this.deleteSecret(connection.id, name)
|
|
592
|
+
}
|
|
593
|
+
this.write(all.filter((item) => item.id !== connectionId))
|
|
594
|
+
const root = this.attachmentRoot()
|
|
595
|
+
if (root) {
|
|
596
|
+
const choices = attachments.load(root)
|
|
597
|
+
attachments.forgetId(choices, connectionCapabilityId(connectionId))
|
|
598
|
+
attachments.save(root, choices)
|
|
599
|
+
}
|
|
600
|
+
this.known.delete(connectionId)
|
|
601
|
+
this.changed()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async signOut(connectionId: string): Promise<Connection> {
|
|
605
|
+
await this.forgetSignIn(connectionId)
|
|
606
|
+
const connection = this.update(connectionId, (item) => {
|
|
607
|
+
if (item.auth === 'oauth') item.authState = 'signed_out'
|
|
608
|
+
Object.assign(item, { checkedAt: null, tools: [], error: null })
|
|
609
|
+
})
|
|
610
|
+
await this.refreshSecrets()
|
|
611
|
+
this.changed()
|
|
612
|
+
return connection
|
|
613
|
+
}
|
|
614
|
+
}
|