@oh-my-pi/pi-coding-agent 16.1.22 → 16.2.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 +99 -1
- package/dist/cli.js +4387 -4164
- package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
- package/dist/types/advisor/emission-guard.d.ts +73 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +7 -0
- package/dist/types/advisor/watchdog.d.ts +2 -0
- package/dist/types/cli/flag-tables.d.ts +1 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
- package/dist/types/cli/gc-cli.d.ts +58 -0
- package/dist/types/collab/display-name.d.ts +3 -0
- package/dist/types/collab/host.d.ts +0 -2
- package/dist/types/commands/gc.d.ts +37 -0
- package/dist/types/commands/worktree.d.ts +0 -3
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +6 -2
- package/dist/types/config/model-resolver.d.ts +12 -0
- package/dist/types/config/models-config-schema.d.ts +29 -2
- package/dist/types/config/models-config.d.ts +22 -1
- package/dist/types/config/settings-schema.d.ts +145 -21
- package/dist/types/config/settings.d.ts +13 -0
- package/dist/types/dap/config.d.ts +1 -1
- package/dist/types/edit/hashline/filesystem.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -17
- package/dist/types/extensibility/hooks/types.d.ts +10 -10
- package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
- package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
- package/dist/types/internal-urls/router.d.ts +1 -1
- package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
- package/dist/types/internal-urls/types.d.ts +19 -2
- package/dist/types/irc/bus.d.ts +6 -0
- package/dist/types/mcp/transports/stdio.d.ts +25 -1
- package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +7 -0
- package/dist/types/modes/components/move-overlay.d.ts +23 -0
- package/dist/types/modes/components/plugin-selector.d.ts +2 -1
- package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
- package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
- package/dist/types/modes/components/settings-defs.d.ts +4 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/show-images-selector.d.ts +2 -1
- package/dist/types/modes/components/status-line/component.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/theme-selector.d.ts +2 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/modes/controllers/command-controller.d.ts +10 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -1
- package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
- package/dist/types/modes/running-subagent-badge.d.ts +6 -0
- package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +9 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +2 -1
- package/dist/types/session/session-listing.d.ts +10 -1
- package/dist/types/session/session-manager.d.ts +13 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +2 -0
- package/dist/types/ssh/file-transfer.d.ts +79 -0
- package/dist/types/ssh/utils.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +5 -0
- package/dist/types/task/executor.d.ts +16 -0
- package/dist/types/tiny/text.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +5 -1
- package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
- package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/tools/path-utils.d.ts +29 -0
- package/dist/types/tools/plan-mode-guard.d.ts +7 -0
- package/dist/types/tools/read.d.ts +2 -2
- package/dist/types/tools/render-utils.d.ts +8 -0
- package/dist/types/tools/renderers.d.ts +11 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo.d.ts +0 -16
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/utils/active-repo-context.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/utils/markit-cache.d.ts +23 -0
- package/dist/types/utils/markit.d.ts +5 -1
- package/dist/types/utils/prompt-path.d.ts +1 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
- package/dist/types/web/search/providers/xai.d.ts +13 -0
- package/dist/types/web/search/types.d.ts +18 -2
- package/package.json +30 -15
- package/scripts/bench-guard.ts +1 -1
- package/scripts/build-binary.ts +9 -9
- package/scripts/bundle-dist.ts +2 -2
- package/src/advisor/__tests__/advisor.test.ts +40 -4
- package/src/advisor/__tests__/emission-guard.test.ts +147 -0
- package/src/advisor/advise-tool.ts +1 -1
- package/src/advisor/emission-guard.ts +172 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +11 -0
- package/src/advisor/watchdog.ts +12 -1
- package/src/cli/args.ts +5 -2
- package/src/cli/auth-broker-cli.ts +17 -0
- package/src/cli/config-cli.ts +4 -0
- package/src/cli/flag-tables.ts +7 -4
- package/src/cli/gallery-cli.ts +14 -2
- package/src/cli/gallery-fixtures/edit.ts +60 -0
- package/src/cli/gallery-fixtures/fs.ts +17 -17
- package/src/cli/gallery-fixtures/search.ts +4 -4
- package/src/cli/gc-cli.ts +939 -0
- package/src/cli/usage-cli.ts +20 -1
- package/src/cli/web-search-cli.ts +1 -1
- package/src/cli-commands.ts +1 -0
- package/src/collab/display-name.ts +13 -0
- package/src/collab/guest.ts +5 -1
- package/src/collab/host.ts +2 -13
- package/src/commands/gc.ts +46 -0
- package/src/commands/worktree.ts +6 -0
- package/src/config/inline-tool-descriptors-mode.ts +27 -0
- package/src/config/model-discovery.ts +77 -8
- package/src/config/model-registry.ts +89 -11
- package/src/config/model-resolver.ts +39 -1
- package/src/config/models-config-schema.ts +41 -5
- package/src/config/models-config.ts +4 -1
- package/src/config/settings-schema.ts +130 -27
- package/src/config/settings.ts +216 -7
- package/src/cursor.ts +1 -1
- package/src/dap/config.ts +152 -8
- package/src/dap/session.ts +1 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/edit/hashline/diff.ts +14 -3
- package/src/edit/hashline/execute.ts +42 -6
- package/src/edit/hashline/filesystem.ts +49 -8
- package/src/edit/index.ts +1 -0
- package/src/edit/modes/patch.ts +11 -1
- package/src/edit/renderer.ts +140 -13
- package/src/eval/__tests__/agent-bridge.test.ts +101 -0
- package/src/eval/__tests__/julia-prelude.test.ts +18 -0
- package/src/eval/agent-bridge.ts +26 -3
- package/src/eval/jl/runner.jl +7 -1
- package/src/eval/js/tool-bridge.ts +2 -2
- package/src/export/html/index.ts +1 -1
- package/src/export/html/template.js +3 -1
- package/src/export/html/tool-views.generated.js +20 -20
- package/src/extensibility/extensions/types.ts +22 -22
- package/src/extensibility/hooks/types.ts +11 -11
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +10 -3
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +31 -10
- package/src/extensibility/plugins/marketplace/manager.ts +22 -0
- package/src/extensibility/plugins/marketplace/types.ts +1 -0
- package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/internal-urls/docs-index.ts +2 -3
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/registry-helpers.ts +19 -4
- package/src/internal-urls/router.ts +5 -3
- package/src/internal-urls/ssh-protocol.ts +367 -0
- package/src/internal-urls/types.ts +19 -2
- package/src/irc/bus.ts +11 -3
- package/src/lsp/index.ts +8 -1
- package/src/mcp/oauth-discovery.ts +20 -20
- package/src/mcp/tool-bridge.ts +32 -2
- package/src/mcp/transports/stdio.test.ts +20 -3
- package/src/mcp/transports/stdio.ts +45 -14
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +2 -2
- package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
- package/src/modes/components/agent-hub.ts +3 -0
- package/src/modes/components/agent-transcript-viewer.ts +9 -7
- package/src/modes/components/custom-editor.ts +90 -5
- package/src/modes/components/move-overlay.ts +282 -0
- package/src/modes/components/plan-review-overlay.ts +35 -35
- package/src/modes/components/plugin-selector.ts +6 -1
- package/src/modes/components/queue-mode-selector.ts +6 -1
- package/src/modes/components/select-list-mouse-routing.ts +35 -0
- package/src/modes/components/session-selector.ts +11 -10
- package/src/modes/components/settings-defs.ts +14 -1
- package/src/modes/components/settings-selector.ts +196 -29
- package/src/modes/components/show-images-selector.ts +6 -1
- package/src/modes/components/status-line/component.ts +108 -28
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/theme-selector.ts +6 -1
- package/src/modes/components/thinking-selector.ts +6 -1
- package/src/modes/components/tool-execution.ts +25 -9
- package/src/modes/components/tree-selector.ts +5 -5
- package/src/modes/controllers/command-controller.ts +140 -47
- package/src/modes/controllers/event-controller.ts +59 -3
- package/src/modes/controllers/input-controller.ts +70 -4
- package/src/modes/controllers/selector-controller.ts +15 -2
- package/src/modes/controllers/streaming-reveal.ts +17 -0
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +142 -59
- package/src/modes/internal-url-autocomplete.ts +17 -2
- package/src/modes/prompt-action-autocomplete.ts +3 -1
- package/src/modes/running-subagent-badge.ts +13 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
- package/src/modes/setup-wizard/scenes/providers.ts +2 -1
- package/src/modes/setup-wizard/scenes/theme.ts +6 -12
- package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
- package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
- package/src/modes/theme/mermaid-rendering.test.ts +53 -0
- package/src/modes/theme/theme.ts +42 -15
- package/src/modes/types.ts +9 -1
- package/src/modes/utils/interactive-context-helpers.ts +1 -1
- package/src/priority.json +15 -0
- package/src/prompts/advisor/active-repo-watchdog.md +6 -0
- package/src/prompts/advisor/system.md +21 -7
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/active-repo-context.md +4 -0
- package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
- package/src/prompts/system/plan-mode-active.md +12 -3
- package/src/prompts/system/project-prompt.md +2 -2
- package/src/prompts/system/system-prompt.md +8 -6
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/{find.md → glob.md} +1 -1
- package/src/prompts/tools/{search.md → grep.md} +3 -3
- package/src/prompts/tools/read.md +4 -2
- package/src/sdk.ts +98 -29
- package/src/session/agent-session.ts +828 -191
- package/src/session/session-history-format.ts +2 -2
- package/src/session/session-listing.ts +35 -2
- package/src/session/session-manager.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +158 -22
- package/src/slash-commands/helpers/usage-report.ts +23 -2
- package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
- package/src/ssh/connection-manager.ts +12 -15
- package/src/ssh/file-transfer.ts +209 -0
- package/src/ssh/utils.ts +24 -0
- package/src/system-prompt.ts +60 -28
- package/src/task/executor.ts +96 -36
- package/src/task/index.ts +3 -3
- package/src/task/render.ts +14 -13
- package/src/task/worktree.ts +38 -1
- package/src/tiny/text.ts +49 -4
- package/src/tiny/worker.ts +3 -3
- package/src/tools/acp-bridge.ts +6 -1
- package/src/tools/bash.ts +2 -2
- package/src/tools/builtin-names.ts +26 -2
- package/src/tools/{find.ts → glob.ts} +48 -42
- package/src/tools/{search.ts → grep.ts} +311 -129
- package/src/tools/index.ts +13 -14
- package/src/tools/irc.ts +6 -2
- package/src/tools/path-utils.ts +64 -1
- package/src/tools/plan-mode-guard.ts +26 -13
- package/src/tools/read.ts +35 -6
- package/src/tools/render-utils.ts +14 -0
- package/src/tools/renderers.ts +15 -4
- package/src/tools/ssh.ts +17 -2
- package/src/tools/todo.ts +4 -26
- package/src/tools/write.ts +25 -9
- package/src/utils/active-repo-context.ts +143 -0
- package/src/utils/edit-mode.ts +19 -2
- package/src/utils/image-resize.ts +88 -7
- package/src/utils/lang-from-path.ts +3 -3
- package/src/utils/markit-cache.ts +166 -0
- package/src/utils/markit.ts +86 -18
- package/src/utils/prompt-path.ts +3 -0
- package/src/utils/shell-snapshot.ts +1 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/provider.ts +54 -34
- package/src/web/search/providers/duckduckgo.ts +140 -0
- package/src/web/search/providers/firecrawl.ts +144 -0
- package/src/web/search/providers/tinyfish.ts +159 -0
- package/src/web/search/providers/xai.ts +292 -0
- package/src/web/search/providers/zai.ts +142 -56
- package/src/web/search/types.ts +6 -2
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Harness documentation index for the `omp://` protocol.
|
|
3
3
|
*
|
|
4
4
|
* Compiled binaries and the prepacked npm bundle inline a compressed index from
|
|
5
|
-
* `docs-index.generated.txt` (populated by `
|
|
6
|
-
* --generate` at build time). The format is two lines:
|
|
5
|
+
* `docs-index.generated.txt` (populated by `gen:docs` at build time). The format is two lines:
|
|
7
6
|
* 1. a plain JSON array of the sorted doc file names, and
|
|
8
7
|
* 2. a base64 gzip blob of the index-aligned doc bodies (`string[]`).
|
|
9
8
|
* Listing/completion (`getDocFilenames`) parses only the small first line and
|
|
@@ -84,7 +83,7 @@ function getIndex(): DocsIndex {
|
|
|
84
83
|
if (decoded === null) {
|
|
85
84
|
throw new Error(
|
|
86
85
|
"Malformed embedded docs index (docs-index.generated.txt): non-empty payload without a newline separator. " +
|
|
87
|
-
"Rebuild with `bun
|
|
86
|
+
"Rebuild with `bun run gen:docs`.",
|
|
88
87
|
);
|
|
89
88
|
}
|
|
90
89
|
index = decoded;
|
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
6
6
|
|
|
7
|
+
const extraArtifactsDirs = new Set<string>();
|
|
8
|
+
|
|
9
|
+
export function registerArtifactsDir(dir: string): () => void {
|
|
10
|
+
extraArtifactsDirs.add(dir);
|
|
11
|
+
return () => {
|
|
12
|
+
extraArtifactsDirs.delete(dir);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resetRegisteredArtifactDirsForTests(): void {
|
|
17
|
+
extraArtifactsDirs.clear();
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* Snapshot of artifacts dirs for every registered session, deduped.
|
|
9
22
|
*
|
|
@@ -15,11 +28,13 @@ import { AgentRegistry } from "../registry/agent-registry";
|
|
|
15
28
|
*/
|
|
16
29
|
export function artifactsDirsFromRegistry(): string[] {
|
|
17
30
|
const dirs: string[] = [];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
|
|
21
|
-
if (!dir) continue;
|
|
31
|
+
const addDir = (dir: string | null | undefined) => {
|
|
32
|
+
if (!dir) return;
|
|
22
33
|
if (!dirs.includes(dir)) dirs.push(dir);
|
|
34
|
+
};
|
|
35
|
+
for (const ref of AgentRegistry.global().list()) {
|
|
36
|
+
addDir(ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null));
|
|
23
37
|
}
|
|
38
|
+
for (const dir of extraArtifactsDirs) addDir(dir);
|
|
24
39
|
return dirs;
|
|
25
40
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
|
|
2
|
+
* Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, `ssh://`, and `vault://`).
|
|
3
3
|
*
|
|
4
4
|
* One process-global router with one handler per scheme. Access via
|
|
5
5
|
* `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
|
|
@@ -16,6 +16,7 @@ import { OmpProtocolHandler } from "./omp-protocol";
|
|
|
16
16
|
import { parseInternalUrl } from "./parse";
|
|
17
17
|
import { RuleProtocolHandler } from "./rule-protocol";
|
|
18
18
|
import { SkillProtocolHandler } from "./skill-protocol";
|
|
19
|
+
import { SshProtocolHandler } from "./ssh-protocol";
|
|
19
20
|
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
|
|
20
21
|
import { VaultProtocolHandler } from "./vault-protocol";
|
|
21
22
|
|
|
@@ -37,6 +38,7 @@ export class InternalUrlRouter {
|
|
|
37
38
|
this.register(new IssueProtocolHandler());
|
|
38
39
|
this.register(new PrProtocolHandler());
|
|
39
40
|
this.register(new HistoryProtocolHandler());
|
|
41
|
+
this.register(new SshProtocolHandler());
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** Process-global router instance. */
|
|
@@ -81,10 +83,10 @@ export class InternalUrlRouter {
|
|
|
81
83
|
* Candidate completions for the host/path portion of `scheme://<query>`.
|
|
82
84
|
* Returns `null` when the scheme is unknown or does not support completion.
|
|
83
85
|
*/
|
|
84
|
-
async complete(scheme: string, query: string): Promise<UrlCompletion[] | null> {
|
|
86
|
+
async complete(scheme: string, query: string, context?: ResolveContext): Promise<UrlCompletion[] | null> {
|
|
85
87
|
const handler = this.#handlers.get(scheme.toLowerCase());
|
|
86
88
|
if (!handler?.complete) return null;
|
|
87
|
-
return handler.complete(query);
|
|
89
|
+
return handler.complete(query, context);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
async resolve(input: string, context?: ResolveContext): Promise<InternalResource> {
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol handler for `ssh://host/path` URLs.
|
|
3
|
+
*
|
|
4
|
+
* Resolves a remote text file or directory listing on a pre-configured SSH host — or any
|
|
5
|
+
* destination OpenSSH can resolve itself (e.g. a `~/.ssh/config` alias) — for
|
|
6
|
+
* the read, search, and write tools, reusing the shared ControlMaster
|
|
7
|
+
* connection in `../ssh/connection-manager`.
|
|
8
|
+
*
|
|
9
|
+
* A remote path resolves to a UTF-8 text file (≤ 1 MiB) or, when it is a
|
|
10
|
+
* directory, a one-level listing. Binary/non-UTF-8 or oversized files are
|
|
11
|
+
* rejected with an explicit error. This handler exposes no `sourcePath`;
|
|
12
|
+
* directory listings carry `isDirectory` so `search` refuses to grep the
|
|
13
|
+
* listing text instead of the directory's real contents.
|
|
14
|
+
*
|
|
15
|
+
* `loadCapability` is imported from `../capability` (not the `../discovery`
|
|
16
|
+
* barrel) on purpose — pulling the barrel here would route
|
|
17
|
+
* `path-utils -> internal-urls -> ssh-protocol -> discovery -> path-utils` and
|
|
18
|
+
* eager-load every provider on any `path-utils` import. Runtime bootstraps the
|
|
19
|
+
* SSH provider via `import "./discovery"` (sdk.ts) / `initializeWithSettings`
|
|
20
|
+
* (main.ts) before any tool resolves.
|
|
21
|
+
*/
|
|
22
|
+
import * as capability from "../capability";
|
|
23
|
+
import { type SSHHost, sshCapability } from "../capability/ssh";
|
|
24
|
+
import type { SSHConnectionTarget } from "../ssh/connection-manager";
|
|
25
|
+
import {
|
|
26
|
+
listRemoteDir,
|
|
27
|
+
type RemoteDirEntry,
|
|
28
|
+
type RemotePathKind,
|
|
29
|
+
readRemoteFile,
|
|
30
|
+
statRemotePath,
|
|
31
|
+
writeRemoteFile,
|
|
32
|
+
} from "../ssh/file-transfer";
|
|
33
|
+
import type {
|
|
34
|
+
InternalResource,
|
|
35
|
+
InternalUrl,
|
|
36
|
+
ProtocolHandler,
|
|
37
|
+
ResolveContext,
|
|
38
|
+
UrlCompletion,
|
|
39
|
+
WriteContext,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
/** Largest remote text file `ssh://` will materialize (mirrors the local:// cap). */
|
|
43
|
+
const SSH_TEXT_MAX_BYTES = 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** POSIX-aware content type from the last path segment's extension. */
|
|
46
|
+
function contentTypeFor(remotePath: string): InternalResource["contentType"] {
|
|
47
|
+
const slash = remotePath.lastIndexOf("/");
|
|
48
|
+
const base = slash === -1 ? remotePath : remotePath.slice(slash + 1);
|
|
49
|
+
const dot = base.lastIndexOf(".");
|
|
50
|
+
const ext = dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
51
|
+
if (ext === ".md") return "text/markdown";
|
|
52
|
+
if (ext === ".json") return "application/json";
|
|
53
|
+
return "text/plain";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode the whole buffer as UTF-8 text, or null if it holds a NUL or invalid byte. */
|
|
57
|
+
function decodeUtf8Text(bytes: Uint8Array): string | null {
|
|
58
|
+
if (bytes.indexOf(0) !== -1) return null;
|
|
59
|
+
try {
|
|
60
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remote absolute path from the URL. Uses `rawPathname` (pre-normalization) so
|
|
68
|
+
* `..`/`//` and percent-escapes survive verbatim to the remote shell; the
|
|
69
|
+
* authority (host/user/port) stays on the WHATWG fields, which preserve case for
|
|
70
|
+
* the non-special `ssh` scheme.
|
|
71
|
+
*/
|
|
72
|
+
function remotePathFromUrl(url: InternalUrl): string {
|
|
73
|
+
// `?`/`#` are URL delimiters, so parseInternalUrl strips them from the path
|
|
74
|
+
// (`ssh://h/tmp/a?draft` → `/tmp/a`). Reject the unsupported suffix instead of
|
|
75
|
+
// silently operating on the truncated path; a literal `?`/`#` in a filename
|
|
76
|
+
// must be percent-encoded (`%3F`/`%23`).
|
|
77
|
+
if (url.search) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`ssh:// does not support URL query strings; percent-encode a literal '?' as %3F in the path: ${url.href}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (url.hash) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`ssh:// does not support URL fragments; percent-encode a literal '#' as %23 in the path: ${url.href}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const raw = url.rawPathname ?? url.pathname;
|
|
88
|
+
let decoded: string;
|
|
89
|
+
try {
|
|
90
|
+
decoded = decodeURIComponent(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`Invalid URL encoding in ssh:// path: ${url.href}`);
|
|
93
|
+
}
|
|
94
|
+
if (!decoded) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"ssh:// requires an absolute path, e.g. ssh://host/etc/hosts or ssh://host/ for the root directory",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return decoded;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load the configured SSH hosts from the `ssh` capability (managed/project `ssh.json`). */
|
|
103
|
+
async function loadConfiguredHosts(cwd?: string): Promise<SSHHost[]> {
|
|
104
|
+
const { items } = await capability.loadCapability<SSHHost>(sshCapability.id, cwd ? { cwd } : {});
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One-line address for a host, e.g. `deploy@10.0.0.1:2222`. */
|
|
109
|
+
function hostAddress(host: SSHHost): string {
|
|
110
|
+
return `${host.username ? `${host.username}@` : ""}${host.host}${host.port ? `:${host.port}` : ""}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render the configured-host index for a bare `ssh://` read (markdown with per-host links). */
|
|
114
|
+
function formatHostIndex(hosts: readonly SSHHost[]): string {
|
|
115
|
+
if (hosts.length === 0) {
|
|
116
|
+
return "# SSH hosts\n\nNo SSH hosts are configured. Add hosts to an `ssh.json` capability file, or read `ssh://<host>/<path>` with any destination OpenSSH can resolve (e.g. a `~/.ssh/config` alias).\n";
|
|
117
|
+
}
|
|
118
|
+
const lines = hosts.map(host => {
|
|
119
|
+
const addr = hostAddress(host);
|
|
120
|
+
const suffix = addr === host.name ? "" : ` — \`${addr}\``;
|
|
121
|
+
const desc = host.description ? ` (${host.description})` : "";
|
|
122
|
+
return `- [${host.name}](ssh://${encodeURIComponent(host.name)}/)${suffix}${desc}`;
|
|
123
|
+
});
|
|
124
|
+
return `# SSH hosts\n\n${hosts.length} configured host${hosts.length === 1 ? "" : "s"}:\n\n${lines.join("\n")}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the URL authority to an SSH connection target. With no explicit
|
|
129
|
+
* user/port, the full DECODED authority (`url.rawHost`) is matched against a
|
|
130
|
+
* configured host name, so percent-encoded reserved-char aliases (e.g.
|
|
131
|
+
* `alice%40prod` → `alice@prod`) resolve correctly. A literal `user@`/`:port`
|
|
132
|
+
* in the URL is an override: it is rejected on a configured bare name (the
|
|
133
|
+
* ControlMaster/host-info caches key on `name` alone) and otherwise treated as
|
|
134
|
+
* an opaque OpenSSH destination so plain `~/.ssh/config` aliases work.
|
|
135
|
+
*/
|
|
136
|
+
async function resolveTarget(url: InternalUrl, cwd?: string): Promise<SSHConnectionTarget> {
|
|
137
|
+
// `parseInternalUrl` falls back to a lenient regex parse when WHATWG `new URL`
|
|
138
|
+
// rejects the input. For ssh:// that only happens on a malformed authority — an
|
|
139
|
+
// invalid or out-of-range port (`prod:abc`, `host:65536`) or a bad IPv6 literal —
|
|
140
|
+
// which would otherwise be mis-read as an opaque host and silently connect to the
|
|
141
|
+
// default port. Reject it before resolving.
|
|
142
|
+
if (!URL.canParse(url.href)) {
|
|
143
|
+
throw new Error(`ssh://: invalid host or port in "${url.href}"; use ssh://host[:1-65535]/<absolute-path>`);
|
|
144
|
+
}
|
|
145
|
+
// WHATWG `hostname` is bracketed only for a *valid* IPv6 literal, so a bracketed
|
|
146
|
+
// host is unambiguously IPv6 — hand OpenSSH the bare address. Percent-encoded
|
|
147
|
+
// bracketed aliases (e.g. `%5Bprod%3A2222%5D`) keep their literal brackets in the
|
|
148
|
+
// decoded `rawHost`, so they are matched and forwarded verbatim, never stripped.
|
|
149
|
+
const bareHost = url.hostname;
|
|
150
|
+
const rawAuthority = url.rawHost || bareHost;
|
|
151
|
+
if (!bareHost && !rawAuthority) {
|
|
152
|
+
throw new Error("ssh:// requires a host: ssh://<host>/<absolute-path>");
|
|
153
|
+
}
|
|
154
|
+
// `decodeOr` fails open, so a malformed percent-escape (`%ZZ`) in the authority
|
|
155
|
+
// would otherwise pass the canonical check below and reach OpenSSH literally.
|
|
156
|
+
// Reject it up front — the path decoder fails closed for the same bad escapes.
|
|
157
|
+
for (const part of [url.username, bareHost]) {
|
|
158
|
+
if (part.includes("%")) {
|
|
159
|
+
try {
|
|
160
|
+
decodeURIComponent(part);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new Error(`ssh://: invalid percent-escape in authority "${url.href}"`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (url.password) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"ssh://: password authentication is not supported; ssh:// uses key/agent auth — drop the ':<password>' from the URL",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const isIpv6Literal = bareHost.startsWith("[") && bareHost.endsWith("]");
|
|
172
|
+
const sshHost = isIpv6Literal ? bareHost.slice(1, -1) : bareHost;
|
|
173
|
+
const username = url.username || undefined;
|
|
174
|
+
const port = url.port ? Number(url.port) : undefined;
|
|
175
|
+
if (port === 0) {
|
|
176
|
+
throw new Error("ssh://: port 0 is not a valid SSH port; use ssh://host:<1-65535>/<path> or omit the port");
|
|
177
|
+
}
|
|
178
|
+
// An empty port (`ssh://prod:/path`, `ssh://user@host:/path`, including
|
|
179
|
+
// percent-encoded authority parts) parses cleanly with `url.port === ""`, so it
|
|
180
|
+
// slips past the malformed-authority guard and would be read as "no port" —
|
|
181
|
+
// silently using the default/configured target. `url.rawHost` is the decoded
|
|
182
|
+
// authority and uniquely retains the trailing `:`; comparing it to the decoded
|
|
183
|
+
// host (+ user) catches the empty port, while a percent-encoded alias like
|
|
184
|
+
// `prod%3A` (whose decoded host already ends in `:`) reconstructs to `prod::`
|
|
185
|
+
// and is left alone.
|
|
186
|
+
const decodeOr = (s: string): string => {
|
|
187
|
+
try {
|
|
188
|
+
return decodeURIComponent(s);
|
|
189
|
+
} catch {
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
if (port === undefined && url.rawHost === `${username ? `${decodeOr(username)}@` : ""}${decodeOr(bareHost)}:`) {
|
|
194
|
+
throw new Error(`ssh://: empty port in "${url.href}"; use ssh://host:<1-65535>/<path> or drop the colon`);
|
|
195
|
+
}
|
|
196
|
+
// A literal but empty userinfo (`ssh://@host`) sets username to "" — WHATWG drops
|
|
197
|
+
// the `@` from hostname, but rawHost keeps the leading `@`. A percent-encoded
|
|
198
|
+
// alias like `%40prod` decodes to `@prod` in rawHost too, but its hostname keeps
|
|
199
|
+
// `%40`, so the reconstruction is `@@prod` and is left alone.
|
|
200
|
+
if (username === undefined && url.rawHost === `@${decodeOr(bareHost)}${port !== undefined ? `:${port}` : ""}`) {
|
|
201
|
+
throw new Error(`ssh://: empty username in "${url.href}"; drop the leading '@' or provide a username before it`);
|
|
202
|
+
}
|
|
203
|
+
// Backstop for any remaining stray/empty authority marker the explicit checks
|
|
204
|
+
// above do not name — notably an empty password (`ssh://user:@host`, `ssh://:@host`,
|
|
205
|
+
// where `url.password === ""`). `rawHost` keeps the literal marker, so it differs
|
|
206
|
+
// from the canonical decoded `[user@]host[:port]` WHATWG actually parsed. Every
|
|
207
|
+
// valid authority — including percent-encoded reserved-char aliases — reconstructs
|
|
208
|
+
// to exactly `rawHost`, so only malformed userinfo trips this.
|
|
209
|
+
const canonicalAuthority = `${url.username ? `${decodeOr(url.username)}@` : ""}${decodeOr(bareHost)}${port !== undefined ? `:${port}` : ""}`;
|
|
210
|
+
if (url.rawHost !== canonicalAuthority) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`ssh://: unsupported or malformed authority in "${url.href}"; use ssh://[user@]host[:1-65535]/<absolute-path>`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const items = await loadConfiguredHosts(cwd);
|
|
216
|
+
|
|
217
|
+
// A literal user/port in the URL is an authority override. A configured alias
|
|
218
|
+
// is addressed only by its (percent-encoded) name, never with a separate
|
|
219
|
+
// user/port — so reject an override on a configured bare name, else opaque.
|
|
220
|
+
if (username || port !== undefined) {
|
|
221
|
+
const decodedBareHost = decodeOr(bareHost);
|
|
222
|
+
if (items.some(entry => entry.name === bareHost || entry.name === decodedBareHost)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`ssh://: user/port overrides are not allowed for the configured host "${decodedBareHost}"; use ssh://${bareHost}/<path> or an unconfigured hostname`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const sshUser = username ? decodeOr(username) : undefined;
|
|
228
|
+
const sshTargetHost = decodeOr(sshHost);
|
|
229
|
+
const name = `${sshUser ? `${sshUser}@` : ""}${sshTargetHost}${port !== undefined ? `:${port}` : ""}`;
|
|
230
|
+
return { name, host: sshTargetHost, username: sshUser, port };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// No explicit user/port: match the full decoded authority against a
|
|
234
|
+
// configured name (so an encoded reserved-char alias resolves correctly).
|
|
235
|
+
const match = items.find(entry => entry.name === rawAuthority) ?? items.find(entry => entry.name === bareHost);
|
|
236
|
+
if (match) {
|
|
237
|
+
return {
|
|
238
|
+
name: match.name,
|
|
239
|
+
host: match.host,
|
|
240
|
+
username: match.username,
|
|
241
|
+
port: match.port,
|
|
242
|
+
keyPath: match.keyPath,
|
|
243
|
+
compat: match.compat,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// Opaque OpenSSH destination (plain ~/.ssh/config alias, or any resolvable host).
|
|
247
|
+
return { name: rawAuthority, host: isIpv6Literal ? sshHost : rawAuthority };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Format a one-level remote directory listing — mirrors buildDirectoryResource's plain `name/` lines. */
|
|
251
|
+
function formatDirListing(entries: readonly RemoteDirEntry[]): string {
|
|
252
|
+
if (entries.length === 0) return "(empty directory)";
|
|
253
|
+
return entries.map(entry => `${entry.name}${entry.isDirectory ? "/" : ""}`).join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export class SshProtocolHandler implements ProtocolHandler {
|
|
257
|
+
readonly scheme = "ssh";
|
|
258
|
+
readonly immutable = false;
|
|
259
|
+
|
|
260
|
+
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
261
|
+
// Bare `ssh://` (or `ssh:///`) with no host lists the configured hosts. A
|
|
262
|
+
// host-less URL that still carries a path (`ssh:///etc/hosts`) is malformed —
|
|
263
|
+
// reject it instead of silently dropping the path and listing hosts.
|
|
264
|
+
if (!(url.rawHost || url.hostname)) {
|
|
265
|
+
const rawPath = url.rawPathname ?? url.pathname;
|
|
266
|
+
if (rawPath && rawPath !== "/") {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`ssh:// requires a host before the path: ssh://<host>${rawPath} (host-less ssh://${rawPath} is not valid)`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return this.#resolveHostIndex(url, context?.cwd);
|
|
272
|
+
}
|
|
273
|
+
const target = await resolveTarget(url, context?.cwd);
|
|
274
|
+
const remotePath = remotePathFromUrl(url);
|
|
275
|
+
// Classify before reading. A FIFO with no writer would block `head` until the
|
|
276
|
+
// timeout, and a device (e.g. /dev/zero) would stream the whole probe, so a
|
|
277
|
+
// special file must fail fast. Only a regular file is read; a directory lists.
|
|
278
|
+
// `missing`/stat-failure falls through to the read so its original remote stderr
|
|
279
|
+
// (e.g. "No such file or directory") still surfaces.
|
|
280
|
+
let kind: RemotePathKind | undefined;
|
|
281
|
+
try {
|
|
282
|
+
kind = await statRemotePath(target, remotePath, { signal: context?.signal });
|
|
283
|
+
} catch {
|
|
284
|
+
// stat failed (host/connection issue) — fall through; the read gives a clearer error.
|
|
285
|
+
}
|
|
286
|
+
if (kind === "directory") {
|
|
287
|
+
return this.#resolveDirectory(target, remotePath, url, context?.signal, context?.skipDirectoryListing);
|
|
288
|
+
}
|
|
289
|
+
if (kind === "other") {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`ssh://: ${remotePath} is not a regular file (FIFO, socket, or device); ssh:// reads UTF-8 text files only — use the ssh tool for special files`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const fileResult = await readRemoteFile(target, remotePath, {
|
|
295
|
+
maxBytes: SSH_TEXT_MAX_BYTES,
|
|
296
|
+
signal: context?.signal,
|
|
297
|
+
});
|
|
298
|
+
if (fileResult.truncated) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`ssh://: ${remotePath} exceeds the 1 MiB limit; ssh:// supports text files up to 1 MiB — use an sshfs mount for larger files`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const content = decodeUtf8Text(fileResult.bytes);
|
|
304
|
+
if (content === null) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`ssh://: ${remotePath} is a binary or non-UTF-8 file; ssh:// supports UTF-8 text only — use the ssh tool or an sshfs mount`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
// No `sourcePath`: keeps search on the virtual-resource path so the
|
|
310
|
+
// displayed/searched resource stays `ssh://…` instead of a temp path.
|
|
311
|
+
return {
|
|
312
|
+
url: url.href,
|
|
313
|
+
content,
|
|
314
|
+
contentType: contentTypeFor(remotePath),
|
|
315
|
+
size: fileResult.bytes.length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Resolve a remote directory to a one-level listing (no `sourcePath`; `isDirectory` so search refuses it; immutable). */
|
|
320
|
+
async #resolveDirectory(
|
|
321
|
+
target: SSHConnectionTarget,
|
|
322
|
+
remotePath: string,
|
|
323
|
+
url: InternalUrl,
|
|
324
|
+
signal?: AbortSignal,
|
|
325
|
+
skipListing?: boolean,
|
|
326
|
+
): Promise<InternalResource> {
|
|
327
|
+
// `search`/`find` reject an ssh:// directory outright, so they pass `skipListing`
|
|
328
|
+
// to avoid draining a full remote `ls` we would only discard.
|
|
329
|
+
const content = skipListing ? "" : formatDirListing(await listRemoteDir(target, remotePath, { signal }));
|
|
330
|
+
return {
|
|
331
|
+
url: url.href,
|
|
332
|
+
content,
|
|
333
|
+
contentType: "text/plain",
|
|
334
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
335
|
+
immutable: true,
|
|
336
|
+
isDirectory: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Resolve a bare `ssh://` to a listing of configured hosts (immutable; plain virtual text, so `search` can still grep host names). */
|
|
341
|
+
async #resolveHostIndex(url: InternalUrl, cwd?: string): Promise<InternalResource> {
|
|
342
|
+
const content = formatHostIndex(await loadConfiguredHosts(cwd));
|
|
343
|
+
return {
|
|
344
|
+
url: url.href,
|
|
345
|
+
content,
|
|
346
|
+
contentType: "text/markdown",
|
|
347
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
348
|
+
immutable: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Autocomplete the host segment of `ssh://` with the configured SSH hosts. */
|
|
353
|
+
async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
|
|
354
|
+
const hosts = await loadConfiguredHosts(context?.cwd);
|
|
355
|
+
return hosts.map(host => ({
|
|
356
|
+
value: encodeURIComponent(host.name),
|
|
357
|
+
label: host.name,
|
|
358
|
+
description: host.description ?? hostAddress(host),
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async write(url: InternalUrl, content: string, context?: WriteContext): Promise<void> {
|
|
363
|
+
const target = await resolveTarget(url, context?.cwd);
|
|
364
|
+
const remotePath = remotePathFromUrl(url);
|
|
365
|
+
await writeRemoteFile(target, remotePath, new TextEncoder().encode(content), { signal: context?.signal });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the internal URL routing system.
|
|
3
3
|
*
|
|
4
|
-
* Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
|
|
4
|
+
* Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, `ssh://`, and `vault://`) are resolved by tools like read,
|
|
5
5
|
* providing access to agent outputs and server resources without exposing filesystem paths.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -33,6 +33,13 @@ export interface InternalResource {
|
|
|
33
33
|
* resources. Mutable resources (e.g. local://) behave like editable files.
|
|
34
34
|
*/
|
|
35
35
|
immutable?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* True when the resource is a directory listing rather than file content.
|
|
38
|
+
* `search` refuses to grep such a resource when it has no `sourcePath` — a
|
|
39
|
+
* remote `ssh://` listing has no local path to recurse, so its listing text
|
|
40
|
+
* must never be mistaken for the directory's contents.
|
|
41
|
+
*/
|
|
42
|
+
isDirectory?: boolean;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -93,6 +100,13 @@ export interface ResolveContext {
|
|
|
93
100
|
localProtocolOptions?: LocalProtocolOptions;
|
|
94
101
|
/** Calling session's loaded skills. Prefer this over process-global skill state. */
|
|
95
102
|
skills?: readonly Skill[];
|
|
103
|
+
/**
|
|
104
|
+
* When set, handlers that would otherwise materialize an expensive directory
|
|
105
|
+
* listing (e.g. the ssh:// handler draining a full remote `ls`) instead return
|
|
106
|
+
* the directory shape (`isDirectory: true`) with empty content. `search`/`find`
|
|
107
|
+
* reject directory resources, so they never need the listing.
|
|
108
|
+
*/
|
|
109
|
+
skipDirectoryListing?: boolean;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
/**
|
|
@@ -150,6 +164,9 @@ export interface ProtocolHandler {
|
|
|
150
164
|
* mcp://) omit it. The caller fuzzy-filters the returned set against the
|
|
151
165
|
* partially typed `query`, so handlers return their full (bounded) candidate
|
|
152
166
|
* list; `query` is provided only so handlers can scope expensive enumeration.
|
|
167
|
+
* `context.cwd`/`context.localProtocolOptions` carry the caller's working dir
|
|
168
|
+
* and session, for handlers whose candidates are project- or session-scoped
|
|
169
|
+
* (e.g. ssh:// hosts from a project `ssh.json`, local:// roots per session).
|
|
153
170
|
*/
|
|
154
|
-
complete?(query
|
|
171
|
+
complete?(query?: string, context?: ResolveContext): Promise<UrlCompletion[]>;
|
|
155
172
|
}
|
package/src/irc/bus.ts
CHANGED
|
@@ -91,8 +91,16 @@ export class IrcBus {
|
|
|
91
91
|
* disabled — e.g. blocked in a synchronous task spawn awaiting the
|
|
92
92
|
* sender's own batch) can generate an ephemeral side-channel auto-reply
|
|
93
93
|
* instead of stranding the sender until timeout.
|
|
94
|
+
*
|
|
95
|
+
* `opts.suppressRelay` skips the display-only main-UI relay for this leg.
|
|
96
|
+
* Set by broadcast fan-out when the same broadcast also targets the main
|
|
97
|
+
* agent directly: the main agent then already sees the body as its own
|
|
98
|
+
* incoming card, so relaying the sibling legs would duplicate it.
|
|
94
99
|
*/
|
|
95
|
-
async send(
|
|
100
|
+
async send(
|
|
101
|
+
msg: Omit<IrcMessage, "id" | "ts">,
|
|
102
|
+
opts?: { expectsReply?: boolean; suppressRelay?: boolean },
|
|
103
|
+
): Promise<IrcDeliveryReceipt> {
|
|
96
104
|
const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
|
|
97
105
|
const ref = this.#registry.get(message.to);
|
|
98
106
|
if (!ref || ref.status === "aborted") {
|
|
@@ -127,7 +135,7 @@ export class IrcBus {
|
|
|
127
135
|
const waiter = this.#takeMatchingWaiter(message.to, message.from);
|
|
128
136
|
if (waiter) {
|
|
129
137
|
waiter.resolve(message);
|
|
130
|
-
this.#relayToMainUi(message);
|
|
138
|
+
if (!opts?.suppressRelay) this.#relayToMainUi(message);
|
|
131
139
|
return { to: message.to, outcome: revived ? "revived" : "injected" };
|
|
132
140
|
}
|
|
133
141
|
|
|
@@ -138,7 +146,7 @@ export class IrcBus {
|
|
|
138
146
|
|
|
139
147
|
try {
|
|
140
148
|
const delivery = await session.deliverIrcMessage(message, opts);
|
|
141
|
-
this.#relayToMainUi(message);
|
|
149
|
+
if (!opts?.suppressRelay) this.#relayToMainUi(message);
|
|
142
150
|
return { to: message.to, outcome: revived ? "revived" : delivery };
|
|
143
151
|
} catch (error) {
|
|
144
152
|
// Live hand-off failed (e.g. recipient disposed mid-shutdown): buffer
|
package/src/lsp/index.ts
CHANGED
|
@@ -2054,7 +2054,14 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
2054
2054
|
}
|
|
2055
2055
|
|
|
2056
2056
|
if (action === "reload" && (isWorkspace || !resolvedFile)) {
|
|
2057
|
-
|
|
2057
|
+
// `reload *` is the user's explicit request to re-read config from
|
|
2058
|
+
// disk. Drop the per-cwd cache entry so `.omp/lsp.json`, root markers,
|
|
2059
|
+
// and plugin configs added after the first LSP call become visible —
|
|
2060
|
+
// otherwise `getConfig` returns the first observation for the rest of
|
|
2061
|
+
// the process lifetime (#3546).
|
|
2062
|
+
configCache.delete(this.session.cwd);
|
|
2063
|
+
const refreshedConfig = getConfig(this.session.cwd);
|
|
2064
|
+
const servers = getLspServers(refreshedConfig);
|
|
2058
2065
|
if (servers.length === 0) {
|
|
2059
2066
|
return {
|
|
2060
2067
|
content: [{ type: "text", text: "No language server found for this action" }],
|
|
@@ -308,10 +308,15 @@ export async function discoverOAuthEndpoints(
|
|
|
308
308
|
"/.mcp/auth",
|
|
309
309
|
"/authorize", // Some MCP servers expose OAuth config here
|
|
310
310
|
];
|
|
311
|
-
const urlsToQuery: string
|
|
311
|
+
const urlsToQuery: Array<{ url: string; issuerCandidate: boolean }> = [];
|
|
312
312
|
const visitedAuthServers = new Set<string>();
|
|
313
313
|
|
|
314
314
|
let protectedResource = opts?.protectedResource;
|
|
315
|
+
const addDiscoveryBase = (url: string | undefined, issuerCandidate: boolean): void => {
|
|
316
|
+
if (!url || visitedAuthServers.has(url)) return;
|
|
317
|
+
urlsToQuery.push({ url, issuerCandidate });
|
|
318
|
+
visitedAuthServers.add(url);
|
|
319
|
+
};
|
|
315
320
|
|
|
316
321
|
// Step 1: If a resource_metadata URL was provided, fetch it to discover auth servers.
|
|
317
322
|
// This follows the RFC 9728 chain: resource_metadata → authorization_servers.
|
|
@@ -332,10 +337,7 @@ export async function discoverOAuthEndpoints(
|
|
|
332
337
|
? meta.authorization_servers.filter((entry): entry is string => typeof entry === "string")
|
|
333
338
|
: [];
|
|
334
339
|
for (const s of authServers) {
|
|
335
|
-
|
|
336
|
-
urlsToQuery.push(s);
|
|
337
|
-
visitedAuthServers.add(s);
|
|
338
|
-
}
|
|
340
|
+
addDiscoveryBase(s, true);
|
|
339
341
|
}
|
|
340
342
|
}
|
|
341
343
|
} catch {
|
|
@@ -343,13 +345,9 @@ export async function discoverOAuthEndpoints(
|
|
|
343
345
|
}
|
|
344
346
|
}
|
|
345
347
|
|
|
346
|
-
// Step 2: Add explicit authServerUrl
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
urlsToQuery.push(url);
|
|
350
|
-
visitedAuthServers.add(url);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
348
|
+
// Step 2: Add explicit authServerUrl as an issuer candidate, then the resource server fallback.
|
|
349
|
+
addDiscoveryBase(authServerUrl, true);
|
|
350
|
+
addDiscoveryBase(serverUrl, false);
|
|
353
351
|
|
|
354
352
|
const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
|
|
355
353
|
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
@@ -414,10 +412,10 @@ export async function discoverOAuthEndpoints(
|
|
|
414
412
|
return null;
|
|
415
413
|
};
|
|
416
414
|
|
|
417
|
-
for (const
|
|
415
|
+
for (const base of urlsToQuery) {
|
|
418
416
|
for (const path of wellKnownPaths) {
|
|
419
417
|
// Try each well-known path at both the absolute origin and relative
|
|
420
|
-
const urlsToTry = buildWellKnownUrls(path,
|
|
418
|
+
const urlsToTry = buildWellKnownUrls(path, base.url);
|
|
421
419
|
for (const url of urlsToTry) {
|
|
422
420
|
try {
|
|
423
421
|
const response = await fetchImpl(url.toString(), {
|
|
@@ -429,13 +427,15 @@ export async function discoverOAuthEndpoints(
|
|
|
429
427
|
if (response.ok) {
|
|
430
428
|
const metadata = (await response.json()) as Record<string, unknown>;
|
|
431
429
|
// Authorization-server / OpenID Connect metadata documents carry an
|
|
432
|
-
// `issuer` field that MUST equal the queried base URL
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
430
|
+
// `issuer` field that MUST equal the queried base URL only when that
|
|
431
|
+
// URL came from an auth-server source (RFC 8414 §3.3, OIDC Discovery
|
|
432
|
+
// §4.3). Resource-server fallback probes can legitimately return
|
|
433
|
+
// cross-host issuer metadata.
|
|
436
434
|
const requireIssuerMatch =
|
|
437
|
-
|
|
438
|
-
|
|
435
|
+
base.issuerCandidate &&
|
|
436
|
+
(path === "/.well-known/oauth-authorization-server" ||
|
|
437
|
+
path === "/.well-known/openid-configuration");
|
|
438
|
+
const issuerOk = requireIssuerMatch ? issuerMatchesBase(metadata.issuer, base.url) : true;
|
|
439
439
|
const endpoints = issuerOk ? findEndpoints(metadata) : null;
|
|
440
440
|
if (endpoints) return endpoints;
|
|
441
441
|
|