@xingular/harness 0.1.6 → 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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -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 +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -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/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- 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 +43 -2
- 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/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -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 +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
|
|
4
|
+
export const HUB_MAX_BYTES = 16 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
const httpsUrl = z.string().max(2000).url().refine((value) => {
|
|
7
|
+
const url = new URL(value)
|
|
8
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash && !url.port
|
|
9
|
+
}, 'Use a public HTTPS URL without credentials, queries, or fragments.')
|
|
10
|
+
const payload = {
|
|
11
|
+
url: httpsUrl,
|
|
12
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
13
|
+
bundled: z.string().max(HUB_MAX_BYTES).optional()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const hubDeliverySchema = z.discriminatedUnion('kind', [
|
|
17
|
+
z.object({ kind: z.literal('registry'), registryKind: z.enum(['skills', 'prompts', 'rules']), ...payload }).strict(),
|
|
18
|
+
z.object({ kind: z.literal('document'), ...payload }).strict(),
|
|
19
|
+
z.object({ kind: z.literal('pi-package'), source: z.string().max(2000).refine((source) =>
|
|
20
|
+
/^npm:(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+@\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(source)
|
|
21
|
+
|| /^git:https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:\.git)?@[a-f0-9]{40}$/.test(source),
|
|
22
|
+
'Packages must use an exact npm version or a GitHub commit.') }).strict()
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const hubEntrySchema = z.object({
|
|
26
|
+
id: z.string().regex(/^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/),
|
|
27
|
+
title: z.string().min(1).max(100),
|
|
28
|
+
description: z.string().min(1).max(500),
|
|
29
|
+
publisher: z.string().min(1).max(100),
|
|
30
|
+
license: z.string().min(1).max(100),
|
|
31
|
+
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/),
|
|
32
|
+
category: z.string().regex(/^[a-z][a-z-]{0,40}$/),
|
|
33
|
+
channel: z.enum(['official', 'community']),
|
|
34
|
+
tags: z.array(z.string().max(60)).max(20),
|
|
35
|
+
requires: z.record(z.string(), z.string().regex(/^\d+\.\d+\.\d+$/)),
|
|
36
|
+
sourceUrl: httpsUrl,
|
|
37
|
+
delivery: hubDeliverySchema
|
|
38
|
+
}).strict()
|
|
39
|
+
|
|
40
|
+
export const hubCatalogSchema = z.object({
|
|
41
|
+
schemaVersion: z.literal(1),
|
|
42
|
+
entries: z.array(hubEntrySchema).max(2000)
|
|
43
|
+
}).strict().superRefine((catalog, ctx) => {
|
|
44
|
+
const keys = new Set<string>()
|
|
45
|
+
catalog.entries.forEach((entry, index) => {
|
|
46
|
+
const key = `${entry.id}@${entry.version}`
|
|
47
|
+
if (keys.has(key)) ctx.addIssue({ code: 'custom', path: ['entries', index, 'id'], message: 'Duplicate catalog item version.' })
|
|
48
|
+
keys.add(key)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const hubReceiptSchema = z.object({
|
|
53
|
+
entry: hubEntrySchema,
|
|
54
|
+
installedAt: z.string(),
|
|
55
|
+
resourceIds: z.array(z.string()),
|
|
56
|
+
document: z.string().nullable()
|
|
57
|
+
}).strict()
|
|
58
|
+
|
|
59
|
+
export const hubOverviewSchema = z.object({
|
|
60
|
+
entries: z.array(hubEntrySchema),
|
|
61
|
+
installed: z.array(hubReceiptSchema),
|
|
62
|
+
origin: z.enum(['bundled', 'cached', 'remote']),
|
|
63
|
+
refreshedAt: z.string().nullable(),
|
|
64
|
+
error: z.string().nullable(),
|
|
65
|
+
dataRoot: z.string().nullable(),
|
|
66
|
+
stateDir: z.string()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export type HubEntry = z.infer<typeof hubEntrySchema>
|
|
70
|
+
export type HubCatalog = z.infer<typeof hubCatalogSchema>
|
|
71
|
+
export type HubReceipt = z.infer<typeof hubReceiptSchema>
|
|
72
|
+
export type HubOverview = z.infer<typeof hubOverviewSchema>
|
|
73
|
+
|
|
74
|
+
export function meetsMinimum(actual: string, minimum: string): boolean {
|
|
75
|
+
const left = actual.split(/[.-]/).slice(0, 3).map(Number)
|
|
76
|
+
const right = minimum.split('.').map(Number)
|
|
77
|
+
for (let index = 0; index < 3; index += 1) {
|
|
78
|
+
if (left[index] !== right[index]) return left[index] > right[index]
|
|
79
|
+
}
|
|
80
|
+
return true
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// providers, a tool policy, a secret store, a directory of its own names, and a description of
|
|
8
8
|
// its own parts as plugins. Everything else is reachable by path, e.g. `@xingular/harness/memory/wiki`.
|
|
9
9
|
export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
|
|
10
|
+
export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './hub/schema'
|
|
10
11
|
|
|
11
12
|
export { Bus, log, type Notice } from './core/bus'
|
|
12
13
|
export { EMPTY_DIRECTORY, type Directory, type HarnessEnv } from './core/env'
|
|
@@ -15,7 +16,7 @@ export { findBinary, searchPath } from './core/path'
|
|
|
15
16
|
|
|
16
17
|
export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from './accounts/secrets'
|
|
17
18
|
export type { Account, AccountsOverview, Pool } from './accounts/store'
|
|
18
|
-
export type { ModelOption } from './accounts/accounts'
|
|
19
|
+
export type { AccountLoginSnapshot, ModelOption } from './accounts/accounts'
|
|
19
20
|
export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
|
|
20
21
|
export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
|
|
21
22
|
|
|
@@ -46,11 +47,15 @@ export type { Effort } from './sessions/runner'
|
|
|
46
47
|
|
|
47
48
|
export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
|
|
48
49
|
export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
|
|
50
|
+
export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
|
|
51
|
+
export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
|
|
52
|
+
export type { CurationJob } from './memory/curation'
|
|
49
53
|
export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
|
|
54
|
+
export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, type RegistryToolOptions } from './tools/registryWrite'
|
|
50
55
|
export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
|
|
51
56
|
|
|
52
57
|
export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
|
|
53
|
-
export type { Connection, ConnectionInput } from './mcp/types'
|
|
58
|
+
export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
|
|
54
59
|
|
|
55
60
|
export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
|
|
56
61
|
|
|
@@ -68,3 +73,8 @@ export { methods } from './server/methods'
|
|
|
68
73
|
export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|
|
69
74
|
export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
|
|
70
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
|
|
package/src/memory/api.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { findRun, readEvents } from '../sessions/runStore'
|
|
|
11
11
|
import type { RunEvent } from '../sessions/contracts'
|
|
12
12
|
import { takeChars } from '../core/text'
|
|
13
13
|
import * as registry from '../skills/registry'
|
|
14
|
+
import { enqueueCuration } from './curation'
|
|
14
15
|
import * as blocks from './blocks'
|
|
15
16
|
import { actorOf, blockCaller, projectOf, type CallerContext } from './caller'
|
|
16
17
|
import * as facts from './facts'
|
|
@@ -104,7 +105,7 @@ export function search(env: HarnessEnv, context: CallerContext, params: Params):
|
|
|
104
105
|
// Another agent's blocks never appear; a block of the caller's own does.
|
|
105
106
|
const own = ownBlocksPrefix(env, context)
|
|
106
107
|
const hits = searchEntries(root, query, { kinds, namespace: textParam(params, 'namespace') || null, projectId: context.projectId, limit: limit + 6 })
|
|
107
|
-
.filter((hit) => !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
|
|
108
|
+
.filter((hit) => context.vaultAccess === 'curator' || !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
|
|
108
109
|
.slice(0, limit)
|
|
109
110
|
if (!hits.length) return 'No matching Wiki entries.'
|
|
110
111
|
const wiki = store.wikiDir(root)
|
|
@@ -126,7 +127,7 @@ export function read(env: HarnessEnv, context: CallerContext, params: Params): s
|
|
|
126
127
|
// An agent's own blocks are not readable by other agents or by sessions.
|
|
127
128
|
if (entry.kind === 'block' && entry.block.scope === 'agent') {
|
|
128
129
|
const own = ownBlocksPrefix(env, context)
|
|
129
|
-
if (own === null || `blocks/agents/${entry.block.owner}/` !== own) throw notFound('Wiki entry')
|
|
130
|
+
if (context.vaultAccess !== 'curator' && (own === null || `blocks/agents/${entry.block.owner}/` !== own)) throw notFound('Wiki entry')
|
|
130
131
|
}
|
|
131
132
|
const linking = backlinks(root, entryId(entry))
|
|
132
133
|
const linked = linking.length ? linking.map((link) => `${link.fromId} (${link.type})`).join(', ') : 'none'
|
|
@@ -272,13 +273,26 @@ export function propose(env: HarnessEnv, context: CallerContext, params: Params)
|
|
|
272
273
|
proposals = {
|
|
273
274
|
memories: [{ id: optional(params, 'id'), title, kind: optional(params, 'type') ?? 'fact', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), supersedes: optional(params, 'supersedes'), volatile: typeof params?.volatile === 'boolean' ? params.volatile : null, sourceRefs: sourceRefs(params, context), links: links(params) }]
|
|
274
275
|
}
|
|
276
|
+
} else if (kind === 'block') {
|
|
277
|
+
const id = textParam(params, 'id')
|
|
278
|
+
const block = blocks.find(wiki, id)
|
|
279
|
+
if (block.front.readOnly) throw invalid('This block is user-only; only the user can change it.')
|
|
280
|
+
proposals = { blocks: [{ block: id, body, sourceRefs: sourceRefs(params, context) }] }
|
|
275
281
|
} else if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
|
|
282
|
+
if (context.vaultAccess) {
|
|
283
|
+
const job = enqueueCuration(root, { kind: 'registry', proposal: { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') }, caller: context })
|
|
284
|
+
return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
|
|
285
|
+
}
|
|
276
286
|
const id = parkRegistryProposal(wiki, trigger, { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') })
|
|
277
287
|
return `The ${kind} change (${id}) is waiting in the Wiki Inbox. Skills, prompts, and rules change only after the user accepts them.`
|
|
278
288
|
} else {
|
|
279
289
|
throw invalid('kind must be page, fact, skill, rule, or prompt.')
|
|
280
290
|
}
|
|
281
291
|
// The caller's own run is evidence only for what it can quote from it.
|
|
292
|
+
if (context.vaultAccess) {
|
|
293
|
+
const job = enqueueCuration(root, { kind: 'proposal', namespace: defaultNamespace, proposals: parseProposalSet(proposals), caller: context })
|
|
294
|
+
return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
|
|
295
|
+
}
|
|
282
296
|
const outcome = apply(wiki, defaultNamespace, trigger, parseProposalSet(proposals), captureHashes(wiki), { runText: context.runId ? evidenceText(root, context.runId) : null })
|
|
283
297
|
let text = outcome.notes.join('\n')
|
|
284
298
|
if (outcome.operation) {
|
package/src/memory/blocks.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface Block {
|
|
|
52
52
|
export type Caller =
|
|
53
53
|
| { kind: 'user' }
|
|
54
54
|
/** An agent with blocks of its own, and the name recorded on its revisions. */
|
|
55
|
-
| { kind: 'agent'; id: string; name: string }
|
|
55
|
+
| { kind: 'agent'; id: string; name: string; vaultAccess?: 'self' | 'propose' | 'curator' }
|
|
56
56
|
/** A session without blocks of its own, and its project, if any. */
|
|
57
57
|
| { kind: 'session'; projectSlug: string | null }
|
|
58
58
|
|
|
@@ -192,6 +192,8 @@ export function editRefusal(caller: Caller, block: Block): string | null {
|
|
|
192
192
|
const label = block.front.label
|
|
193
193
|
if (caller.kind === 'user') return null
|
|
194
194
|
if (block.front.readOnly) return `The ${label} block is read-only; only the user can change it.`
|
|
195
|
+
if (caller.kind === 'agent' && caller.vaultAccess === 'propose') return 'Memory changes are proposals in this session. Submit the block change with wiki_propose; the next memory merge applies it.'
|
|
196
|
+
if (caller.kind === 'agent' && caller.vaultAccess === 'self' && (block.scope !== 'agent' || block.owner !== caller.id || !['persona', 'notes'].includes(label))) return 'This field is outside your writable memory. Submit a block change with wiki_propose for the curator to review.'
|
|
195
197
|
if (caller.kind === 'agent') {
|
|
196
198
|
if (block.scope === 'agent') return block.owner === caller.id ? null : 'That block belongs to another agent.'
|
|
197
199
|
if (block.scope === 'shared') return !block.front.agents.length || block.front.agents.includes(caller.id) ? null : `The ${label} block is not attached to you.`
|
package/src/memory/caller.ts
CHANGED
|
@@ -10,11 +10,23 @@ import * as blocks from './blocks'
|
|
|
10
10
|
* never limits what can be read.
|
|
11
11
|
*/
|
|
12
12
|
export interface CallerContext {
|
|
13
|
+
/** Opaque, host-owned policy data frozen when this session starts. The harness only forwards it to the embedding app's tool policy. */
|
|
14
|
+
policyContext?: unknown
|
|
15
|
+
/**
|
|
16
|
+
* Trusted, frozen vault mutation capability supplied by an embedding host. `self` writes its own
|
|
17
|
+
* persona and notes directly and proposes the rest; `propose` writes nothing directly; `curator`
|
|
18
|
+
* applies proposals.
|
|
19
|
+
*/
|
|
20
|
+
vaultAccess?: 'self' | 'propose' | 'curator'
|
|
21
|
+
maintenanceJobId?: string | null
|
|
22
|
+
maintenanceJobIds?: string[]
|
|
13
23
|
runId?: string | null
|
|
14
24
|
sessionId?: string | null
|
|
15
25
|
projectId?: string | null
|
|
16
26
|
/** The agent calling; it decides which memory blocks are attached and editable. */
|
|
17
27
|
agentId?: string | null
|
|
28
|
+
/** Agents whose conversations this caller may not recall (opaque ids from the embedding host). */
|
|
29
|
+
recallExcludeAgents?: string[] | null
|
|
18
30
|
/** The agent's name, recorded on the revisions it makes. */
|
|
19
31
|
agentName?: string | null
|
|
20
32
|
/** Projects whose briefs the agent carries, most recently active first. */
|
|
@@ -42,7 +54,7 @@ export function projectOf(root: string, context: CallerContext): Project | null
|
|
|
42
54
|
export function blockCaller(env: HarnessEnv, context: CallerContext): blocks.Caller {
|
|
43
55
|
if (isSession(context)) return { kind: 'session', projectSlug: projectOf(env.root, context)?.slug ?? null }
|
|
44
56
|
if (!context.agentId) throw invalid('Memory blocks need an agent or a Harness session.')
|
|
45
|
-
return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId }
|
|
57
|
+
return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId, vaultAccess: context.vaultAccess === 'propose' ? 'propose' : context.vaultAccess ? 'self' : undefined }
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/** The blocks in the caller's context: an agent also carries the briefs of its active projects. */
|