@xingular/harness 0.1.0 → 0.1.2
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/dist/cli.js +799 -335
- package/dist/terminal-pi.js +2 -2
- package/package.json +4 -4
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +176 -42
- package/src/capabilities/api.ts +3 -3
- package/src/harness.ts +12 -2
- package/src/index.ts +2 -1
- package/src/mcp/connections.ts +176 -3
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.ts +18 -36
- package/src/mcp/types.ts +3 -0
- package/src/memory/api.ts +5 -3
- package/src/memory/dispatch.ts +2 -2
- package/src/memory/maintenance.test.ts +1 -1
- package/src/plugins/attachments.ts +13 -0
- package/src/plugins/catalogue.ts +3 -2
- package/src/plugins/manifest.ts +34 -2
- package/src/plugins/plugins.ts +8 -3
- package/src/plugins/types.ts +19 -0
- package/src/prompt/compile.ts +4 -4
- package/src/server/methods.ts +6 -0
- package/src/sessions/sessions.ts +10 -10
- package/src/skills/registry.ts +16 -3
- package/src/terminal/piExtension.ts +4 -2
- package/src/tools/memoryTools.ts +2 -2
package/src/mcp/connections.ts
CHANGED
|
@@ -10,7 +10,9 @@ import { newId, readJson, safeId, writeJson } from '../core/fs'
|
|
|
10
10
|
import { findBinary, searchPath } from '../core/path'
|
|
11
11
|
import { isDir } from '../memory/store'
|
|
12
12
|
import type { RunEvent } from '../sessions/contracts'
|
|
13
|
-
import
|
|
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'
|
|
14
16
|
|
|
15
17
|
const OAUTH_STATE = 'oauth'
|
|
16
18
|
const OAUTH_CLIENT_ID = 'oauth_client_id'
|
|
@@ -144,6 +146,24 @@ export function revisionOf(all: readonly Connection[]): unknown[] {
|
|
|
144
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 }))
|
|
145
147
|
}
|
|
146
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
|
+
|
|
147
167
|
// ---- The store --------------------------------------------------------------------------------
|
|
148
168
|
|
|
149
169
|
export class Connections {
|
|
@@ -154,13 +174,16 @@ export class Connections {
|
|
|
154
174
|
constructor(
|
|
155
175
|
stateDir: string,
|
|
156
176
|
private readonly secrets: SecretStore,
|
|
157
|
-
private readonly bus: Bus
|
|
177
|
+
private readonly bus: Bus,
|
|
178
|
+
private readonly attachmentRoot: () => string | null = () => null
|
|
158
179
|
) {
|
|
159
180
|
this.path = join(stateDir, 'connections.json')
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
list(): Connection[] {
|
|
163
184
|
const stored = readJson<unknown[]>(this.path) ?? []
|
|
185
|
+
const root = this.attachmentRoot()
|
|
186
|
+
const choices = root ? attachments.load(root) : null
|
|
164
187
|
return stored.flatMap((item) => {
|
|
165
188
|
const parsed = connectionSchema.safeParse(item)
|
|
166
189
|
if (!parsed.success) return []
|
|
@@ -169,6 +192,22 @@ export class Connections {
|
|
|
169
192
|
if (!connection.auth) connection.auth = 'none'
|
|
170
193
|
// The preset was saved as "X Workspace" before the product was renamed.
|
|
171
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
|
+
}
|
|
172
211
|
return [connection]
|
|
173
212
|
})
|
|
174
213
|
}
|
|
@@ -188,7 +227,31 @@ export class Connections {
|
|
|
188
227
|
}
|
|
189
228
|
|
|
190
229
|
private write(all: Connection[]): void {
|
|
191
|
-
|
|
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)
|
|
192
255
|
try {
|
|
193
256
|
chmodSync(this.path, 0o600)
|
|
194
257
|
} catch {
|
|
@@ -196,6 +259,22 @@ export class Connections {
|
|
|
196
259
|
}
|
|
197
260
|
}
|
|
198
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
|
+
|
|
199
278
|
changed(): void {
|
|
200
279
|
this.bus.emit({ type: 'connections.changed' })
|
|
201
280
|
}
|
|
@@ -205,7 +284,10 @@ export class Connections {
|
|
|
205
284
|
const all = this.list()
|
|
206
285
|
const connection = all.find((item) => item.id === id)
|
|
207
286
|
if (!connection) throw notFound('Connection')
|
|
287
|
+
const before = { ...connection, agentIds: [...connection.agentIds], projectIds: [...connection.projectIds], excludedAgentIds: [...connection.excludedAgentIds], excludedProjectIds: [...connection.excludedProjectIds] }
|
|
208
288
|
change(connection)
|
|
289
|
+
const root = this.attachmentRoot()
|
|
290
|
+
if (root && !connectionScopeMigrationComplete(root) && !sameScope(before, connection)) throw scopeMigrationError()
|
|
209
291
|
this.write(all)
|
|
210
292
|
return connection
|
|
211
293
|
}
|
|
@@ -213,11 +295,91 @@ export class Connections {
|
|
|
213
295
|
/** Read-modify-write of every connection. */
|
|
214
296
|
updateAll(change: (all: Connection[]) => void): void {
|
|
215
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
|
+
}]))
|
|
216
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
|
+
}
|
|
217
315
|
this.write(all)
|
|
218
316
|
this.changed()
|
|
219
317
|
}
|
|
220
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
|
+
|
|
221
383
|
private secret(connectionId: string, name: string): Promise<string | null> {
|
|
222
384
|
return this.secrets.get(secretKey(`mcp--${connectionId}`, name))
|
|
223
385
|
}
|
|
@@ -364,6 +526,10 @@ export class Connections {
|
|
|
364
526
|
|
|
365
527
|
const all = this.list()
|
|
366
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
|
+
}
|
|
367
533
|
connection.envKeys = [...(existing?.envKeys ?? [])]
|
|
368
534
|
connection.headerKeys = [...(existing?.headerKeys ?? [])]
|
|
369
535
|
// A sign-in belongs to one server and one client; never carry it to another.
|
|
@@ -395,6 +561,7 @@ export class Connections {
|
|
|
395
561
|
connection.envKeys = connection.envKeys.filter((value) => value !== key)
|
|
396
562
|
}
|
|
397
563
|
Object.assign(connection, { checkedAt: null, tools: [], serverName: null, error: null })
|
|
564
|
+
if (root && connectionScopeMigrationComplete(root)) this.writeScope(root, connection as Connection)
|
|
398
565
|
this.write([...all.filter((item) => item.id !== connection.id), connection])
|
|
399
566
|
await this.refreshSecrets()
|
|
400
567
|
this.changed()
|
|
@@ -424,6 +591,12 @@ export class Connections {
|
|
|
424
591
|
for (const name of [OAUTH_STATE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET]) await this.deleteSecret(connection.id, name)
|
|
425
592
|
}
|
|
426
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
|
+
}
|
|
427
600
|
this.known.delete(connectionId)
|
|
428
601
|
this.changed()
|
|
429
602
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Moves who can reach each connected app out of the device-local connection records and into
|
|
2
|
+
// the data root's shared capability attachment store. The originals remain as owner-only backups.
|
|
3
|
+
import { chmodSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { atomicWrite, readJson, writeJson } from '../core/fs'
|
|
7
|
+
import * as attachments from '../plugins/attachments'
|
|
8
|
+
import { connectionCapabilityId, connectionSchema, type Connection } from './types'
|
|
9
|
+
|
|
10
|
+
interface MigrationRecord {
|
|
11
|
+
version: 1
|
|
12
|
+
status: 'attachments-written' | 'complete'
|
|
13
|
+
connectionCount: number
|
|
14
|
+
scopeAssignmentCount: number
|
|
15
|
+
connectionsBackup: string | null
|
|
16
|
+
attachmentsBackup: string | null
|
|
17
|
+
hadAttachments: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const recordPath = (root: string): string => join(root, 'config/capability-scope-migration.json')
|
|
21
|
+
const attachmentsPath = (root: string): string => join(root, 'config/plugin-attachments.json')
|
|
22
|
+
const connectionPath = (stateDir: string): string => join(stateDir, 'connections.json')
|
|
23
|
+
const connectionsBackupPath = (stateDir: string): string => join(stateDir, 'connections.pre-capability-scope.json')
|
|
24
|
+
const attachmentsBackupPath = (root: string): string => join(root, 'config/plugin-attachments.pre-capability-scope.json')
|
|
25
|
+
|
|
26
|
+
/** Scope writes are safe only after the legacy records have been copied and stripped. */
|
|
27
|
+
export function connectionScopeMigrationComplete(root: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
const marker = readJson<MigrationRecord>(recordPath(root))
|
|
30
|
+
return marker?.version === 1 && marker.status === 'complete'
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ScopeRows {
|
|
37
|
+
global: boolean
|
|
38
|
+
agents: Map<string, boolean>
|
|
39
|
+
projects: Map<string, boolean>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unique(values: readonly string[]): string[] {
|
|
43
|
+
return [...new Set(values)]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scopeRows(connection: Connection): ScopeRows {
|
|
47
|
+
const agents = new Map<string, boolean>()
|
|
48
|
+
const projects = new Map<string, boolean>()
|
|
49
|
+
for (const id of unique([...connection.agentIds, ...connection.excludedAgentIds])) {
|
|
50
|
+
agents.set(id, !connection.excludedAgentIds.includes(id))
|
|
51
|
+
}
|
|
52
|
+
for (const id of unique([...connection.projectIds, ...connection.excludedProjectIds])) {
|
|
53
|
+
projects.set(id, !connection.excludedProjectIds.includes(id))
|
|
54
|
+
}
|
|
55
|
+
return { global: connection.global, agents, projects }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assignmentCount(rows: ScopeRows): number {
|
|
59
|
+
return 1 + rows.agents.size + rows.projects.size
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeId(choices: attachments.Attachments, id: string): void {
|
|
63
|
+
attachments.forgetId(choices, id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function put(map: { on: string[]; off: string[] }, id: string, value: boolean): void {
|
|
67
|
+
map.on = map.on.filter((item) => item !== id)
|
|
68
|
+
map.off = map.off.filter((item) => item !== id)
|
|
69
|
+
map[value ? 'on' : 'off'].push(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeRows(choices: attachments.Attachments, id: string, rows: ScopeRows): void {
|
|
73
|
+
removeId(choices, id)
|
|
74
|
+
put(choices.global, id, rows.global)
|
|
75
|
+
for (const [scopeId, on] of rows.agents) {
|
|
76
|
+
const target = (choices.agents[scopeId] ??= { on: [], off: [] })
|
|
77
|
+
put(target, id, on)
|
|
78
|
+
}
|
|
79
|
+
for (const [scopeId, on] of rows.projects) {
|
|
80
|
+
const target = (choices.projects[scopeId] ??= { on: [], off: [] })
|
|
81
|
+
put(target, id, on)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readValue(item: { on: string[]; off: string[] } | undefined, id: string): boolean | null {
|
|
86
|
+
if (item?.off.includes(id)) return false
|
|
87
|
+
if (item?.on.includes(id)) return true
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function verifyRows(choices: attachments.Attachments, id: string, expected: ScopeRows): boolean {
|
|
92
|
+
if (readValue(choices.global, id) !== expected.global) return false
|
|
93
|
+
for (const [scopeId, on] of expected.agents) {
|
|
94
|
+
if (readValue(choices.agents[scopeId], id) !== on) return false
|
|
95
|
+
}
|
|
96
|
+
for (const [scopeId, on] of expected.projects) {
|
|
97
|
+
if (readValue(choices.projects[scopeId], id) !== on) return false
|
|
98
|
+
}
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function backup(source: string, target: string): boolean {
|
|
103
|
+
if (!existsSync(source)) return false
|
|
104
|
+
if (!existsSync(target)) atomicWrite(target, readFileSync(source))
|
|
105
|
+
chmodSync(target, 0o600)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setPrivate(path: string): void {
|
|
110
|
+
try { chmodSync(path, 0o600) } catch { /* The atomic write remains usable if chmod is unavailable. */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stripScopes(stateDir: string): void {
|
|
114
|
+
const path = connectionPath(stateDir)
|
|
115
|
+
if (!existsSync(path)) return
|
|
116
|
+
const stored = readJson<unknown>(path)
|
|
117
|
+
if (!Array.isArray(stored)) throw new Error('Stored connector data must be a list before its scopes can be migrated.')
|
|
118
|
+
const records = stored.map((item) => {
|
|
119
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('A stored connector record is invalid; no scope data was removed.')
|
|
120
|
+
const { global: _global, agentIds: _agentIds, projectIds: _projectIds, excludedAgentIds: _excludedAgentIds, excludedProjectIds: _excludedProjectIds, ...connection } = item as Record<string, unknown>
|
|
121
|
+
return connection
|
|
122
|
+
})
|
|
123
|
+
writeJson(path, records)
|
|
124
|
+
setPrivate(path)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runs once when a data root opens. Both source files are backed up before either is changed;
|
|
129
|
+
* a stage marker makes an interrupted migration safe to finish on the next open.
|
|
130
|
+
*/
|
|
131
|
+
export function migrateConnectionScopes(root: string, stateDir: string): MigrationRecord {
|
|
132
|
+
const marker = readJson<MigrationRecord>(recordPath(root))
|
|
133
|
+
if (marker?.version === 1 && marker.status === 'complete') return marker
|
|
134
|
+
|
|
135
|
+
if (marker?.version === 1 && marker.status === 'attachments-written') {
|
|
136
|
+
stripScopes(stateDir)
|
|
137
|
+
const complete = { ...marker, status: 'complete' as const }
|
|
138
|
+
writeJson(recordPath(root), complete)
|
|
139
|
+
return complete
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const connectionsFile = connectionPath(stateDir)
|
|
143
|
+
const hadConnections = existsSync(connectionsFile)
|
|
144
|
+
const raw = hadConnections ? readJson<unknown>(connectionsFile) : []
|
|
145
|
+
if (!Array.isArray(raw)) throw new Error('Stored connector data must be a list before its scopes can be migrated.')
|
|
146
|
+
const parsed = raw.map((item) => {
|
|
147
|
+
const result = connectionSchema.safeParse(item)
|
|
148
|
+
if (!result.success) throw new Error('A stored connector record is invalid; no scope data was removed.')
|
|
149
|
+
return result.data
|
|
150
|
+
})
|
|
151
|
+
const connectionBackup = hadConnections ? connectionsBackupPath(stateDir) : null
|
|
152
|
+
const hadAttachments = existsSync(attachmentsPath(root))
|
|
153
|
+
const attachmentBackup = hadAttachments ? attachmentsBackupPath(root) : null
|
|
154
|
+
const migration: MigrationRecord = {
|
|
155
|
+
version: 1,
|
|
156
|
+
status: 'attachments-written',
|
|
157
|
+
connectionCount: parsed.length,
|
|
158
|
+
scopeAssignmentCount: parsed.reduce((count, connection) => count + assignmentCount(scopeRows(connection)), 0),
|
|
159
|
+
connectionsBackup: connectionBackup,
|
|
160
|
+
attachmentsBackup: attachmentBackup,
|
|
161
|
+
hadAttachments
|
|
162
|
+
}
|
|
163
|
+
backup(connectionsFile, connectionBackup ?? '')
|
|
164
|
+
backup(attachmentsPath(root), attachmentBackup ?? '')
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const choices = attachments.load(root)
|
|
168
|
+
const expected = new Map<string, ScopeRows>()
|
|
169
|
+
for (const connection of parsed) {
|
|
170
|
+
const id = connectionCapabilityId(connection.id)
|
|
171
|
+
const rows = scopeRows(connection)
|
|
172
|
+
expected.set(id, rows)
|
|
173
|
+
writeRows(choices, id, rows)
|
|
174
|
+
}
|
|
175
|
+
const migratedAssignments = [...expected.values()].reduce((count, rows) => count + assignmentCount(rows), 0)
|
|
176
|
+
if (migratedAssignments !== migration.scopeAssignmentCount || [...expected].some(([id, rows]) => !verifyRows(choices, id, rows))) {
|
|
177
|
+
throw new Error('Connector scope count check failed; the original files were restored.')
|
|
178
|
+
}
|
|
179
|
+
attachments.save(root, choices)
|
|
180
|
+
const saved = attachments.load(root)
|
|
181
|
+
if ([...expected].some(([id, rows]) => !verifyRows(saved, id, rows))) throw new Error('Connector scope count check failed after saving; the original files were restored.')
|
|
182
|
+
writeJson(recordPath(root), migration)
|
|
183
|
+
stripScopes(stateDir)
|
|
184
|
+
const complete = { ...migration, status: 'complete' as const }
|
|
185
|
+
writeJson(recordPath(root), complete)
|
|
186
|
+
return complete
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (attachmentBackup && existsSync(attachmentBackup)) atomicWrite(attachmentsPath(root), readFileSync(attachmentBackup))
|
|
189
|
+
else if (!hadAttachments && existsSync(attachmentsPath(root))) unlinkSync(attachmentsPath(root))
|
|
190
|
+
if (connectionBackup && existsSync(connectionBackup)) atomicWrite(connectionsFile, readFileSync(connectionBackup))
|
|
191
|
+
try { unlinkSync(recordPath(root)) } catch { /* No marker is also a valid retry state. */ }
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Restores the byte-identical pre-migration files from the backups if an operator rolls back. */
|
|
197
|
+
export function rollbackConnectionScopeMigration(root: string, stateDir: string): void {
|
|
198
|
+
const marker = readJson<MigrationRecord>(recordPath(root))
|
|
199
|
+
if (!marker || marker.version !== 1) return
|
|
200
|
+
if (marker.attachmentsBackup && existsSync(marker.attachmentsBackup)) atomicWrite(attachmentsPath(root), readFileSync(marker.attachmentsBackup))
|
|
201
|
+
else if (!marker.hadAttachments && existsSync(attachmentsPath(root))) unlinkSync(attachmentsPath(root))
|
|
202
|
+
if (marker.connectionsBackup && existsSync(marker.connectionsBackup)) atomicWrite(connectionPath(stateDir), readFileSync(marker.connectionsBackup))
|
|
203
|
+
unlinkSync(recordPath(root))
|
|
204
|
+
}
|
package/src/mcp/service.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { ActiveRun, RunRegistry } from '../sessions/registry'
|
|
|
17
17
|
import { findRun, runDir } from '../sessions/runStore'
|
|
18
18
|
import type { SessionBroker } from '../sessions/sessions'
|
|
19
19
|
import type { ToolSession, ToolSpec } from '../tools/types'
|
|
20
|
-
import { allowed, Connections,
|
|
20
|
+
import { allowed, Connections, webUrl } from './connections'
|
|
21
21
|
import { isSignInRequired, McpManager, signInRequired } from './manager'
|
|
22
22
|
import { signIn, type OAuthHost } from './oauth'
|
|
23
23
|
import type { Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
|
|
@@ -134,7 +134,7 @@ export class ConnectedApps implements SessionBroker {
|
|
|
134
134
|
private brokered: BrokeredTools | null = null
|
|
135
135
|
|
|
136
136
|
constructor(private readonly options: ConnectedAppsOptions) {
|
|
137
|
-
this.connections = new Connections(options.stateDir, options.secrets, options.bus)
|
|
137
|
+
this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.dataRoot)
|
|
138
138
|
this.oauth = {
|
|
139
139
|
clientName: options.env.productName,
|
|
140
140
|
load: (id) => this.connections.oauthLoad(id),
|
|
@@ -235,7 +235,7 @@ export class ConnectedApps implements SessionBroker {
|
|
|
235
235
|
return { tools, errors: [] }
|
|
236
236
|
} catch (error) {
|
|
237
237
|
const unauthorized = isSignInRequired(error)
|
|
238
|
-
const message = unauthorized ? signInRequired(connection).message : `${connection.name} could not load its tools. Check
|
|
238
|
+
const message = unauthorized ? signInRequired(connection).message : `${connection.name} could not load its tools. Check its scope in Capability → Connectors.`
|
|
239
239
|
if (unauthorized) await this.manager.close(connection.id, session.sessionId)
|
|
240
240
|
this.connectionFailed(connection.id, unauthorized)
|
|
241
241
|
return { tools: [], errors: [{ connection: connection.name, message }] }
|
|
@@ -393,8 +393,8 @@ export class ConnectedApps implements SessionBroker {
|
|
|
393
393
|
return [
|
|
394
394
|
{
|
|
395
395
|
name: 'mcp_list_tools',
|
|
396
|
-
label: 'App and
|
|
397
|
-
description: `Discover enabled ${this.options.env.productName} app operations and
|
|
396
|
+
label: 'App and connector tools',
|
|
397
|
+
description: `Discover enabled ${this.options.env.productName} app operations and connector tools by name. Pass tool to get one tool with its input schema.${this.brokered?.discoveryNote ? ` ${this.brokered.discoveryNote}` : ''}`,
|
|
398
398
|
parameters: (Type) =>
|
|
399
399
|
Type.Object({
|
|
400
400
|
tool: Type.Optional(Type.String({ description: 'One tool name, to get its input schema.' })),
|
|
@@ -411,8 +411,8 @@ export class ConnectedApps implements SessionBroker {
|
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
name: 'mcp_call_tool',
|
|
414
|
-
label: 'Use
|
|
415
|
-
description: 'Invoke an enabled app or
|
|
414
|
+
label: 'Use connector',
|
|
415
|
+
description: 'Invoke an enabled app or connector tool returned by mcp_list_tools. Current settings are checked on every call. Changes require approval.',
|
|
416
416
|
parameters: (Type) => Type.Object({ name: Type.String(), arguments: Type.Record(Type.String(), Type.Unknown()) }),
|
|
417
417
|
execute: async (input) => JSON.stringify(await this.invoke(session, String(input.name), (input.arguments ?? {}) as Record<string, unknown>))
|
|
418
418
|
}
|
|
@@ -506,23 +506,13 @@ export class ConnectedApps implements SessionBroker {
|
|
|
506
506
|
|
|
507
507
|
/** Gives one agent exactly these connections, if its creator may reach each of them. */
|
|
508
508
|
setAgentScope(creator: { agentId?: string | null; projectId?: string | null }, agentId: string, ids: readonly string[]): Connection[] {
|
|
509
|
-
this.connections.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
517
|
-
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
518
|
-
if (ids.includes(connection.id)) {
|
|
519
|
-
if (!connection.global) connection.agentIds.push(agentId)
|
|
520
|
-
} else if (connection.global) {
|
|
521
|
-
connection.excludedAgentIds.push(agentId)
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
})
|
|
525
|
-
return this.connections.forScope({ agentId })
|
|
509
|
+
const all = this.connections.list()
|
|
510
|
+
for (const id of ids) {
|
|
511
|
+
const connection = all.find((item) => item.id === id)
|
|
512
|
+
if (!connection) throw notFound('Connection')
|
|
513
|
+
if (!allowed(connection, creator)) throw new HarnessError('required_connections_unavailable', `${connection.name} is disabled or outside your connection scope.`)
|
|
514
|
+
}
|
|
515
|
+
return this.connections.setAgentScope(agentId, ids)
|
|
526
516
|
}
|
|
527
517
|
|
|
528
518
|
/**
|
|
@@ -542,22 +532,14 @@ export class ConnectedApps implements SessionBroker {
|
|
|
542
532
|
|
|
543
533
|
/** Switches one saved connection on or off for everyone, one agent, or one project. */
|
|
544
534
|
attach(connectionId: string, scope: { agentId?: string | null; projectId?: string | null }, on: boolean): void {
|
|
545
|
-
this.connections.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (!connection.enabled) throw invalid(`${connection.name} is switched off. Switch it on in its own settings first.`)
|
|
549
|
-
setScope(connection, scope, on)
|
|
550
|
-
})
|
|
535
|
+
const connection = this.connections.find(connectionId)
|
|
536
|
+
if (!connection.enabled) throw invalid(`${connection.name} is switched off. Switch it on in its own settings first.`)
|
|
537
|
+
this.connections.setScope(connectionId, scope, on)
|
|
551
538
|
}
|
|
552
539
|
|
|
553
540
|
/** Forgets an agent that no longer exists. */
|
|
554
541
|
removeAgentScope(agentId: string): void {
|
|
555
|
-
this.connections.
|
|
556
|
-
for (const connection of all) {
|
|
557
|
-
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
558
|
-
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
559
|
-
}
|
|
560
|
-
})
|
|
542
|
+
this.connections.removeAgentScope(agentId)
|
|
561
543
|
}
|
|
562
544
|
|
|
563
545
|
async close(): Promise<void> {
|
package/src/mcp/types.ts
CHANGED
|
@@ -7,6 +7,9 @@ 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
|
+
/** Stable attachment key for a connected app's capability switch. */
|
|
11
|
+
export const connectionCapabilityId = (id: string): string => `connected-apps/connection/${id}`
|
|
12
|
+
|
|
10
13
|
export const connectionSchema = z.object({
|
|
11
14
|
id: z.string().default(''),
|
|
12
15
|
name: z.string().default(''),
|
package/src/memory/api.ts
CHANGED
|
@@ -183,13 +183,15 @@ export function list(env: HarnessEnv, params: Params): string {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/** One skill's instructions by name or id, or the skills index when no name is given. */
|
|
186
|
-
export function skill(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
186
|
+
export function skill(env: HarnessEnv, context: CallerContext, params: Params, contributions?: readonly string[]): string {
|
|
187
187
|
const { root } = env
|
|
188
188
|
const name = textParam(params, 'name').toLowerCase()
|
|
189
|
-
const items = registry.list(root).filter((item) => item.kind === 'skills')
|
|
189
|
+
const items = registry.limitToContributions(registry.list(root).filter((item) => item.kind === 'skills'), contributions)
|
|
190
190
|
if (!name) {
|
|
191
191
|
if (!items.length) return 'No skills yet.'
|
|
192
|
-
const active =
|
|
192
|
+
const active = (contributions === undefined
|
|
193
|
+
? registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null)
|
|
194
|
+
: items).map((item) => item.id)
|
|
193
195
|
return items.map((item) => `- ${item.name} (${item.id})${active.includes(item.id) ? ' · active' : ''} — ${item.description}`).join('\n')
|
|
194
196
|
}
|
|
195
197
|
const found = items.find((item) => item.id.toLowerCase() === name || item.id.replace(/^skills\//, '').toLowerCase() === name || item.name.toLowerCase() === name)
|
package/src/memory/dispatch.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { reindexQuietly } from './wikiIndex'
|
|
|
12
12
|
export type DispatchEnv = HarnessEnv & { bus?: Bus }
|
|
13
13
|
|
|
14
14
|
/** Runs one call and returns its reply as text for a model. */
|
|
15
|
-
export function dispatch(env: DispatchEnv, context: CallerContext, call: string, params: api.Params): string {
|
|
15
|
+
export function dispatch(env: DispatchEnv, context: CallerContext, call: string, params: api.Params, contributions?: readonly string[]): string {
|
|
16
16
|
switch (call) {
|
|
17
17
|
case 'wiki.entrypoint': {
|
|
18
18
|
const recorded = context.runId ? safely(() => load(env.root, context.runId!)) : null
|
|
@@ -36,7 +36,7 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
|
|
|
36
36
|
case 'wiki.list':
|
|
37
37
|
return api.list(env, params)
|
|
38
38
|
case 'wiki.skill':
|
|
39
|
-
return api.skill(env, context, params)
|
|
39
|
+
return api.skill(env, context, params, contributions)
|
|
40
40
|
case 'wiki.memory':
|
|
41
41
|
return api.memories(env, context, params)
|
|
42
42
|
case 'wiki.propose': {
|
|
@@ -16,7 +16,7 @@ import { listInbox } from './operations'
|
|
|
16
16
|
import { wikiDir } from './store'
|
|
17
17
|
|
|
18
18
|
const request: MaintenanceRequest = { jobId: 'job-1', namespace: '/library', trigger: { kind: 'ingest', sourceId: 'src-1', sourceName: 'notes.md', text: 'We chose Tauri.' }, pages: [], index: '# Index' }
|
|
19
|
-
const input = { cwd: '/tmp', product: 'Xingular Agent', account: { id: 'a', provider: 'pi' as const, label: 'A', env: {}, configDir: '/tmp' }, runtime: async () => ({}) as never }
|
|
19
|
+
const input = { cwd: '/tmp', product: 'Xingular Agent', account: { id: 'a', provider: 'pi' as const, label: 'A', env: {}, configDir: '/tmp', sharing: [] }, runtime: async () => ({}) as never }
|
|
20
20
|
|
|
21
21
|
describe('the maintainer session', () => {
|
|
22
22
|
it('collects proposals and flags without writing anything itself', async () => {
|
|
@@ -126,3 +126,16 @@ export function forget(attachments: Attachments, scope: AttachScope): void {
|
|
|
126
126
|
if (scope.agentId) delete attachments.agents[scope.agentId]
|
|
127
127
|
if (scope.projectId) delete attachments.projects[scope.projectId]
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/** Removes one capability's choices without disturbing the other items in each scope. */
|
|
131
|
+
export function forgetId(attachments: Attachments, id: string): void {
|
|
132
|
+
const forgetFrom = (item: Entries): void => {
|
|
133
|
+
item.on = item.on.filter((entry) => entry !== id)
|
|
134
|
+
item.off = item.off.filter((entry) => entry !== id)
|
|
135
|
+
}
|
|
136
|
+
forgetFrom(attachments.global)
|
|
137
|
+
for (const item of Object.values(attachments.agents)) forgetFrom(item)
|
|
138
|
+
for (const item of Object.values(attachments.projects)) forgetFrom(item)
|
|
139
|
+
attachments.agents = Object.fromEntries(Object.entries(attachments.agents).filter(([, item]) => item.on.length || item.off.length))
|
|
140
|
+
attachments.projects = Object.fromEntries(Object.entries(attachments.projects).filter(([, item]) => item.on.length || item.off.length))
|
|
141
|
+
}
|