Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ and finished help requests are not reopened. Allowing help makes `request-help`
not require every browser action to ask for permission. Task authorization and host approvals still apply.

Start tasks with `bsk session start`; add `--no-focus` to avoid focusing the Agent Window.
For a specific Chrome profile, use **Copy profile instructions** in that profile's extension
popup and send them to your agent. This pins each new session to its instance with `--browser`,
even when only one browser is online. See [browser profile selection](docs/browser-profiles.md).
For unattended operation, turn off the corresponding settings in the extension. `--unattended`,
`tab borrow --no-confirm`, and `BSK_REQUEST_HELP=off` remain accepted for compatibility but are
deprecated and cannot override the switches. The CLI logs a notice when these inputs are used;
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ bsk update --yes
允许人工协助意味着 `request-help` 可用,不代表每个浏览器操作都必须先请求许可;任务授权和宿主审批仍然有效。

正常使用 `bsk session start`;需要后台打开 Agent Window 时添加 `--no-focus`。
需要指定 Chrome Profile 时,在目标 Profile 的扩展弹窗中点击“复制此 Profile 的指令”,
再发给 Agent。指令通过 `--browser` 为每个新会话固定实例,即使只有一个浏览器在线也不省略。
详见[浏览器 Profile 选择](docs/browser-profiles.md)。
无人值守由用户在插件中关闭相应开关。`--unattended`、`tab borrow --no-confirm`、
`BSK_REQUEST_HELP=off` 保留兼容识别,但已弃用,不能覆盖插件开关。CLI 使用这些输入时会输出说明,
Daemon 也会为自身继承的旧环境设置记录说明。原先只依靠这些输入避免等待的脚本,现在需要遵循浏览器设置。
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { POPUP_FEATURES, type PopupView } from "./features";
import { InteractionSettings } from "./interaction-settings";
import { LongScreenshot } from "./long-screenshot";
import { ProfileInstructions } from "./profile-instructions";
import { SettingInfo } from "./setting-info";
import { Switch } from "./switch";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
Expand Down Expand Up @@ -232,6 +233,7 @@ export function App() {
connectionEnabled={snapshot.connectionEnabled}
disconnected={isDisconnected && !snapshot.lastError}
/>
<ProfileInstructions instanceId={snapshot.instanceId} connected={connectionLive} />
</section>

<section
Expand Down
84 changes: 84 additions & 0 deletions apps/extension/src/entrypoints/popup/profile-instructions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { i18n } from "@browser-skill/i18n";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProfileInstructions } from "./profile-instructions";

