@xingular/harness 0.1.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +6 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
package/src/harness.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The harness, assembled: Pi sessions on isolated accounts, a compiled prompt, a memory
|
|
2
2
|
// lifecycle, tools, connected apps, and skills. An application embeds one of these and adds
|
|
3
3
|
// its own prompt sections, tools, and policy; nothing here knows what that application is.
|
|
4
|
+
import { Subagents } from './sessions/subagentService'
|
|
4
5
|
import { mkdirSync } from 'node:fs'
|
|
5
6
|
import { join } from 'node:path'
|
|
6
7
|
|
|
@@ -16,6 +17,7 @@ import { readJsonOr } from './core/fs'
|
|
|
16
17
|
import { closeIndex } from './core/index'
|
|
17
18
|
import { projectsOrNone } from './core/projects'
|
|
18
19
|
import { ConnectedApps } from './mcp/service'
|
|
20
|
+
import type { AppOAuthClients } from './mcp/types'
|
|
19
21
|
import { migrateConnectionScopes } from './mcp/scopeMigration'
|
|
20
22
|
import type { MaintainerFactory } from './memory/maintainer'
|
|
21
23
|
import { Maintenance } from './memory/maintenance'
|
|
@@ -76,6 +78,11 @@ export interface HarnessOptions {
|
|
|
76
78
|
plugins?: PluginDescriptor[]
|
|
77
79
|
/** Replaces Pi's own search for extensions, skills, prompts and themes, for tests. */
|
|
78
80
|
resolver?: Resolver
|
|
81
|
+
/**
|
|
82
|
+
* The application's own registered OAuth clients, so a connector it publishes needs no client
|
|
83
|
+
* from the person. Only a catalog entry listed in `ids` can use one, and nothing is stored.
|
|
84
|
+
*/
|
|
85
|
+
oauthClients?: AppOAuthClients
|
|
79
86
|
/** Replaces how a plugin's hooks file is read as a module, for tests. */
|
|
80
87
|
hookImporter?: (address: string) => Promise<unknown>
|
|
81
88
|
}
|
|
@@ -124,6 +131,7 @@ export class Harness {
|
|
|
124
131
|
/** Every part described as a plugin: the harness's own, then the application's. */
|
|
125
132
|
descriptors: readonly PluginDescriptor[] = HARNESS_PLUGINS
|
|
126
133
|
applicationPlugins: ReadonlySet<string> = new Set()
|
|
134
|
+
readonly subagents: Subagents
|
|
127
135
|
readonly sessions: Sessions
|
|
128
136
|
readonly maintenance: Maintenance
|
|
129
137
|
readonly terminals: Terminals
|
|
@@ -152,7 +160,7 @@ export class Harness {
|
|
|
152
160
|
redact: (event) => this.apps.connections.redactEvent(event),
|
|
153
161
|
ended: (runId, sessionId) => this.apps.revokeRun(runId, sessionId)
|
|
154
162
|
})
|
|
155
|
-
this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy })
|
|
163
|
+
this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy, oauthClients: options.oauthClients })
|
|
156
164
|
this.plugins = new Plugins({ stateDir: this.stateDir, bus: this.bus, resolver: options.resolver, attachments: () => (this.env.dataRoot ? attachments.load(this.env.dataRoot) : null), packagesDir: () => (this.env.dataRoot ? join(this.env.dataRoot, 'plugins') : null) })
|
|
157
165
|
this.hooks = new Hooks({
|
|
158
166
|
stateDir: this.stateDir,
|
|
@@ -182,7 +190,9 @@ export class Harness {
|
|
|
182
190
|
reloadTimer.unref?.()
|
|
183
191
|
})
|
|
184
192
|
this.describe(options.plugins ?? [])
|
|
193
|
+
this.subagents = new Subagents(this.env, this.runs, (input) => this.sessions.run(input))
|
|
185
194
|
this.sessions = new Sessions({
|
|
195
|
+
subagents: this.subagents,
|
|
186
196
|
env: this.env,
|
|
187
197
|
accounts: this.accounts,
|
|
188
198
|
runs: this.runs,
|
|
@@ -193,7 +203,7 @@ export class Harness {
|
|
|
193
203
|
factory: options.sessionFactory
|
|
194
204
|
})
|
|
195
205
|
this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs, external: options.externalMaintenance, excludedTriggers: options.maintenanceExcludedTriggers, runTrigger: options.maintenanceRunTrigger })
|
|
196
|
-
this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
|
|
206
|
+
this.terminals = new Terminals({ subagents: this.subagents, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
|
|
197
207
|
this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
|
|
198
208
|
this.runs.listen((event, run) => {
|
|
199
209
|
this.accounts.onRunEvent(event, run)
|
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ export type { Effort } from './sessions/runner'
|
|
|
47
47
|
|
|
48
48
|
export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
|
|
49
49
|
export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
|
|
50
|
-
export { SPAWN_SUBAGENT, SUBAGENT_RELATION
|
|
50
|
+
export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
|
|
51
51
|
export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
|
|
52
52
|
export type { CurationJob } from './memory/curation'
|
|
53
53
|
export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
|
|
@@ -55,7 +55,7 @@ export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, typ
|
|
|
55
55
|
export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
|
|
56
56
|
|
|
57
57
|
export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
|
|
58
|
-
export type { Connection, ConnectionInput } from './mcp/types'
|
|
58
|
+
export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
|
|
59
59
|
|
|
60
60
|
export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
|
|
61
61
|
|
|
@@ -73,3 +73,8 @@ export { methods } from './server/methods'
|
|
|
73
73
|
export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|
|
74
74
|
export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
|
|
75
75
|
export { attach, serve, type Served } from './server/serve'
|
|
76
|
+
|
|
77
|
+
export { DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions, type SubagentInput } from './sessions/subagents'
|
|
78
|
+
|
|
79
|
+
export { Subagents } from './sessions/subagentService'
|
|
80
|
+
export { WAIT_SUBAGENTS, type SubagentResult, type SubagentSelection } from './sessions/subagents'
|
package/src/mcp/connections.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { isDir } from '../memory/store'
|
|
|
12
12
|
import type { RunEvent } from '../sessions/contracts'
|
|
13
13
|
import * as attachments from '../plugins/attachments'
|
|
14
14
|
import { connectionScopeMigrationComplete } from './scopeMigration'
|
|
15
|
-
import { connectionCapabilityId, connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
|
|
15
|
+
import { connectionCapabilityId, connectionInputSchema, connectionSchema, type AppOAuthClients, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
|
|
16
16
|
|
|
17
17
|
const OAUTH_STATE = 'oauth'
|
|
18
18
|
const OAUTH_CLIENT_ID = 'oauth_client_id'
|
|
@@ -175,7 +175,8 @@ export class Connections {
|
|
|
175
175
|
stateDir: string,
|
|
176
176
|
private readonly secrets: SecretStore,
|
|
177
177
|
private readonly bus: Bus,
|
|
178
|
-
private readonly attachmentRoot: () => string | null = () => null
|
|
178
|
+
private readonly attachmentRoot: () => string | null = () => null,
|
|
179
|
+
private readonly appClients: AppOAuthClients | null = null
|
|
179
180
|
) {
|
|
180
181
|
this.path = join(stateDir, 'connections.json')
|
|
181
182
|
}
|
|
@@ -468,23 +469,49 @@ export class Connections {
|
|
|
468
469
|
|
|
469
470
|
// ---- OAuth state --------------------------------------------------------------------------
|
|
470
471
|
|
|
471
|
-
private requireOauth(id: string):
|
|
472
|
-
|
|
472
|
+
private requireOauth(id: string): Connection {
|
|
473
|
+
const connection = this.list().find((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')
|
|
474
|
+
if (!connection) throw notFound('Connection')
|
|
475
|
+
return connection
|
|
473
476
|
}
|
|
474
477
|
|
|
475
|
-
/** The saved sign-in of a connection, with the client the user registered,
|
|
478
|
+
/** The saved sign-in of a connection, with the client the user registered, or the application's own. */
|
|
476
479
|
async oauthLoad(id: string): Promise<Record<string, unknown>> {
|
|
477
|
-
this.requireOauth(id)
|
|
480
|
+
const connection = this.requireOauth(id)
|
|
478
481
|
const saved = (parsedContainer((await this.secret(id, OAUTH_STATE)) ?? '') ?? {}) as Record<string, unknown>
|
|
479
482
|
const state = Array.isArray(saved) ? {} : saved
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
const
|
|
483
|
-
state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
|
|
483
|
+
delete state.staticClient
|
|
484
|
+
if (connection.oauthClient === 'app') {
|
|
485
|
+
const client = await this.appClient(connection)
|
|
486
|
+
if (client) state.staticClient = { client_id: client.clientId, ...(client.clientSecret ? { client_secret: client.clientSecret } : {}) }
|
|
487
|
+
} else if (connection.oauthClient === 'user' || connection.oauthClient === null) {
|
|
488
|
+
const clientId = await this.secret(id, OAUTH_CLIENT_ID)
|
|
489
|
+
if (clientId) {
|
|
490
|
+
const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
|
|
491
|
+
state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
|
|
492
|
+
}
|
|
484
493
|
}
|
|
485
494
|
return state
|
|
486
495
|
}
|
|
487
496
|
|
|
497
|
+
/** The client this application registered for a catalog entry, if it has one. */
|
|
498
|
+
private async appClient(connection: Connection): Promise<{ clientId: string; clientSecret?: string } | null> {
|
|
499
|
+
if (!connection.catalogId) return null
|
|
500
|
+
return (await this.appClients?.get(connection.catalogId)) ?? null
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Whether a sign-in can be started: a dynamic server registers itself, the others need a client. */
|
|
504
|
+
async hasSignInClient(connection: Connection): Promise<boolean> {
|
|
505
|
+
if (connection.oauthClient === 'user') return (await this.secret(connection.id, OAUTH_CLIENT_ID)) !== null
|
|
506
|
+
if (connection.oauthClient === 'app') return (await this.appClient(connection)) !== null
|
|
507
|
+
return true
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The catalog entries this application can sign in for without asking for a client. */
|
|
511
|
+
availableAppClients(): string[] {
|
|
512
|
+
return this.appClients?.ids() ?? []
|
|
513
|
+
}
|
|
514
|
+
|
|
488
515
|
async oauthSave(id: string, state: Record<string, unknown>): Promise<void> {
|
|
489
516
|
this.requireOauth(id)
|
|
490
517
|
const { staticClient: _static, ...saved } = state ?? {}
|
|
@@ -515,7 +542,7 @@ export class Connections {
|
|
|
515
542
|
if (remote) {
|
|
516
543
|
if (!webUrl(connection.url)) throw invalid('Server URL must be an https:// address.')
|
|
517
544
|
if (!['none', 'headers', 'oauth'].includes(connection.auth)) throw invalid('Unsupported sign-in method.')
|
|
518
|
-
if (connection.oauthClient && !['dynamic', 'user'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
|
|
545
|
+
if (connection.oauthClient && !['dynamic', 'user', 'app'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
|
|
519
546
|
Object.assign(connection, { command: '', args: [], cwd: null })
|
|
520
547
|
if (connection.auth !== 'oauth') Object.assign(connection, { oauthClient: null, oauthScopes: [] })
|
|
521
548
|
} else {
|
|
@@ -533,7 +560,7 @@ export class Connections {
|
|
|
533
560
|
connection.envKeys = [...(existing?.envKeys ?? [])]
|
|
534
561
|
connection.headerKeys = [...(existing?.headerKeys ?? [])]
|
|
535
562
|
// A sign-in belongs to one server and one client; never carry it to another.
|
|
536
|
-
const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && !oauthCredentials
|
|
563
|
+
const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && existing?.oauthClient === connection.oauthClient && existing?.catalogId === connection.catalogId && !oauthCredentials
|
|
537
564
|
if (!sameServer || connection.auth !== 'oauth') await this.forgetSignIn(connection.id)
|
|
538
565
|
connection.authState = connection.auth === 'oauth' ? (sameServer ? (existing?.authState ?? 'signed_out') : 'signed_out') : null
|
|
539
566
|
if (oauthCredentials) {
|
package/src/mcp/service.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelconte
|
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
9
|
|
|
10
10
|
import { memorySecretStore } from '../accounts/secrets'
|
|
11
|
-
import { createHarness, type Harness } from '../harness'
|
|
11
|
+
import { createHarness, type Harness, type HarnessOptions } from '../harness'
|
|
12
12
|
import type { RunEvent } from '../sessions/contracts'
|
|
13
13
|
import type { ToolPolicy } from '../sessions/policy'
|
|
14
14
|
import type { PiSessionLike } from '../sessions/runner'
|
|
@@ -106,12 +106,12 @@ afterEach(async () => {
|
|
|
106
106
|
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
107
107
|
|
|
108
108
|
/** A harness with one live run whose session keeps going until the test releases it. */
|
|
109
|
-
async function live(policy: ToolPolicy = {}) {
|
|
109
|
+
async function live(policy: ToolPolicy = {}, extra: Partial<HarnessOptions> = {}) {
|
|
110
110
|
let release: () => void = () => undefined
|
|
111
111
|
const stateDir = mkdtempSync(join(tmpdir(), 'harness-mcp-state-'))
|
|
112
112
|
const secrets = memorySecretStore()
|
|
113
113
|
const session: PiSessionLike = { sessionId: 'native', subscribe: () => () => undefined, prompt: () => new Promise<void>((resolve) => (release = resolve)), sendCustomMessage: async () => undefined, abort: async () => release(), dispose: () => undefined }
|
|
114
|
-
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session })
|
|
114
|
+
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session, ...extra })
|
|
115
115
|
open.push(harness)
|
|
116
116
|
harness.accounts.create('Main', 'apiKey')
|
|
117
117
|
const events: RunEvent[] = []
|
|
@@ -277,6 +277,7 @@ describe('remote connected apps', () => {
|
|
|
277
277
|
await expect(harness.apps.save({ name: '', kind: 'local', command: 'x' })).rejects.toThrow('A name, executable')
|
|
278
278
|
await expect(harness.apps.save({ ...local, env: { 'BAD NAME': 'x' } })).rejects.toThrow('environment variable')
|
|
279
279
|
await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'headers', headers: { 'X-Key': 'a\nb' } })).rejects.toThrow('header')
|
|
280
|
+
await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'oauth', oauthClient: 'whoever' })).rejects.toThrow('Unsupported OAuth client')
|
|
280
281
|
const workspace = await harness.apps.save({ name: 'Xingular Workspace', kind: 'workspace', command: process.execPath, enabled: true, global: true })
|
|
281
282
|
expect(() => harness.apps.connections.allowTool(workspace.id, 'x_workspace_mutation_apply')).toThrow('one at a time')
|
|
282
283
|
await harness.apps.remove(workspace.id)
|
|
@@ -373,4 +374,49 @@ describe('signing in to a remote connected app', () => {
|
|
|
373
374
|
expect(server.seen.tokenRequests[0]).toContain('fixture-code')
|
|
374
375
|
expect(server.seen.tokenRequests[0]).toMatch(/own-client|b3duLWNsaWVudDpvd24tc2VjcmV0/)
|
|
375
376
|
})
|
|
377
|
+
|
|
378
|
+
it('signs in with the application\'s own client, keeps it out of storage, and offers it only for that catalog entry', async () => {
|
|
379
|
+
const server = await oauthFixture()
|
|
380
|
+
const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
|
|
381
|
+
harness.bus.on((notice) => {
|
|
382
|
+
if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
|
|
383
|
+
})
|
|
384
|
+
expect(harness.apps.appClients()).toEqual({ catalogIds: ['fixture-app'] })
|
|
385
|
+
const saved = await harness.apps.save({ name: 'App client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'app', catalogId: 'fixture-app', oauthScopes: ['read'] })
|
|
386
|
+
expect((await harness.apps.signIn(saved.id)).authState).toBe('signed_in')
|
|
387
|
+
// No client of its own is registered, and the application's client is what the provider sees.
|
|
388
|
+
expect(server.seen.registered).toBe(0)
|
|
389
|
+
expect(server.seen.authorizeUrls[0].searchParams.get('client_id')).toBe('app-client')
|
|
390
|
+
// Neither the saved connection nor the secret store ever holds the application's client secret.
|
|
391
|
+
expect(JSON.stringify(harness.apps.connections.list())).not.toContain('app-secret')
|
|
392
|
+
expect(JSON.stringify([...secrets.values.entries()])).not.toContain('app-secret')
|
|
393
|
+
expect(JSON.parse(secrets.values.get(`mcp--${saved.id}/oauth`)!).tokens).toMatchObject({ refresh_token: 'fixture-refresh' })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('uses the application client and resets sign-in when switching from a saved user client', async () => {
|
|
397
|
+
const server = await oauthFixture()
|
|
398
|
+
const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
|
|
399
|
+
harness.bus.on((notice) => {
|
|
400
|
+
if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
|
|
401
|
+
})
|
|
402
|
+
const user = await harness.apps.save({ name: 'Switched client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'user', oauthCredentials: { clientId: 'user-client', clientSecret: 'user-secret' } })
|
|
403
|
+
const signedInUser = await harness.apps.signIn(user.id)
|
|
404
|
+
expect(signedInUser.authState).toBe('signed_in')
|
|
405
|
+
|
|
406
|
+
const app = await harness.apps.save({ ...user, oauthClient: 'app', catalogId: 'fixture-app' })
|
|
407
|
+
expect(app.authState).toBe('signed_out')
|
|
408
|
+
expect(secrets.values.has(`mcp--${user.id}/oauth`)).toBe(false)
|
|
409
|
+
await harness.apps.signIn(user.id)
|
|
410
|
+
|
|
411
|
+
expect(server.seen.authorizeUrls.map((url) => url.searchParams.get('client_id'))).toEqual(['user-client', 'app-client'])
|
|
412
|
+
expect(server.seen.tokenRequests[1]).toMatch(/app-client|YXBwLWNsaWVudDphcHAtc2VjcmV0/)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('refuses an application sign-in this build has no client for, naming the reason', async () => {
|
|
416
|
+
const { harness } = await live()
|
|
417
|
+
const saved = await harness.apps.save({ name: 'No client', kind: 'remote', enabled: true, global: true, url: 'https://example.com/mcp', auth: 'oauth', oauthClient: 'app', catalogId: 'google-drive', oauthScopes: [] })
|
|
418
|
+
expect(harness.apps.appClients()).toEqual({ catalogIds: [] })
|
|
419
|
+
await expect(harness.apps.signIn(saved.id)).rejects.toThrow(/no app sign-in/)
|
|
420
|
+
expect(harness.apps.connections.find(saved.id).authState).toBe('signed_out')
|
|
421
|
+
})
|
|
376
422
|
})
|
package/src/mcp/service.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { ToolSession, ToolSpec } from '../tools/types'
|
|
|
20
20
|
import { allowed, Connections, webUrl } from './connections'
|
|
21
21
|
import { isSignInRequired, McpManager, signInRequired } from './manager'
|
|
22
22
|
import { signIn, type OAuthHost } from './oauth'
|
|
23
|
-
import type { Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
|
|
23
|
+
import type { AppOAuthClients, Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
|
|
24
24
|
|
|
25
25
|
/** A session's access to the broker. A terminal session presents `token`; an SDK session holds the object. */
|
|
26
26
|
export interface BrokerSession {
|
|
@@ -118,6 +118,8 @@ export interface ConnectedAppsOptions {
|
|
|
118
118
|
bus: Bus
|
|
119
119
|
runs: RunRegistry
|
|
120
120
|
policy: ToolPolicy
|
|
121
|
+
/** The application's own registered OAuth clients, if it has any. */
|
|
122
|
+
oauthClients?: AppOAuthClients
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
/** The scope a plugin hook's connections are opened under: they belong to no session. */
|
|
@@ -134,7 +136,7 @@ export class ConnectedApps implements SessionBroker {
|
|
|
134
136
|
private brokered: BrokeredTools | null = null
|
|
135
137
|
|
|
136
138
|
constructor(private readonly options: ConnectedAppsOptions) {
|
|
137
|
-
this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root)
|
|
139
|
+
this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root, options.oauthClients ?? null)
|
|
138
140
|
this.oauth = {
|
|
139
141
|
clientName: options.env.productName,
|
|
140
142
|
load: (id) => this.connections.oauthLoad(id),
|
|
@@ -469,7 +471,11 @@ export class ConnectedApps implements SessionBroker {
|
|
|
469
471
|
async signIn(connectionId: string): Promise<Connection> {
|
|
470
472
|
const connection = this.connections.find(connectionId)
|
|
471
473
|
if (connection.kind !== 'remote' || connection.auth !== 'oauth') throw invalid('This connection does not use browser sign-in.')
|
|
472
|
-
if (
|
|
474
|
+
if (!(await this.connections.hasSignInClient(connection))) {
|
|
475
|
+
throw invalid(connection.oauthClient === 'app'
|
|
476
|
+
? 'This connector has no app sign-in in this build. Enable its OAuth client or use your own client.'
|
|
477
|
+
: 'Add your OAuth client ID before signing in.')
|
|
478
|
+
}
|
|
473
479
|
await signIn(this.oauth, (await this.connections.launch(connection)) as HttpConfig)
|
|
474
480
|
const updated = this.connections.update(connectionId, (item) => Object.assign(item, { authState: 'signed_in', error: null }))
|
|
475
481
|
await this.manager.close(connectionId)
|
|
@@ -477,6 +483,11 @@ export class ConnectedApps implements SessionBroker {
|
|
|
477
483
|
return updated
|
|
478
484
|
}
|
|
479
485
|
|
|
486
|
+
/** The catalog entries this application can sign in for, so a client need not be added by hand. */
|
|
487
|
+
appClients(): { catalogIds: string[] } {
|
|
488
|
+
return { catalogIds: this.connections.availableAppClients() }
|
|
489
|
+
}
|
|
490
|
+
|
|
480
491
|
async signOut(connectionId: string): Promise<Connection> {
|
|
481
492
|
const updated = await this.connections.signOut(connectionId)
|
|
482
493
|
this.cancelConnection(connectionId)
|
package/src/mcp/types.ts
CHANGED
|
@@ -7,6 +7,16 @@ import { z } from 'zod'
|
|
|
7
7
|
export const MCP_OAUTH_PORT = 47823
|
|
8
8
|
export const MCP_OAUTH_REDIRECT_URL = `http://127.0.0.1:${MCP_OAUTH_PORT}/oauth/callback`
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The application's own registered OAuth clients, by catalog entry. A client supplied here is
|
|
12
|
+
* used for that sign-in only and is never written to the connection or the secret store.
|
|
13
|
+
*/
|
|
14
|
+
export interface AppOAuthClients {
|
|
15
|
+
/** The catalog entries this application can sign in for. */
|
|
16
|
+
ids(): string[]
|
|
17
|
+
get(catalogId: string): Promise<{ clientId: string; clientSecret?: string } | null>
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
/** Stable attachment key for a connected app's capability switch. */
|
|
11
21
|
export const connectionCapabilityId = (id: string): string => `connected-apps/connection/${id}`
|
|
12
22
|
|
|
@@ -11,9 +11,10 @@ import type { ToolSession } from '../tools/types'
|
|
|
11
11
|
import * as blocks from './blocks'
|
|
12
12
|
import { memoryEdit, propose } from './api'
|
|
13
13
|
import { enqueueCuration } from './curation'
|
|
14
|
-
import { findMemory } from './facts'
|
|
14
|
+
import { findMemory, saveAsUser } from './facts'
|
|
15
15
|
import * as registry from '../skills/registry'
|
|
16
16
|
import * as attachments from '../plugins/attachments'
|
|
17
|
+
import { listInbox } from './operations'
|
|
17
18
|
import { wikiDir } from './store'
|
|
18
19
|
|
|
19
20
|
const opened: Harness[] = []
|
|
@@ -57,6 +58,48 @@ test('a shared fact is proposed without writing, then validated against its orig
|
|
|
57
58
|
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
|
|
58
59
|
})
|
|
59
60
|
|
|
61
|
+
test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
|
|
62
|
+
const h = setup(); await origin(h)
|
|
63
|
+
propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
64
|
+
propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
65
|
+
const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
|
|
66
|
+
const batchCurator = { ...curator, maintenanceJobIds: ids }
|
|
67
|
+
expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
|
|
68
|
+
|
|
69
|
+
const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
|
|
70
|
+
pages: [],
|
|
71
|
+
memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
72
|
+
blocks: [],
|
|
73
|
+
flags: []
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(applied.operationId).toMatch(/^batch-/)
|
|
77
|
+
expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
|
|
78
|
+
expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
|
|
79
|
+
expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
|
|
80
|
+
expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a batch parks updates whose captured Wiki target changed before review', async () => {
|
|
84
|
+
const h = setup(); await origin(h)
|
|
85
|
+
const wiki = wikiDir(h.env.root)
|
|
86
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
|
|
87
|
+
propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
88
|
+
const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
|
|
89
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
|
|
90
|
+
|
|
91
|
+
const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
|
|
92
|
+
pages: [],
|
|
93
|
+
memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
94
|
+
blocks: [],
|
|
95
|
+
flags: []
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(result.jobs[0].status).toBe('needs_user')
|
|
99
|
+
expect(result.operationId).toBeNull()
|
|
100
|
+
expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
|
|
101
|
+
})
|
|
102
|
+
|
|
60
103
|
test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
|
|
61
104
|
const h = setup(); await origin(h)
|
|
62
105
|
propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
|
package/src/memory/dispatch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { launchText, load, render } from '../prompt/compile'
|
|
|
7
7
|
import * as api from './api'
|
|
8
8
|
import type { CallerContext } from './caller'
|
|
9
9
|
import * as recall from './recall'
|
|
10
|
+
import * as trajectory from './trajectory'
|
|
10
11
|
import { reindexQuietly } from './wikiIndex'
|
|
11
12
|
|
|
12
13
|
export type DispatchEnv = HarnessEnv & { bus?: Bus }
|
|
@@ -22,6 +23,10 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
|
|
|
22
23
|
return recall.searchTool(env, context, params)
|
|
23
24
|
case 'recall.read':
|
|
24
25
|
return recall.readTool(env, context, params)
|
|
26
|
+
case 'trajectory.search':
|
|
27
|
+
return trajectory.searchTool(env, context, params)
|
|
28
|
+
case 'trajectory.read':
|
|
29
|
+
return trajectory.readTool(env, context, params)
|
|
25
30
|
case 'memory.append':
|
|
26
31
|
case 'memory.replace':
|
|
27
32
|
case 'memory.rethink': {
|
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
5
5
|
|
|
6
6
|
import { memorySecretStore } from '../accounts/secrets'
|
|
7
7
|
import { writeJson } from '../core/fs'
|
|
8
|
-
import type
|
|
8
|
+
import { setLogSink, type Notice } from '../core/bus'
|
|
9
9
|
import { createHarness, type Harness } from '../harness'
|
|
10
10
|
import type { PiSessionLike } from '../sessions/runner'
|
|
11
11
|
import * as blocks from './blocks'
|
|
@@ -132,6 +132,22 @@ describe('the memory lifecycle', () => {
|
|
|
132
132
|
expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('a code run outside any project is not maintained, and it leaves no log line', async () => {
|
|
136
|
+
const lines: string[] = []
|
|
137
|
+
setLogSink((text) => void lines.push(text))
|
|
138
|
+
try {
|
|
139
|
+
let jobs = 0
|
|
140
|
+
const { harness } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
|
|
141
|
+
await harness.sessions.run({ run: { id: 'code-1', sessionId: 'nora', mode: 'code' },cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Code' }, tools: { allow: [] } })
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
143
|
+
await harness.maintenance.settled()
|
|
144
|
+
expect(jobs).toBe(0)
|
|
145
|
+
expect(lines.filter((line) => line.includes('maintenance'))).toEqual([])
|
|
146
|
+
} finally {
|
|
147
|
+
setLogSink(null)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
135
151
|
it('an imported source is read once and its proposals are checked against its text', async () => {
|
|
136
152
|
const { harness, wiki } = setup(async ({ kind, collect }) => ({
|
|
137
153
|
prompt: async () => {
|