@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.
@@ -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 { connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
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
- writeJson(this.path, all)
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
+ }
@@ -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, setScope, webUrl } from './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 the connection in Settings → Connections.`
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 connected tools',
397
- description: `Discover enabled ${this.options.env.productName} app operations and connected-app tools by name. Pass tool to get one tool with its input schema.${this.brokered?.discoveryNote ? ` ${this.brokered.discoveryNote}` : ''}`,
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 connected app',
415
- description: 'Invoke an enabled app or connected-app tool returned by mcp_list_tools. Current settings are checked on every call. Changes require approval.',
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.updateAll((all) => {
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
- for (const connection of all) {
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.updateAll((all) => {
546
- const connection = all.find((item) => item.id === connectionId)
547
- if (!connection) throw notFound('Connection')
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.updateAll((all) => {
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 = registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null).map((item) => item.id)
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)
@@ -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
+ }