@gajae-code/coding-agent 0.5.4 → 0.6.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/CHANGELOG.md +23 -0
- package/dist/types/cli/web-search-cli.d.ts +12 -0
- package/dist/types/commands/rlm.d.ts +10 -0
- package/dist/types/commands/web-search.d.ts +54 -0
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-profiles.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +61 -3
- package/dist/types/edit/notebook.d.ts +3 -0
- package/dist/types/eval/py/executor.d.ts +3 -0
- package/dist/types/eval/py/kernel.d.ts +3 -1
- package/dist/types/eval/py/runtime.d.ts +9 -1
- package/dist/types/exec/bash-executor.d.ts +4 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/goals/tools/goal-tool.d.ts +2 -0
- package/dist/types/main.d.ts +11 -0
- package/dist/types/modes/components/custom-editor.d.ts +4 -2
- package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
- package/dist/types/modes/components/model-selector.d.ts +5 -2
- package/dist/types/modes/components/status-line.d.ts +4 -1
- package/dist/types/modes/controllers/input-controller.d.ts +3 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/print-mode.d.ts +6 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
- package/dist/types/rlm/artifacts.d.ts +9 -0
- package/dist/types/rlm/complete-research-tool.d.ts +35 -0
- package/dist/types/rlm/data-context.d.ts +6 -0
- package/dist/types/rlm/index.d.ts +35 -0
- package/dist/types/rlm/notebook.d.ts +12 -0
- package/dist/types/rlm/preset.d.ts +23 -0
- package/dist/types/rlm/python-tool.d.ts +16 -0
- package/dist/types/rlm/report.d.ts +14 -0
- package/dist/types/rlm/types.d.ts +37 -0
- package/dist/types/sdk.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
- package/dist/types/tools/browser/attach.d.ts +19 -3
- package/dist/types/tools/browser/registry.d.ts +15 -0
- package/dist/types/tools/browser/render.d.ts +3 -0
- package/dist/types/tools/browser.d.ts +18 -1
- package/dist/types/tools/computer/render.d.ts +17 -0
- package/dist/types/tools/computer.d.ts +465 -0
- package/dist/types/tools/index.d.ts +24 -1
- package/dist/types/tools/job.d.ts +13 -0
- package/dist/types/tools/tool-timeouts.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +32 -2
- package/dist/types/web/search/providers/base.d.ts +22 -0
- package/dist/types/web/search/providers/xai.d.ts +64 -0
- package/dist/types/web/search/types.d.ts +11 -3
- package/package.json +7 -7
- package/src/cli/web-search-cli.ts +123 -8
- package/src/cli.ts +2 -0
- package/src/commands/rlm.ts +19 -0
- package/src/commands/web-search.ts +66 -0
- package/src/config/keybindings.ts +11 -0
- package/src/config/model-profiles.ts +11 -3
- package/src/config/model-registry.ts +55 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +67 -1
- package/src/edit/notebook.ts +6 -2
- package/src/eval/py/executor.ts +8 -1
- package/src/eval/py/kernel.ts +9 -4
- package/src/eval/py/runtime.ts +153 -32
- package/src/exec/bash-executor.ts +10 -4
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/custom-tools/wrapper.ts +2 -0
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/extensibility/extensions/wrapper.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +129 -1
- package/src/gjc-runtime/session-state-sidecar.ts +61 -1
- package/src/gjc-runtime/tmux-common.ts +26 -2
- package/src/gjc-runtime/tmux-gc.ts +40 -27
- package/src/gjc-runtime/tmux-sessions.ts +13 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
- package/src/goals/runtime.ts +4 -3
- package/src/goals/state.ts +1 -1
- package/src/goals/tools/goal-tool.ts +16 -3
- package/src/internal-urls/docs-index.generated.ts +13 -9
- package/src/main.ts +28 -3
- package/src/modes/components/custom-editor.ts +13 -4
- package/src/modes/components/custom-model-preset-wizard.ts +293 -0
- package/src/modes/components/hook-selector.ts +1 -1
- package/src/modes/components/model-selector.ts +72 -29
- package/src/modes/components/skill-message.ts +62 -8
- package/src/modes/components/status-line.ts +13 -1
- package/src/modes/controllers/input-controller.ts +60 -11
- package/src/modes/controllers/selector-controller.ts +39 -0
- package/src/modes/interactive-mode.ts +1 -1
- package/src/modes/print-mode.ts +14 -4
- package/src/modes/rpc/rpc-client.ts +250 -80
- package/src/modes/rpc/rpc-mode.ts +6 -12
- package/src/modes/rpc/rpc-socket-security.ts +103 -0
- package/src/modes/rpc/rpc-types.ts +10 -0
- package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
- package/src/modes/shared/agent-wire/command-validation.ts +1 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
- package/src/modes/utils/hotkeys-markdown.ts +4 -2
- package/src/modes/utils/ui-helpers.ts +2 -2
- package/src/prompts/goals/goal-continuation.md +1 -0
- package/src/prompts/goals/goal-mode-active.md +1 -0
- package/src/prompts/system/rlm-report-command.md +1 -0
- package/src/prompts/system/rlm-research.md +23 -0
- package/src/prompts/tools/bash.md +23 -2
- package/src/prompts/tools/browser.md +7 -3
- package/src/prompts/tools/computer.md +74 -0
- package/src/prompts/tools/goal.md +3 -0
- package/src/prompts/tools/job.md +9 -1
- package/src/prompts/tools/web-search.md +7 -0
- package/src/rlm/artifacts.ts +60 -0
- package/src/rlm/complete-research-tool.ts +163 -0
- package/src/rlm/data-context.ts +26 -0
- package/src/rlm/index.ts +339 -0
- package/src/rlm/notebook.ts +108 -0
- package/src/rlm/preset.ts +76 -0
- package/src/rlm/python-tool.ts +68 -0
- package/src/rlm/report.ts +70 -0
- package/src/rlm/types.ts +40 -0
- package/src/sdk.ts +12 -0
- package/src/session/agent-session.ts +48 -3
- package/src/slash-commands/builtin-registry.ts +17 -0
- package/src/tools/bash-allowed-prefixes.ts +84 -1
- package/src/tools/bash.ts +80 -13
- package/src/tools/browser/attach.ts +103 -3
- package/src/tools/browser/registry.ts +176 -2
- package/src/tools/browser/render.ts +9 -1
- package/src/tools/browser.ts +33 -0
- package/src/tools/computer/render.ts +78 -0
- package/src/tools/computer.ts +640 -0
- package/src/tools/index.ts +41 -1
- package/src/tools/job.ts +88 -5
- package/src/tools/json-tree.ts +42 -29
- package/src/tools/renderers.ts +2 -0
- package/src/tools/tool-timeouts.ts +1 -0
- package/src/web/search/index.ts +27 -2
- package/src/web/search/provider.ts +16 -1
- package/src/web/search/providers/base.ts +22 -0
- package/src/web/search/providers/xai.ts +511 -0
- package/src/web/search/render.ts +7 -0
- package/src/web/search/types.ts +11 -1
package/src/sdk.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
|
|
48
48
|
import { Settings, type SkillsSettings } from "./config/settings";
|
|
49
49
|
import { CursorExecHandlers } from "./cursor";
|
|
50
|
+
import type { BashRestrictionProfile } from "./tools/bash-allowed-prefixes";
|
|
50
51
|
import "./discovery";
|
|
51
52
|
import { resolveConfigValue } from "./config/resolve-config-value";
|
|
52
53
|
import { getEmbeddedDefaultGjcSkills } from "./defaults/gjc-defaults";
|
|
@@ -310,6 +311,12 @@ export interface CreateAgentSessionOptions {
|
|
|
310
311
|
agentDisplayName?: string;
|
|
311
312
|
/** Optional restricted bash command prefixes for read-only role agents. */
|
|
312
313
|
bashAllowedPrefixes?: string[];
|
|
314
|
+
/** Restriction policy paired with bashAllowedPrefixes. */
|
|
315
|
+
bashRestrictionProfile?: BashRestrictionProfile;
|
|
316
|
+
/** Optional per-session restriction for goal tool operations. */
|
|
317
|
+
goalToolAllowedOps?: readonly ("create" | "get" | "complete" | "resume" | "drop" | "pause")[];
|
|
318
|
+
/** Optional per-session allowlist for tools exposed through search_tool_bm25. */
|
|
319
|
+
discoverableToolAllowedNames?: readonly string[];
|
|
313
320
|
/** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
|
|
314
321
|
agentRegistry?: AgentRegistry;
|
|
315
322
|
/** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
|
|
@@ -619,6 +626,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
|
|
619
626
|
label: tool.label,
|
|
620
627
|
description: tool.description,
|
|
621
628
|
parameters: tool.parameters,
|
|
629
|
+
concurrency: tool.concurrency,
|
|
622
630
|
hidden: tool.hidden,
|
|
623
631
|
deferrable: tool.deferrable,
|
|
624
632
|
mcpServerName: tool.mcpServerName,
|
|
@@ -1219,6 +1227,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1219
1227
|
},
|
|
1220
1228
|
getAgentId: () => resolvedAgentId,
|
|
1221
1229
|
bashAllowedPrefixes: options.bashAllowedPrefixes,
|
|
1230
|
+
bashRestrictionProfile: options.bashRestrictionProfile,
|
|
1231
|
+
goalToolAllowedOps: options.goalToolAllowedOps,
|
|
1232
|
+
discoverableToolAllowedNames: options.discoverableToolAllowedNames,
|
|
1222
1233
|
getToolByName: name => session?.getToolByName(name),
|
|
1223
1234
|
agentRegistry,
|
|
1224
1235
|
getSessionSpawns: () => options.spawns ?? "*",
|
|
@@ -2028,6 +2039,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2028
2039
|
rebuildSystemPrompt,
|
|
2029
2040
|
reloadSshTool,
|
|
2030
2041
|
requestedToolNames: requestedToolNameSet,
|
|
2042
|
+
discoverableToolAllowedNames: options.discoverableToolAllowedNames,
|
|
2031
2043
|
getMcpServerInstructions: mcpManager
|
|
2032
2044
|
? () => {
|
|
2033
2045
|
const raw = mcpManager.getServerInstructions();
|
|
@@ -388,6 +388,8 @@ export interface AgentSessionConfig {
|
|
|
388
388
|
/** Rebuild the SSH tool from current capability discovery results. */
|
|
389
389
|
reloadSshTool?: () => Promise<AgentTool | null>;
|
|
390
390
|
requestedToolNames?: ReadonlySet<string>;
|
|
391
|
+
/** Optional per-session allowlist for tools exposed through search_tool_bm25. */
|
|
392
|
+
discoverableToolAllowedNames?: readonly string[];
|
|
391
393
|
/**
|
|
392
394
|
* Optional accessor for live MCP server instructions. Read by the session's
|
|
393
395
|
* `rebuildSystemPrompt`-skip optimization to detect server-side instruction
|
|
@@ -944,6 +946,7 @@ export class AgentSession {
|
|
|
944
946
|
// Bash execution state
|
|
945
947
|
#bashAbortControllers = new Set<AbortController>();
|
|
946
948
|
#pendingBashMessages: BashExecutionMessage[] = [];
|
|
949
|
+
#foregroundBashBackgroundRequestHandler: (() => void) | undefined;
|
|
947
950
|
|
|
948
951
|
// Python execution state
|
|
949
952
|
#evalAbortControllers = new Set<AbortController>();
|
|
@@ -1016,6 +1019,7 @@ export class AgentSession {
|
|
|
1016
1019
|
// Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
|
|
1017
1020
|
#discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
|
|
1018
1021
|
#selectedDiscoveredToolNames = new Set<string>();
|
|
1022
|
+
#discoverableToolAllowedNames: ReadonlySet<string> | undefined;
|
|
1019
1023
|
#rpcHostToolNames = new Set<string>();
|
|
1020
1024
|
#gjcSubskillToolNames = new Set<string>();
|
|
1021
1025
|
#gjcSubskillToolSignature: string | undefined;
|
|
@@ -1224,6 +1228,9 @@ export class AgentSession {
|
|
|
1224
1228
|
this.#reloadSshTool = config.reloadSshTool;
|
|
1225
1229
|
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
1226
1230
|
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
|
|
1231
|
+
this.#discoverableToolAllowedNames = config.discoverableToolAllowedNames
|
|
1232
|
+
? new Set(config.discoverableToolAllowedNames.map(name => name.toLowerCase()))
|
|
1233
|
+
: undefined;
|
|
1227
1234
|
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
|
1228
1235
|
this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
|
|
1229
1236
|
this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
|
|
@@ -3429,6 +3436,42 @@ export class AgentSession {
|
|
|
3429
3436
|
return this.#toolRegistry.get(name);
|
|
3430
3437
|
}
|
|
3431
3438
|
|
|
3439
|
+
/**
|
|
3440
|
+
* Register a UI/control-plane request handler for a currently foregrounded
|
|
3441
|
+
* managed bash execution. This is intentionally narrower than generic
|
|
3442
|
+
* process/job control: unsupported tool types simply do not register a
|
|
3443
|
+
* handler, so Ctrl+B-style folding fails closed instead of aborting or
|
|
3444
|
+
* shell-suspending arbitrary work.
|
|
3445
|
+
*/
|
|
3446
|
+
registerForegroundBashBackgroundRequestHandler(handler: () => void): () => void {
|
|
3447
|
+
this.#foregroundBashBackgroundRequestHandler = handler;
|
|
3448
|
+
return () => {
|
|
3449
|
+
if (this.#foregroundBashBackgroundRequestHandler === handler) {
|
|
3450
|
+
this.#foregroundBashBackgroundRequestHandler = undefined;
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
/**
|
|
3456
|
+
* Returns whether a managed foreground bash call is currently backgroundable.
|
|
3457
|
+
* UI key handlers use this to avoid consuming normal editor shortcuts when
|
|
3458
|
+
* no fold target exists.
|
|
3459
|
+
*/
|
|
3460
|
+
hasForegroundBashBackgroundRequestHandler(): boolean {
|
|
3461
|
+
return this.#foregroundBashBackgroundRequestHandler !== undefined;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/**
|
|
3465
|
+
* Ask the active managed foreground bash call to return as a background job.
|
|
3466
|
+
* Returns false when no supported foreground tool is currently backgroundable.
|
|
3467
|
+
*/
|
|
3468
|
+
requestForegroundBashBackground(): boolean {
|
|
3469
|
+
const handler = this.#foregroundBashBackgroundRequestHandler;
|
|
3470
|
+
if (!handler) return false;
|
|
3471
|
+
handler();
|
|
3472
|
+
return true;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3432
3475
|
/**
|
|
3433
3476
|
* Get all configured tool names (built-in via --tools or default, plus custom tools).
|
|
3434
3477
|
*/
|
|
@@ -3553,6 +3596,7 @@ export class AgentSession {
|
|
|
3553
3596
|
for (const tool of this.#toolRegistry.values()) {
|
|
3554
3597
|
if (tool.loadMode !== "discoverable") continue;
|
|
3555
3598
|
if (activeNames.has(tool.name)) continue;
|
|
3599
|
+
if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(tool.name)) continue;
|
|
3556
3600
|
const collected = collectDiscoverableTools([tool], { source: "builtin" });
|
|
3557
3601
|
result.push(...collected);
|
|
3558
3602
|
}
|
|
@@ -3599,6 +3643,7 @@ export class AgentSession {
|
|
|
3599
3643
|
const currentActiveNames = new Set(this.getActiveToolNames());
|
|
3600
3644
|
const newlyAdded: string[] = [];
|
|
3601
3645
|
for (const name of nonMcpNames) {
|
|
3646
|
+
if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(name)) continue;
|
|
3602
3647
|
if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) {
|
|
3603
3648
|
newlyAdded.push(name);
|
|
3604
3649
|
this.#selectedDiscoveredToolNames.add(name);
|
|
@@ -5804,9 +5849,9 @@ export class AgentSession {
|
|
|
5804
5849
|
);
|
|
5805
5850
|
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
|
5806
5851
|
|
|
5807
|
-
//
|
|
5808
|
-
// configured defaultLevel
|
|
5809
|
-
this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel);
|
|
5852
|
+
// Apply the explicitly selected thinking level when the selector supplies one;
|
|
5853
|
+
// otherwise prefer the model's configured defaultLevel, then preserve the current level.
|
|
5854
|
+
this.setThinkingLevel(options?.thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
|
|
5810
5855
|
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
5811
5856
|
}
|
|
5812
5857
|
|
|
@@ -449,6 +449,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
449
449
|
runtime.ctx.editor.setText("");
|
|
450
450
|
},
|
|
451
451
|
},
|
|
452
|
+
{
|
|
453
|
+
name: "copy",
|
|
454
|
+
description: "Copy last response as markdown",
|
|
455
|
+
// Public `/copy` is strict zero-argument, but `allowArgs` lets the
|
|
456
|
+
// TUI dispatcher route `/copy <arg>` here so it can be rejected locally
|
|
457
|
+
// instead of falling through as a model prompt.
|
|
458
|
+
allowArgs: true,
|
|
459
|
+
handleTui: (command, runtime) => {
|
|
460
|
+
if (command.args.trim().length > 0) {
|
|
461
|
+
runtime.ctx.showError("Usage: /copy");
|
|
462
|
+
runtime.ctx.editor.setText("");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
runtime.ctx.handleCopyCommand(undefined);
|
|
466
|
+
runtime.ctx.editor.setText("");
|
|
467
|
+
},
|
|
468
|
+
},
|
|
452
469
|
{
|
|
453
470
|
name: "dump",
|
|
454
471
|
description: "Copy session transcript to clipboard",
|
|
@@ -3,11 +3,18 @@ export interface BashAllowedPrefixesCheck {
|
|
|
3
3
|
reason?: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export type BashRestrictionProfile = "workflow" | "read-only";
|
|
7
|
+
|
|
8
|
+
export interface BashRestrictionOptions {
|
|
9
|
+
profile?: BashRestrictionProfile;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
|
|
7
13
|
const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}", "~"]);
|
|
8
14
|
const STATE_FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
|
|
9
15
|
const STATE_ACTIONS = new Set(["read", "write", "clear", "contract", "handoff"]);
|
|
10
16
|
const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
|
|
17
|
+
const READ_ONLY_COMMANDS = new Set(["grep", "rg", "tree", "ls", "pwd", "wc", "du", "file", "stat"]);
|
|
11
18
|
|
|
12
19
|
function parseShellWords(command: string): { words: string[]; reason?: string } {
|
|
13
20
|
const words: string[] = [];
|
|
@@ -118,6 +125,59 @@ function parseStateAction(words: readonly string[]): string | undefined {
|
|
|
118
125
|
return third ? undefined : second;
|
|
119
126
|
}
|
|
120
127
|
|
|
128
|
+
function optionWords(words: readonly string[]): string[] {
|
|
129
|
+
const options: string[] = [];
|
|
130
|
+
for (const word of words.slice(1)) {
|
|
131
|
+
if (word === "--") break;
|
|
132
|
+
options.push(word);
|
|
133
|
+
}
|
|
134
|
+
return options;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isLongOption(word: string, option: string): boolean {
|
|
138
|
+
return word === option || word.startsWith(`${option}=`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasShortOption(word: string, option: string): boolean {
|
|
142
|
+
return word.startsWith("-") && !word.startsWith("--") && word.slice(1).includes(option);
|
|
143
|
+
}
|
|
144
|
+
function shellQuote(value: string): string {
|
|
145
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolvedExternalCommand(command: string): string | undefined {
|
|
149
|
+
return Bun.which(command) ?? undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesCheck {
|
|
153
|
+
const command = words[0];
|
|
154
|
+
if (!command || !READ_ONLY_COMMANDS.has(command)) {
|
|
155
|
+
return { allowed: false, reason: "read-only bash only allows approved inspection commands" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const options = optionWords(words);
|
|
159
|
+
if (command === "rg") {
|
|
160
|
+
for (const option of options) {
|
|
161
|
+
if (isLongOption(option, "--pre") || isLongOption(option, "--pre-glob")) {
|
|
162
|
+
return { allowed: false, reason: "read-only bash does not allow ripgrep preprocessors" };
|
|
163
|
+
}
|
|
164
|
+
if (isLongOption(option, "--search-zip") || hasShortOption(option, "z")) {
|
|
165
|
+
return { allowed: false, reason: "read-only bash does not allow ripgrep compressed-file subprocesses" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (command === "tree") {
|
|
171
|
+
for (const option of options) {
|
|
172
|
+
if (isLongOption(option, "--output") || hasShortOption(option, "o")) {
|
|
173
|
+
return { allowed: false, reason: "read-only bash does not allow tree output-file writes" };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { allowed: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
121
181
|
function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixesCheck {
|
|
122
182
|
if (words[0] !== "gjc") return { allowed: true };
|
|
123
183
|
|
|
@@ -145,9 +205,29 @@ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixe
|
|
|
145
205
|
return { allowed: true };
|
|
146
206
|
}
|
|
147
207
|
|
|
208
|
+
function commandAllowedPrefixesReason(normalizedPrefixes: readonly string[], options: BashRestrictionOptions): string {
|
|
209
|
+
const prefixList = normalizedPrefixes.join(", ");
|
|
210
|
+
return options.profile === "read-only"
|
|
211
|
+
? `read-only bash only allows commands starting with: ${prefixList}`
|
|
212
|
+
: `restricted role-agent bash only allows commands starting with: ${prefixList}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function normalizeReadOnlyBashCommand(command: string): string | undefined {
|
|
216
|
+
const parsed = parseShellWords(command.trim());
|
|
217
|
+
if (parsed.reason || parsed.words.length === 0) return undefined;
|
|
218
|
+
const validation = validateReadOnlyCommand(parsed.words);
|
|
219
|
+
if (!validation.allowed) return undefined;
|
|
220
|
+
const [head, ...rest] = parsed.words;
|
|
221
|
+
if (!head) return undefined;
|
|
222
|
+
const resolvedHead = resolvedExternalCommand(head);
|
|
223
|
+
if (!resolvedHead) return undefined;
|
|
224
|
+
return [shellQuote(resolvedHead), ...rest.map(shellQuote)].join(" ");
|
|
225
|
+
}
|
|
226
|
+
|
|
148
227
|
export function checkBashAllowedPrefixes(
|
|
149
228
|
command: string,
|
|
150
229
|
allowedPrefixes: readonly string[] | undefined,
|
|
230
|
+
options: BashRestrictionOptions = {},
|
|
151
231
|
): BashAllowedPrefixesCheck {
|
|
152
232
|
const normalizedPrefixes = allowedPrefixes?.map(prefix => prefix.trim()).filter(Boolean) ?? [];
|
|
153
233
|
if (normalizedPrefixes.length === 0) return { allowed: true };
|
|
@@ -161,9 +241,12 @@ export function checkBashAllowedPrefixes(
|
|
|
161
241
|
if (!matched) {
|
|
162
242
|
return {
|
|
163
243
|
allowed: false,
|
|
164
|
-
reason:
|
|
244
|
+
reason: commandAllowedPrefixesReason(normalizedPrefixes, options),
|
|
165
245
|
};
|
|
166
246
|
}
|
|
167
247
|
|
|
248
|
+
if (options.profile === "read-only") {
|
|
249
|
+
return validateReadOnlyCommand(parsed.words);
|
|
250
|
+
}
|
|
168
251
|
return validateMatchedGjcCommand(parsed.words);
|
|
169
252
|
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { renderStatusLine } from "../tui";
|
|
|
19
19
|
import { CachedOutputBlock } from "../tui/output-block";
|
|
20
20
|
import { getSixelLineMask } from "../utils/sixel";
|
|
21
21
|
import type { ToolSession } from ".";
|
|
22
|
-
import { checkBashAllowedPrefixes } from "./bash-allowed-prefixes";
|
|
22
|
+
import { checkBashAllowedPrefixes, normalizeReadOnlyBashCommand } from "./bash-allowed-prefixes";
|
|
23
23
|
import { applyBashFixups } from "./bash-command-fixup";
|
|
24
24
|
import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
|
|
25
25
|
import { checkBashInterception } from "./bash-interceptor";
|
|
@@ -36,6 +36,12 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
36
36
|
|
|
37
37
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
38
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
39
|
+
const READ_ONLY_BASH_ENV: Record<string, string> = {
|
|
40
|
+
GREP_OPTIONS: "",
|
|
41
|
+
GREP_COLOR: "",
|
|
42
|
+
GREP_COLORS: "",
|
|
43
|
+
RIPGREP_CONFIG_PATH: "",
|
|
44
|
+
};
|
|
39
45
|
|
|
40
46
|
export async function saveBashOriginalArtifactForTests(
|
|
41
47
|
session: ToolSession,
|
|
@@ -261,6 +267,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
261
267
|
hasSearch: this.session.settings.get("search.enabled"),
|
|
262
268
|
hasFind: this.session.settings.get("find.enabled"),
|
|
263
269
|
restrictedAllowedPrefixes: this.session.bashAllowedPrefixes,
|
|
270
|
+
restrictionProfile: this.session.bashRestrictionProfile,
|
|
264
271
|
});
|
|
265
272
|
}
|
|
266
273
|
|
|
@@ -363,6 +370,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
363
370
|
const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
|
|
364
371
|
let latestText = "";
|
|
365
372
|
let backgrounded = options.startBackgrounded;
|
|
373
|
+
const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
|
|
374
|
+
backgrounded ? { async: { state: "running", jobId, type: "bash" } } : undefined;
|
|
375
|
+
const completedDetails = (jobId: string): Record<string, unknown> | undefined =>
|
|
376
|
+
backgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined;
|
|
377
|
+
const failedDetails = (jobId: string): Record<string, unknown> | undefined =>
|
|
378
|
+
backgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined;
|
|
366
379
|
const completion = Promise.withResolvers<ManagedBashJobCompletion>();
|
|
367
380
|
|
|
368
381
|
const jobId = manager.register(
|
|
@@ -381,10 +394,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
381
394
|
artifactPath,
|
|
382
395
|
artifactId,
|
|
383
396
|
oneShot: true,
|
|
397
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
398
|
+
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
384
399
|
onChunk: chunk => {
|
|
385
400
|
tailBuffer.append(chunk);
|
|
386
401
|
latestText = tailBuffer.text();
|
|
387
|
-
void reportProgress(latestText,
|
|
402
|
+
void reportProgress(latestText, runningDetails(jobId));
|
|
388
403
|
},
|
|
389
404
|
onRawChunk: chunk => {
|
|
390
405
|
// Forward the unthrottled sanitized chunk to the async-job
|
|
@@ -402,13 +417,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
402
417
|
const finalText = this.#extractTextResult(finalResult);
|
|
403
418
|
latestText = finalText;
|
|
404
419
|
completion.resolve({ kind: "completed", result: finalResult });
|
|
405
|
-
await reportProgress(finalText,
|
|
420
|
+
await reportProgress(finalText, completedDetails(jobId));
|
|
406
421
|
return finalText;
|
|
407
422
|
} catch (error) {
|
|
408
423
|
const message = error instanceof Error ? error.message : String(error);
|
|
409
424
|
latestText = message;
|
|
410
425
|
completion.resolve({ kind: "failed", error });
|
|
411
|
-
await reportProgress(message,
|
|
426
|
+
await reportProgress(message, failedDetails(jobId));
|
|
412
427
|
throw error;
|
|
413
428
|
}
|
|
414
429
|
},
|
|
@@ -439,6 +454,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
439
454
|
job: ManagedBashJobHandle,
|
|
440
455
|
thresholdMs: number,
|
|
441
456
|
signal?: AbortSignal,
|
|
457
|
+
backgroundRequest?: Promise<void>,
|
|
442
458
|
): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
|
|
443
459
|
if (signal?.aborted) {
|
|
444
460
|
return { kind: "aborted" };
|
|
@@ -448,6 +464,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
448
464
|
job.completion,
|
|
449
465
|
Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
|
|
450
466
|
];
|
|
467
|
+
if (backgroundRequest) {
|
|
468
|
+
waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
|
|
469
|
+
}
|
|
451
470
|
|
|
452
471
|
if (!signal) {
|
|
453
472
|
return await Promise.race(waiters);
|
|
@@ -510,23 +529,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
510
529
|
|
|
511
530
|
const rawCommand = input.command;
|
|
512
531
|
const allowedPrefixes = this.session.bashAllowedPrefixes;
|
|
532
|
+
if (
|
|
533
|
+
(this.session.bashRestrictionProfile === "read-only" || (allowedPrefixes && allowedPrefixes.length > 0)) &&
|
|
534
|
+
env &&
|
|
535
|
+
Object.keys(env).length > 0
|
|
536
|
+
) {
|
|
537
|
+
const mode = this.session.bashRestrictionProfile === "read-only" ? "Read-only" : "Restricted role-agent";
|
|
538
|
+
throw new ToolError(`${mode} bash does not allow per-command env overrides.`);
|
|
539
|
+
}
|
|
513
540
|
if (allowedPrefixes && allowedPrefixes.length > 0) {
|
|
514
|
-
if (env && Object.keys(env).length > 0) {
|
|
515
|
-
throw new ToolError("Restricted role-agent bash does not allow per-command env overrides.");
|
|
516
|
-
}
|
|
517
541
|
const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
|
|
518
542
|
for (const commandToCheck of commandsToCheck) {
|
|
519
|
-
const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes
|
|
543
|
+
const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes, {
|
|
544
|
+
profile: this.session.bashRestrictionProfile,
|
|
545
|
+
});
|
|
520
546
|
if (!allowlist.allowed) {
|
|
521
547
|
throw new ToolError(allowlist.reason ?? "Command blocked by restricted role-agent bash allowlist.");
|
|
522
548
|
}
|
|
523
549
|
}
|
|
524
550
|
}
|
|
551
|
+
if (this.session.bashRestrictionProfile === "read-only") {
|
|
552
|
+
const normalizedReadOnlyCommand = normalizeReadOnlyBashCommand(command);
|
|
553
|
+
if (!normalizedReadOnlyCommand) {
|
|
554
|
+
throw new ToolError("Read-only bash command could not be normalized safely.");
|
|
555
|
+
}
|
|
556
|
+
command = normalizedReadOnlyCommand;
|
|
557
|
+
}
|
|
525
558
|
|
|
526
559
|
// Check both the original command and the cwd-normalized command so
|
|
527
560
|
// leading `cd ... &&` wrappers do not hide either shell-navigation rules
|
|
528
561
|
// or the dedicated-tool command that follows the directory change.
|
|
529
|
-
if (this.session.settings.get("bashInterceptor.enabled")) {
|
|
562
|
+
if (this.session.bashRestrictionProfile !== "read-only" && this.session.settings.get("bashInterceptor.enabled")) {
|
|
530
563
|
const rules = this.session.settings.getBashInterceptorRules();
|
|
531
564
|
const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
|
|
532
565
|
for (const commandToCheck of commandsToCheck) {
|
|
@@ -545,7 +578,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
545
578
|
getSessionId: this.session.getSessionId,
|
|
546
579
|
},
|
|
547
580
|
};
|
|
548
|
-
command = await expandInternalUrls(command, {
|
|
581
|
+
command = await expandInternalUrls(command, {
|
|
582
|
+
...internalUrlOptions,
|
|
583
|
+
ensureLocalParentDirs: this.session.bashRestrictionProfile !== "read-only",
|
|
584
|
+
});
|
|
549
585
|
const sessionFile = this.session.getSessionFile?.() ?? null;
|
|
550
586
|
const expandedEnv = env
|
|
551
587
|
? Object.fromEntries(
|
|
@@ -568,6 +604,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
568
604
|
cwd: this.session.cwd,
|
|
569
605
|
}),
|
|
570
606
|
...expandedEnv,
|
|
607
|
+
...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
|
|
571
608
|
...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
|
|
572
609
|
};
|
|
573
610
|
|
|
@@ -682,6 +719,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
682
719
|
artifactPath,
|
|
683
720
|
artifactId,
|
|
684
721
|
oneShot: true,
|
|
722
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
723
|
+
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
685
724
|
onChunk: chunk => {
|
|
686
725
|
tailBuffer.append(chunk);
|
|
687
726
|
void reportProgress(tailBuffer.text(), {
|
|
@@ -729,6 +768,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
729
768
|
if (asyncRequested && !this.#asyncEnabled) {
|
|
730
769
|
throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
|
|
731
770
|
}
|
|
771
|
+
if (this.session.bashRestrictionProfile === "read-only" && pty) {
|
|
772
|
+
throw new ToolError("Read-only bash does not allow PTY mode.");
|
|
773
|
+
}
|
|
774
|
+
|
|
732
775
|
const prepared = await this.#prepareBashExecution(
|
|
733
776
|
{ command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
|
|
734
777
|
ctx,
|
|
@@ -787,7 +830,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
787
830
|
notices: pendingNotices,
|
|
788
831
|
});
|
|
789
832
|
}
|
|
790
|
-
const
|
|
833
|
+
const backgroundRequest = Promise.withResolvers<void>();
|
|
834
|
+
const unregisterBackgroundRequest = this.session.registerForegroundBashBackgroundRequestHandler?.(() => {
|
|
835
|
+
job.setBackgrounded(true);
|
|
836
|
+
backgroundRequest.resolve();
|
|
837
|
+
});
|
|
838
|
+
let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
839
|
+
try {
|
|
840
|
+
waitResult = await this.#waitForManagedBashJob(
|
|
841
|
+
job,
|
|
842
|
+
autoBackgroundWaitMs,
|
|
843
|
+
signal,
|
|
844
|
+
backgroundRequest.promise,
|
|
845
|
+
);
|
|
846
|
+
} finally {
|
|
847
|
+
unregisterBackgroundRequest?.();
|
|
848
|
+
}
|
|
791
849
|
if (waitResult.kind === "completed") {
|
|
792
850
|
autoBgManager.acknowledgeDeliveries([job.jobId]);
|
|
793
851
|
return waitResult.result;
|
|
@@ -810,7 +868,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
810
868
|
|
|
811
869
|
// Route through the client terminal when the client advertises the terminal capability.
|
|
812
870
|
// Skip when pty=true (PTY needs the local terminal UI).
|
|
813
|
-
const clientBridge =
|
|
871
|
+
const clientBridge =
|
|
872
|
+
this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
|
|
814
873
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
815
874
|
const handle = await clientBridge.createTerminal({
|
|
816
875
|
command,
|
|
@@ -983,7 +1042,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
983
1042
|
// Allocate artifact for truncated output storage
|
|
984
1043
|
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
|
|
985
1044
|
|
|
986
|
-
const interactiveUi =
|
|
1045
|
+
const interactiveUi =
|
|
1046
|
+
this.session.bashRestrictionProfile === "read-only"
|
|
1047
|
+
? undefined
|
|
1048
|
+
: canUseInteractiveBashPty(pty, ctx)
|
|
1049
|
+
? ctx?.ui
|
|
1050
|
+
: undefined;
|
|
987
1051
|
const result: BashResult | BashInteractiveResult = interactiveUi
|
|
988
1052
|
? await runInteractiveBashPty(interactiveUi, {
|
|
989
1053
|
command,
|
|
@@ -997,6 +1061,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
997
1061
|
: await executeBash(command, {
|
|
998
1062
|
cwd: commandCwd,
|
|
999
1063
|
sessionKey: this.session.getSessionId?.() ?? undefined,
|
|
1064
|
+
oneShot: this.session.bashRestrictionProfile === "read-only",
|
|
1000
1065
|
timeout: timeoutMs,
|
|
1001
1066
|
signal,
|
|
1002
1067
|
env: resolvedEnv,
|
|
@@ -1004,6 +1069,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1004
1069
|
artifactId,
|
|
1005
1070
|
onChunk: streamTailUpdates(tailBuffer, onUpdate),
|
|
1006
1071
|
onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
|
|
1072
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1073
|
+
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
1007
1074
|
});
|
|
1008
1075
|
if (result.cancelled) {
|
|
1009
1076
|
if (signal?.aborted) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as net from "node:net";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { Process, ProcessStatus } from "@gajae-code/natives";
|
|
3
4
|
import type { Browser, Page } from "puppeteer-core";
|
|
4
5
|
import { ToolError, throwIfAborted } from "../tool-errors";
|
|
@@ -62,6 +63,17 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
|
|
|
62
63
|
* accepts both `--flag=value` and `--flag value`). Returns null if absent or
|
|
63
64
|
* malformed.
|
|
64
65
|
*/
|
|
66
|
+
export function findCdpPortInArgsForTest(args: string[]): number | null {
|
|
67
|
+
return findCdpPortInArgs(args);
|
|
68
|
+
}
|
|
69
|
+
export function findCdpAddressInArgsForTest(args: readonly string[]): string | null {
|
|
70
|
+
return findCdpAddressInArgs(args);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isSafeCdpAddressForTest(address: string | null): boolean {
|
|
74
|
+
return isSafeCdpAddress(address);
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
function findCdpPortInArgs(args: string[]): number | null {
|
|
66
78
|
for (const arg of args) {
|
|
67
79
|
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
|
|
@@ -79,6 +91,41 @@ function findCdpPortInArgs(args: string[]): number | null {
|
|
|
79
91
|
return null;
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
function findArgValue(args: readonly string[], name: string): string | null {
|
|
95
|
+
const prefix = `${name}=`;
|
|
96
|
+
for (const arg of args) {
|
|
97
|
+
if (arg.startsWith(prefix)) return arg.slice(prefix.length);
|
|
98
|
+
}
|
|
99
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
100
|
+
if (args[i] === name) return args[i + 1] ?? null;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeProfilePath(input: string): string {
|
|
106
|
+
return path.resolve(input);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function argsMatchChromeProfileForTest(
|
|
110
|
+
args: readonly string[],
|
|
111
|
+
profile: { userDataDir: string; profileDirectory: string },
|
|
112
|
+
): boolean {
|
|
113
|
+
return argsMatchChromeProfile(args, profile);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function argsMatchChromeProfile(
|
|
117
|
+
args: readonly string[],
|
|
118
|
+
profile: { userDataDir: string; profileDirectory: string },
|
|
119
|
+
): boolean {
|
|
120
|
+
const userDataDir = findArgValue(args, "--user-data-dir");
|
|
121
|
+
const profileDirectory = findArgValue(args, "--profile-directory") ?? "Default";
|
|
122
|
+
return (
|
|
123
|
+
userDataDir !== null &&
|
|
124
|
+
normalizeProfilePath(userDataDir) === normalizeProfilePath(profile.userDataDir) &&
|
|
125
|
+
profileDirectory === profile.profileDirectory
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
82
129
|
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
|
|
83
130
|
async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
|
|
84
131
|
const probeTimeout = AbortSignal.timeout(1500);
|
|
@@ -93,10 +140,26 @@ async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean>
|
|
|
93
140
|
}
|
|
94
141
|
|
|
95
142
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
143
|
+
* Chromium binds remote debugging to loopback by default when no address is
|
|
144
|
+
* provided. Reuse only loopback CDP listeners; a matching profile launched with
|
|
145
|
+
* --remote-debugging-address=0.0.0.0, ::, or any LAN/public address is unsafe.
|
|
99
146
|
*/
|
|
147
|
+
function findCdpAddressInArgs(args: readonly string[]): string | null {
|
|
148
|
+
return findArgValue(args, "--remote-debugging-address");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSafeCdpAddress(address: string | null): boolean {
|
|
152
|
+
if (address === null || address.trim() === "") return true;
|
|
153
|
+
const normalized = address.trim().toLowerCase();
|
|
154
|
+
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "[::1]";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function unsafeCdpAddressReason(address: string): string {
|
|
158
|
+
return `Refusing to reuse Chrome profile CDP endpoint because --remote-debugging-address=${JSON.stringify(
|
|
159
|
+
address,
|
|
160
|
+
)} is not a loopback-only address. Restart Chrome with --remote-debugging-address=127.0.0.1 or omit the address flag.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
100
163
|
export async function findReusableCdp(
|
|
101
164
|
exe: string,
|
|
102
165
|
signal?: AbortSignal,
|
|
@@ -111,6 +174,8 @@ export async function findReusableCdp(
|
|
|
111
174
|
}
|
|
112
175
|
const port = findCdpPortInArgs(args);
|
|
113
176
|
if (port === null) continue;
|
|
177
|
+
const address = findCdpAddressInArgs(args);
|
|
178
|
+
if (!isSafeCdpAddress(address)) continue;
|
|
114
179
|
if (await probeCdpAt(port, signal)) {
|
|
115
180
|
return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid };
|
|
116
181
|
}
|
|
@@ -118,6 +183,41 @@ export async function findReusableCdp(
|
|
|
118
183
|
return null;
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
export interface RunningChromeProfile {
|
|
187
|
+
pid: number;
|
|
188
|
+
cdpUrl: string | null;
|
|
189
|
+
unsafeCdpReason?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function findRunningChromeProfile(
|
|
193
|
+
exe: string,
|
|
194
|
+
profile: { userDataDir: string; profileDirectory: string },
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<RunningChromeProfile | null> {
|
|
197
|
+
const candidates = Process.fromPath(exe).filter(p => p.status() === ProcessStatus.Running);
|
|
198
|
+
for (const proc of candidates) {
|
|
199
|
+
let args: string[];
|
|
200
|
+
try {
|
|
201
|
+
args = proc.args();
|
|
202
|
+
} catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!argsMatchChromeProfile(args, profile)) continue;
|
|
206
|
+
const port = findCdpPortInArgs(args);
|
|
207
|
+
if (port !== null) {
|
|
208
|
+
const address = findCdpAddressInArgs(args);
|
|
209
|
+
if (!isSafeCdpAddress(address)) {
|
|
210
|
+
return { pid: proc.pid, cdpUrl: null, unsafeCdpReason: unsafeCdpAddressReason(address ?? "") };
|
|
211
|
+
}
|
|
212
|
+
if (await probeCdpAt(port, signal)) {
|
|
213
|
+
return { pid: proc.pid, cdpUrl: `http://127.0.0.1:${port}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { pid: proc.pid, cdpUrl: null };
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
121
221
|
/**
|
|
122
222
|
* Pick the best page target on an attached browser. Without a matcher, prefer
|
|
123
223
|
* a page that doesn't look like a helper window (devtools, request handler,
|