describe("profile instructions", () => {
beforeEach(async () => {
await i18n.changeLanguage("en-US");
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(async () => {
cleanup();
await i18n.changeLanguage("zh-CN");
vi.restoreAllMocks();
});
it("copies an instruction that pins every new session to this instance", async () => {
render(<ProfileInstructions instanceId="a1234567" connected />);
fireEvent.click(screen.getByRole("button", { name: "Copy profile instructions" }));
const text = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0];
expect(text).toContain("bsk session start --browser a1234567 --json");
expect(text).toContain('browser_session({ action: "start", browser: "a1234567" })');
expect(text).toContain("use the tool call instead of running the CLI command separately");
expect(text).toContain("every new session for this task");
expect(text).toContain("Do not omit --browser / browser or switch to another instance");
expect(await screen.findByRole("status")).toBeTruthy();
});
it.each([
{ instanceId: "a1234567", connected: false },
{ instanceId: "", connected: true },
])("does not copy an unavailable target: %j", (props) => {
render(<ProfileInstructions {...props} />);
const button = screen.getByRole("button", { name: "Copy profile instructions" });
expect(button.hasAttribute("disabled")).toBe(true);
fireEvent.click(button);
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
});
it("uses the current instance after a change and hides stale copy feedback", async () => {
let resolveCopy: () => void = () => {};
vi.mocked(navigator.clipboard.writeText).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveCopy = resolve;
}),
);
const { rerender } = render(<ProfileInstructions instanceId="a1234567" connected />);
fireEvent.click(screen.getByRole("button", { name: "Copy profile instructions" }));
rerender(<ProfileInstructions instanceId="b1234567" connected />);
await act(async () => resolveCopy());
expect(screen.queryByRole("status")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Copy profile instructions" }));
await screen.findByRole("status");
expect(vi.mocked(navigator.clipboard.writeText).mock.calls[1]?.[0]).toContain(
"bsk session start --browser b1234567 --json",
);
expect(vi.mocked(navigator.clipboard.writeText).mock.calls[1]?.[0]).toContain(
'browser_session({ action: "start", browser: "b1234567" })',
);
});
it("reports clipboard failure and allows retry without claiming success", async () => {
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error("clipboard denied"));
render(<ProfileInstructions instanceId="a1234567" connected />);
fireEvent.click(screen.getByRole("button", { name: "Copy profile instructions" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.queryByRole("status")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Copy profile instructions" }));
expect(await screen.findByRole("status")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it.each(["zh-CN", "ko-KR"])("preserves the exact command in %s instructions", async (locale) => {
await i18n.changeLanguage(locale);
render(<ProfileInstructions instanceId="a1234567" connected />);
fireEvent.click(screen.getByRole("button"));
expect(vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0]).toContain(
"bsk session start --browser a1234567 --json",
);
expect(vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0]).toContain(
'browser_session({ action: "start", browser: "a1234567" })',
);
expect(await screen.findByRole("status")).toBeTruthy();
});
});
62 changes: 62 additions & 0 deletions apps/extension/src/entrypoints/popup/profile-instructions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Button } from "@browser-skill/ui";
import { RiFileCopyLine } from "@remixicon/react";
import { useState } from "react";

export function ProfileInstructions({
instanceId,
connected,
}: {
instanceId: string;
connected: boolean;
}) {
const { t } = useTranslation("extension");
const [feedback, setFeedback] = useState<{
instanceId: string;
kind: "copied" | "failed";
} | null>(null);
const ready = connected && Boolean(instanceId);
const currentFeedback = ready && feedback?.instanceId === instanceId ? feedback.kind : null;

const copy = async () => {
if (!ready) return;
setFeedback(null);
try {
await navigator.clipboard.writeText(
t("popup.profile.promptTemplate", {
command: `bsk session start --browser ${instanceId} --json`,
toolCall: `browser_session({ action: "start", browser: ${JSON.stringify(instanceId)} })`,
}),
);
setFeedback({ instanceId, kind: "copied" });
} catch {
setFeedback({ instanceId, kind: "failed" });
}
};

return (
<div className="mt-3 space-y-2 border-t border-border/70 pt-2" data-slot="popup-profile">
<p className="text-[11px] leading-snug text-muted-foreground">{t("popup.profile.hint")}</p>
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={!ready}
onClick={() => void copy()}
data-slot="popup-profile-copy"
>
<RiFileCopyLine className="size-3.5" aria-hidden />
{t("popup.profile.copyButton")}
</Button>
{currentFeedback && (
<p
role={currentFeedback === "failed" ? "alert" : "status"}
className="text-[11px] leading-snug text-muted-foreground"
>
{t(currentFeedback === "failed" ? "popup.profile.copyFailed" : "popup.copied")}
</p>
)}
</div>
);
}
30 changes: 26 additions & 4 deletions crates/bsk-cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,34 @@ environment settings may not persist between shell calls. Keep browser commands
sandboxed. For other startup failures, retry once, then use `bsk doctor`.
A local process identity warning permits browser commands when IPC works.

## Required browser profiles

When the user requires a particular browser profile, bind the task to that
profile's extension instance before starting a session, even if only one browser
is connected. A Chrome profile name or directory is not a BrowserSkill instance
ID or an automatically assigned label.

Use the instance ID from the BrowserSkill popup in the required profile. The user
can choose **Copy profile instructions** there and send the resulting instruction.
If only a profile name/path is supplied and its mapping is unknown, ask the user
to open that profile, verify its Profile Path at `chrome://version`, and copy the
profile instructions. Do not infer the mapping from a single Connected browser
or Chrome process command lines.

Run `bsk browsers --json` to check that the supplied instance is connected, then
pass `--browser <instance-id>` on every new session for this task. A previously
verified unique label also works. If the target is missing or ambiguous, stop and
report it; never omit the selector or substitute another instance to recover.
Opening another Chrome profile does not retarget an existing session. After an
extension reinstall or storage reset, obtain the instance mapping again.

## Task workflow

1. Define success from the user's request. Start `bsk session start --json` and
retain its `session_id`. With multiple browsers, run `bsk browsers` and add
`--browser <id-or-label>` to start. For background work, add `--no-focus` to
`session start` only.
1. Define success from the user's request. For a required browser profile, follow
**Required browser profiles** above and start with its explicit `--browser`
selector. Otherwise start `bsk session start --json`; with multiple browsers,
run `bsk browsers` and choose `--browser <id-or-label>`. Retain the returned
`session_id`. For background work, add `--no-focus` to `session start` only.
2. For a new page, navigate; for an existing user tab, follow **Borrowing** below.
Read the page before interacting:

Expand Down
4 changes: 2 additions & 2 deletions crates/bsk-cli/src/cli/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ pub struct SessionStartArgs {
/// Optional task name displayed in local operation history.
#[arg(long)]
pub name: Option<String>,
/// Target browser instance id (only required when multiple browsers
/// are connected).
/// Target browser instance ID or unique label. Always set this when
/// a specific browser profile is required.
#[arg(long)]
pub browser: Option<String>,

Expand Down
72 changes: 72 additions & 0 deletions docs/browser-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Select a specific browser profile

BrowserSkill connects to the extension installed in a browser profile. A successful
connection alone does not identify the profile you intended to use. The CLI does
not launch Chrome or accept Chrome's `--profile-directory` argument.

## Bind a task to the intended profile

1. Open the required profile in Chrome. If you need to verify its directory, open
`chrome://version` in that window and check **Profile Path**.
2. Open BrowserSkill's popup in that same profile and ensure it is connected.
3. Choose **Copy profile instructions** and send the instructions along with your
task to the agent. The copied instruction includes this profile's extension
instance ID and requires it on every new session for the task.

You can also copy the **Instance ID** from the popup and use it directly:

```sh
bsk browsers --json
bsk session start --browser <instance-id> --json
```

Replace the placeholder with the popup's instance ID, not Chrome's extension ID,
profile display name or directory name. An existing, verified unique BrowserSkill
label also works; labels are not populated from Chrome profile names automatically.
The instruction only copies text: it does not start a session or change settings.

If the target is offline, reconnect BrowserSkill in that profile and retry the
same selector. Do not remove `--browser` to get past the error: that could select
another profile. A missing selector is rejected when multiple browsers are online,
but automatically selects the only connected browser when just one is online.

The session remains bound to its selected instance. Opening or switching Chrome
profiles does not move an existing session. Stop it with `bsk session stop <id>`
when the task ends. Reinstalling the extension or resetting its storage can change
the instance ID; verify the mapping again instead of substituting another browser.
Copying a whole profile can also copy its extension storage, so instance IDs are
routing identifiers, not independent proof of a filesystem path.

## DeepSeek Harness

Use the verified popup instance ID in the plugin's session tool:

```text
browser_session({ action: "start", browser: "<verified-instance-id>" })
```

**Copy profile instructions** includes this tool call alongside the CLI command.
Use the DeepSeek Harness call instead of running a separate CLI session. The plugin
manages its own sessions. Supply the same selector on every new session for the task, even if
only one browser is connected. If the mapping is unknown or the target is offline,
ask the user to confirm or reconnect it; do not retry without `browser`.

## Scope and limitations

This is a manual profile-to-instance workflow, not automatic profile discovery or
a persisted task/workspace requirement. Explicit selectors reject unavailable or
ambiguous targets. Neither the CLI nor the DSH plugin can infer a profile requirement
from the user's conversation if the agent omits the selector: with only one browser
connected, an unqualified start can still select the wrong profile. The skill and
copied instructions guide the agent; they do not enforce a binding on future calls.

## Windows and macOS

The instance-selection workflow is the same on both platforms. The profile path
location differs; use the value displayed by Chrome rather than guessing it.
[Chromium documents this check](https://chromium.googlesource.com/chromium/src/+/main/docs/user_data_dir.md).

Several profiles can run in one Chrome browser process. Its startup command line
may still name the first profile after a second profile opens, so scanning process
arguments cannot prove which profile owns a connected extension. There is no need
to quit all other Chrome profiles to select a connected BrowserSkill instance.
27 changes: 25 additions & 2 deletions packages/dsh-plugin-browserskill/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,31 @@ Do not control the browser through another process. Use the loaded action schema

For remote setup or pairing, follow the [remote guide](https://github.com/Tencent/BrowserSkill/blob/main/docs/remote-extension-connection.md) before using these tools.

## Required browser profiles

If the user or workspace requires a specific profile, confirm its instance ID before
starting, even with only one connected browser. If unknown, ask the user to open the
intended profile, check **Profile Path** at `chrome://version` if a directory was
specified, and copy the **Instance ID** or **Copy profile instructions** from the
connected BrowserSkill popup in that same profile. Connected alone and Chrome's
process arguments do not prove the profile.

Use the verified ID (or verified unique BrowserSkill label) on every new session:

```text
browser_session({ action: "start", browser: "<verified-instance-id>" })
```

If the copied instructions contain a command-line example, use its instance ID in
this tool call; do not run that command separately. A Chrome profile name, directory,
or extension ID is not an instance ID. If the mapping is unclear, ambiguous, or the
target is unavailable, stop and ask the user to confirm or reconnect it. Never omit
`browser` or substitute another instance to recover.

## Mandatory workflow

1. Define success. Start a session and retain `sessionId`. For a new page:
1. Define success. Start a session and retain `sessionId`. Include `browser` as above
when a profile is required. Otherwise, for a new page:

```text
browser_session({ action: "start" })
Expand Down Expand Up @@ -83,7 +105,8 @@ unknown effects or switch backends to bypass limits. Borrow confirmation still a
again (users can enter `/browser-skill`), then retry the intended browser tool once
after its schema appears. If it remains unavailable, report the failure.
- Stale ref: observe, then retry the intended action once.
- Unknown tab/session: list owned resources or start a session; never guess IDs.
- Unknown tab/session: list owned resources or start a session with the required
browser selector, if any; never guess IDs.
- Failed or interrupted session stop: accepted cleanup continues in the background.
Retry the same stop; a completed previous stop returns `alreadyClosed: true`.
If several stops are pending, specify `session` or the owned `requestId` from the
Expand Down
10 changes: 7 additions & 3 deletions packages/dsh-plugin-browserskill/src/browser-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { defineTool, type ParameterSchemaSpec, type ToolDefinition } from "@deepseek-ai/dsh-tools";
import {
BROWSER_PARAM,
SESSION_PARAM,
SESSION_STOP_PARAMS,
TAB_ID_PARAM,
Expand Down Expand Up @@ -104,8 +105,11 @@ const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
name: "browser_session",
description:
"Manage plugin-owned browser sessions. Actions: start opens an Agent Window; stop closes an " +
"owned session; list returns owned sessions. For start, url/device/width/height/noFocus/browser " +
"are optional. For stop, specify session or requestId (not both), or omit both to retry an " +
"owned session; list returns owned sessions. For start, url/device/width/height/noFocus are " +
"optional. When a specific profile is required, always set browser to its verified instance " +
"ID or unique label, even with one connected browser; stop if the target is unknown or " +
"unavailable instead of omitting or changing browser. For stop, specify session or requestId " +
"(not both), or omit both to retry an " +
"unacknowledged stop before selecting the current owned session. If several stops await " +
"acknowledgement, specify a target. Once accepted, cleanup continues if the call is aborted.",
actions: {
Expand All @@ -119,7 +123,7 @@ const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
width: { type: "integer", description: "Agent Window width; start requires height too." },
height: { type: "integer", description: "Agent Window height; start requires width too." },
noFocus: { type: "boolean", description: "Start the Agent Window in the background." },
browser: { type: "string", description: "Browser instance id for start." },
browser: BROWSER_PARAM,
device: { type: "string", enum: DEVICE_PRESETS, description: "Device preset for start." },
},
},
Expand Down
Loading
Loading