@xingular/harness 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Live MCP client sessions: one per (session, connection), opened on first use and closed when
|
|
2
|
+
// the session's run ends or the connection changes.
|
|
3
|
+
import { Client, SSEClientTransport, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
import { HarnessError } from '../core/errors'
|
|
8
|
+
import { StoredOAuthProvider, type OAuthHost } from './oauth'
|
|
9
|
+
import type { HttpConfig, LaunchConfig, StdioConfig } from './types'
|
|
10
|
+
|
|
11
|
+
interface Live {
|
|
12
|
+
client: Client
|
|
13
|
+
transport: { close(): Promise<void> }
|
|
14
|
+
config: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const signInRequired = (config: Pick<LaunchConfig, 'name'>): HarnessError => new HarnessError('mcp_unauthorized', `${config.name} needs you to sign in again in Connections.`)
|
|
18
|
+
export const isSignInRequired = (error: unknown): boolean => error instanceof UnauthorizedError || (error instanceof HarnessError && error.code === 'mcp_unauthorized')
|
|
19
|
+
|
|
20
|
+
export class McpManager {
|
|
21
|
+
private readonly sessions = new Map<string, Promise<Live>>()
|
|
22
|
+
|
|
23
|
+
constructor(private readonly oauth: OAuthHost) {}
|
|
24
|
+
|
|
25
|
+
async close(connectionId?: string, sessionId?: string): Promise<void> {
|
|
26
|
+
const closing = [...this.sessions].filter(([key]) => (!connectionId || key.endsWith(`:${connectionId}`)) && (!sessionId || key.startsWith(`${sessionId}:`)))
|
|
27
|
+
for (const [key] of closing) this.sessions.delete(key)
|
|
28
|
+
await Promise.allSettled(
|
|
29
|
+
closing.map(async ([, session]) => {
|
|
30
|
+
const live = await session
|
|
31
|
+
await live.client.close()
|
|
32
|
+
await live.transport.close()
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The client for a connection in one scope, reconnecting when the connection's configuration changed. */
|
|
38
|
+
async client(scope: string, config: LaunchConfig): Promise<Client> {
|
|
39
|
+
const key = `${scope}:${config.id}`
|
|
40
|
+
const fingerprint = JSON.stringify(config)
|
|
41
|
+
const existing = this.sessions.get(key)
|
|
42
|
+
if (existing) {
|
|
43
|
+
const live = await existing.catch(() => null)
|
|
44
|
+
if (live?.config === fingerprint) return live.client
|
|
45
|
+
await live?.client.close().catch(() => undefined)
|
|
46
|
+
this.sessions.delete(key)
|
|
47
|
+
}
|
|
48
|
+
const pending: Promise<Live> = (async () => {
|
|
49
|
+
const { client, transport } = config.transport === 'http' ? await this.connectHttp(config) : await this.connectStdio(config)
|
|
50
|
+
client.onclose = () => {
|
|
51
|
+
if (this.sessions.get(key) === pending) this.sessions.delete(key)
|
|
52
|
+
}
|
|
53
|
+
return { client, transport, config: fingerprint }
|
|
54
|
+
})()
|
|
55
|
+
this.sessions.set(key, pending)
|
|
56
|
+
try {
|
|
57
|
+
return (await pending).client
|
|
58
|
+
} catch (error) {
|
|
59
|
+
this.sessions.delete(key)
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async connectStdio(config: StdioConfig) {
|
|
65
|
+
const client = new Client({ name: 'agent-x', version: '0.1.0' })
|
|
66
|
+
const transport = new StdioClientTransport({ command: config.command, args: config.args, cwd: config.cwd ?? undefined, env: config.env, stderr: 'pipe' })
|
|
67
|
+
try {
|
|
68
|
+
await client.connect(transport, { timeout: 30000 })
|
|
69
|
+
} catch {
|
|
70
|
+
await transport.close()
|
|
71
|
+
throw new HarnessError('mcp_unavailable', `${config.name} could not start or initialize. Check its executable and arguments.`)
|
|
72
|
+
}
|
|
73
|
+
// Servers' diagnostics may contain secrets; do not persist or forward stderr.
|
|
74
|
+
transport.stderr?.on('data', () => undefined)
|
|
75
|
+
return { client, transport }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async connectHttp(config: HttpConfig) {
|
|
79
|
+
const authProvider = config.auth === 'oauth' ? new StoredOAuthProvider(this.oauth, config, false) : undefined
|
|
80
|
+
// Without saved tokens the only way forward is the browser, which a session may not open.
|
|
81
|
+
if (authProvider && !(await authProvider.tokens())) throw signInRequired(config)
|
|
82
|
+
const options = { authProvider, requestInit: { headers: config.headers } }
|
|
83
|
+
// Servers that predate Streamable HTTP only answer on SSE.
|
|
84
|
+
for (const Transport of [StreamableHTTPClientTransport, SSEClientTransport]) {
|
|
85
|
+
const client = new Client({ name: 'agent-x', version: '0.1.0' })
|
|
86
|
+
const transport = new Transport(new URL(config.url), options)
|
|
87
|
+
try {
|
|
88
|
+
await client.connect(transport, { timeout: 30000 })
|
|
89
|
+
return { client, transport }
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await transport.close().catch(() => undefined)
|
|
92
|
+
if (isSignInRequired(error)) throw signInRequired(config)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new HarnessError('mcp_unavailable', `${config.name} could not be reached. Check its address and credentials.`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Runs `work` on a connection opened only for it. */
|
|
99
|
+
private async once<T>(prefix: string, config: LaunchConfig, work: (client: Client) => Promise<T>): Promise<T> {
|
|
100
|
+
const scope = `${prefix}-${randomUUID()}`
|
|
101
|
+
try {
|
|
102
|
+
return await work(await this.client(scope, config))
|
|
103
|
+
} finally {
|
|
104
|
+
const key = `${scope}:${config.id}`
|
|
105
|
+
const session = this.sessions.get(key)
|
|
106
|
+
this.sessions.delete(key)
|
|
107
|
+
if (session) await (await session).client.close().catch(() => undefined)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What a connection offers, read on a connection of its own. */
|
|
112
|
+
probe(config: LaunchConfig): Promise<{ serverName: string; tools: unknown[]; vaultId: string | null }> {
|
|
113
|
+
return this.once('probe', config, async (client) => {
|
|
114
|
+
const tools = (await client.listTools()).tools
|
|
115
|
+
let vaultId: string | null = null
|
|
116
|
+
if (tools.some((tool) => tool.name === 'x_workspace_status')) {
|
|
117
|
+
const status = await client.callTool({ name: 'x_workspace_status', arguments: {} })
|
|
118
|
+
const envelope = status.structuredContent as { ok?: boolean; vaultId?: string; error?: { message?: string } } | undefined
|
|
119
|
+
if (!envelope?.ok) throw new HarnessError('mcp_unavailable', envelope?.error?.message ?? 'Workspace vault is not bound.')
|
|
120
|
+
vaultId = envelope.vaultId ?? null
|
|
121
|
+
}
|
|
122
|
+
return { serverName: client.getServerVersion()?.name ?? config.name, tools, vaultId }
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One read on a connection of its own, outside any session. */
|
|
127
|
+
readSource(config: LaunchConfig, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
128
|
+
return this.once('source', config, (client) => client.callTool({ name: tool, arguments: args }, { timeout: 60000 }))
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/mcp/oauth.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// OAuth sign-in for a remote connection. State is kept with the connection's other secrets.
|
|
2
|
+
// Only an interactive provider (a sign-in a person started) may open a browser; a session that
|
|
3
|
+
// needs a new sign-in fails instead.
|
|
4
|
+
import { auth, UnauthorizedError } from '@modelcontextprotocol/client'
|
|
5
|
+
import type { OAuthClientInformationContext, OAuthClientMetadata, OAuthClientProvider, OAuthDiscoveryState, StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/client'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { createServer } from 'node:http'
|
|
8
|
+
|
|
9
|
+
import { HarnessError } from '../core/errors'
|
|
10
|
+
import { MCP_OAUTH_PORT, MCP_OAUTH_REDIRECT_URL, type HttpConfig } from './types'
|
|
11
|
+
|
|
12
|
+
interface Saved {
|
|
13
|
+
client?: StoredOAuthClientInformation
|
|
14
|
+
tokens?: StoredOAuthTokens
|
|
15
|
+
discovery?: OAuthDiscoveryState
|
|
16
|
+
staticClient?: StoredOAuthClientInformation
|
|
17
|
+
staticIssuer?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where a connection's OAuth state is read and written, and how a sign-in page is shown. */
|
|
21
|
+
export interface OAuthHost {
|
|
22
|
+
load(connectionId: string): Promise<Record<string, unknown>>
|
|
23
|
+
save(connectionId: string, state: Record<string, unknown>): Promise<void>
|
|
24
|
+
openUrl(url: string): Promise<void> | void
|
|
25
|
+
/** The name shown on the provider's consent page. */
|
|
26
|
+
clientName: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class StoredOAuthProvider implements OAuthClientProvider {
|
|
30
|
+
readonly expectedState = randomUUID()
|
|
31
|
+
private verifier: string | null = null
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly host: OAuthHost,
|
|
35
|
+
private readonly config: Pick<HttpConfig, 'id' | 'scopes'>,
|
|
36
|
+
private readonly interactive: boolean
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
get redirectUrl(): string {
|
|
40
|
+
return MCP_OAUTH_REDIRECT_URL
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
44
|
+
return {
|
|
45
|
+
client_name: this.host.clientName,
|
|
46
|
+
redirect_uris: [MCP_OAUTH_REDIRECT_URL],
|
|
47
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
48
|
+
response_types: ['code'],
|
|
49
|
+
token_endpoint_auth_method: 'none',
|
|
50
|
+
...(this.config.scopes.length ? { scope: this.config.scopes.join(' ') } : {})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
state(): string {
|
|
55
|
+
return this.expectedState
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private load(): Promise<Saved> {
|
|
59
|
+
return this.host.load(this.config.id) as Promise<Saved>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async change(edit: (saved: Saved) => Saved): Promise<void> {
|
|
63
|
+
await this.host.save(this.config.id, edit(await this.load()) as Record<string, unknown>)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async clientInformation(context?: OAuthClientInformationContext): Promise<StoredOAuthClientInformation | undefined> {
|
|
67
|
+
const saved = await this.load()
|
|
68
|
+
if (!saved.staticClient) return saved.client
|
|
69
|
+
// A client the user registered themselves (providers without dynamic registration) wins. It is
|
|
70
|
+
// tied to the authorization server seen at sign-in, so a session never offers it to another one.
|
|
71
|
+
if (this.interactive && context && saved.staticIssuer !== context.issuer) await this.change((current) => ({ ...current, staticIssuer: context.issuer }))
|
|
72
|
+
const issuer = this.interactive ? context?.issuer : saved.staticIssuer
|
|
73
|
+
return issuer ? { ...saved.staticClient, issuer } : saved.staticClient
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async saveClientInformation(client: StoredOAuthClientInformation): Promise<void> {
|
|
77
|
+
await this.change((saved) => ({ ...saved, client }))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async tokens(): Promise<StoredOAuthTokens | undefined> {
|
|
81
|
+
return (await this.load()).tokens
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
|
|
85
|
+
await this.change((saved) => ({ ...saved, tokens }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
|
89
|
+
return (await this.load()).discovery
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async saveDiscoveryState(discovery: OAuthDiscoveryState): Promise<void> {
|
|
93
|
+
await this.change((saved) => ({ ...saved, discovery }))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
saveCodeVerifier(verifier: string): void {
|
|
97
|
+
this.verifier = verifier
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
codeVerifier(): string {
|
|
101
|
+
if (!this.verifier) throw new UnauthorizedError('Sign-in was not started from this app.')
|
|
102
|
+
return this.verifier
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise<void> {
|
|
106
|
+
if (scope === 'verifier' || scope === 'all') this.verifier = null
|
|
107
|
+
if (scope === 'verifier') return
|
|
108
|
+
await this.change((saved) => (scope === 'all' ? {} : { ...saved, [scope]: undefined }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async redirectToAuthorization(url: URL): Promise<void> {
|
|
112
|
+
if (!this.interactive) throw new UnauthorizedError('Sign in again in Connections.')
|
|
113
|
+
// Google only returns a refresh token when offline access is requested with a consent prompt.
|
|
114
|
+
if (url.hostname === 'accounts.google.com') {
|
|
115
|
+
url.searchParams.set('access_type', 'offline')
|
|
116
|
+
url.searchParams.set('prompt', 'consent')
|
|
117
|
+
}
|
|
118
|
+
await this.host.openUrl(url.toString())
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const page = (title: string, message: string): string => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:20vh auto;max-width:28rem;text-align:center"><p>${message}</p></body>`
|
|
123
|
+
|
|
124
|
+
// One sign-in owns the loopback port. Starting another ends the one still waiting, so a closed
|
|
125
|
+
// browser tab never blocks a retry.
|
|
126
|
+
let cancelActive: (() => Promise<void>) | null = null
|
|
127
|
+
|
|
128
|
+
/** Waits on the fixed loopback address for the provider to send the browser back. */
|
|
129
|
+
function listenForCallback(expectedState: string, clientName: string): Promise<{ result: Promise<{ code: string; iss?: string }>; close: () => Promise<void> }> {
|
|
130
|
+
return new Promise((ready, failed) => {
|
|
131
|
+
let settle: { resolve: (value: { code: string; iss?: string }) => void; reject: (error: Error) => void }
|
|
132
|
+
const result = new Promise<{ code: string; iss?: string }>((resolve, reject) => {
|
|
133
|
+
settle = { resolve, reject }
|
|
134
|
+
})
|
|
135
|
+
result.catch(() => undefined)
|
|
136
|
+
const server = createServer((request, response) => {
|
|
137
|
+
const url = new URL(request.url ?? '/', MCP_OAUTH_REDIRECT_URL)
|
|
138
|
+
const reply = (status: number, message: string): void => {
|
|
139
|
+
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
|
|
140
|
+
response.end(page(clientName, message))
|
|
141
|
+
}
|
|
142
|
+
if (url.pathname !== new URL(MCP_OAUTH_REDIRECT_URL).pathname) return reply(404, 'Not found.')
|
|
143
|
+
// Anything on this machine can reach the port; only the request carrying this sign-in's state counts.
|
|
144
|
+
if (url.searchParams.get('state') !== expectedState) return reply(400, `This sign-in link is not the one ${clientName} is waiting for.`)
|
|
145
|
+
const code = url.searchParams.get('code')
|
|
146
|
+
if (!code) {
|
|
147
|
+
reply(400, 'Sign-in was not completed. You can close this window.')
|
|
148
|
+
return settle.reject(new HarnessError('mcp_unauthorized', url.searchParams.get('error') === 'access_denied' ? 'Sign-in was cancelled.' : 'The provider did not complete the sign-in.'))
|
|
149
|
+
}
|
|
150
|
+
reply(200, `Signed in. You can close this window and return to ${clientName}.`)
|
|
151
|
+
settle.resolve({ code, iss: url.searchParams.get('iss') ?? undefined })
|
|
152
|
+
})
|
|
153
|
+
const timer = setTimeout(() => settle.reject(new HarnessError('mcp_unauthorized', 'Sign-in timed out. Try again.')), 300000)
|
|
154
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
failed(new HarnessError('invalid_request', error.code === 'EADDRINUSE' ? `Port ${MCP_OAUTH_PORT} is in use. Finish or cancel the other sign-in, or quit the program using that port, then try again.` : 'The sign-in listener could not start.'))
|
|
157
|
+
})
|
|
158
|
+
const closed = new Promise<void>((resolve) => server.once('close', resolve))
|
|
159
|
+
const close = (): Promise<void> => {
|
|
160
|
+
clearTimeout(timer)
|
|
161
|
+
if (cancelActive === cancel) cancelActive = null
|
|
162
|
+
server.close()
|
|
163
|
+
server.closeAllConnections()
|
|
164
|
+
return closed
|
|
165
|
+
}
|
|
166
|
+
const cancel = (): Promise<void> => {
|
|
167
|
+
settle.reject(new HarnessError('mcp_unauthorized', 'Sign-in was cancelled.'))
|
|
168
|
+
return close()
|
|
169
|
+
}
|
|
170
|
+
server.listen(MCP_OAUTH_PORT, '127.0.0.1', () => {
|
|
171
|
+
cancelActive = cancel
|
|
172
|
+
ready({ result, close })
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Opens the provider's sign-in page and waits for the browser to return to the loopback address. */
|
|
178
|
+
export async function signIn(host: OAuthHost, config: HttpConfig): Promise<void> {
|
|
179
|
+
const provider = new StoredOAuthProvider(host, config, true)
|
|
180
|
+
const scope = config.scopes.join(' ') || undefined
|
|
181
|
+
await cancelActive?.()
|
|
182
|
+
const callback = await listenForCallback(provider.expectedState, host.clientName)
|
|
183
|
+
try {
|
|
184
|
+
if ((await auth(provider, { serverUrl: config.url, scope, forceReauthorization: true })) === 'REDIRECT') {
|
|
185
|
+
const { code, iss } = await callback.result
|
|
186
|
+
if ((await auth(provider, { serverUrl: config.url, scope, authorizationCode: code, iss })) !== 'AUTHORIZED') throw new HarnessError('mcp_unauthorized', 'The provider did not complete the sign-in.')
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof HarnessError) throw error
|
|
190
|
+
// Provider responses can quote credentials; report only that it failed.
|
|
191
|
+
throw new HarnessError('mcp_unauthorized', `${config.name} sign-in failed. Check the connection's address and OAuth client, then try again.`)
|
|
192
|
+
} finally {
|
|
193
|
+
await callback.close()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|