mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9504072914 | ||
|
|
b4450fbb8c | ||
|
|
1268761611 | ||
|
|
37d3e2b22f | ||
|
|
ce8bc6c870 | ||
|
|
93df62a356 | ||
|
|
20002e1ff2 | ||
|
|
d0bee1a09b | ||
|
|
aab3b7a8e5 |
@@ -126,3 +126,202 @@ jobs:
|
||||
make_latest: false
|
||||
generate_release_notes: true
|
||||
files: dist/*.zip
|
||||
|
||||
publish-cws:
|
||||
needs: [resolve, build-extension]
|
||||
runs-on: ubuntu-latest
|
||||
environment: chrome-web-store
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: extension-zip
|
||||
path: dist
|
||||
|
||||
- name: Validate Chrome Web Store configuration
|
||||
shell: bash
|
||||
env:
|
||||
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
|
||||
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$PUBLISHER_ID" ]; then
|
||||
echo "::error::Repository variable CHROME_WEBSTORE_PUBLISHER_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$EXTENSION_ID" ]; then
|
||||
echo "::error::Repository variable CHROME_WEBSTORE_EXTENSION_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||
if [ -z "$ARCHIVE" ]; then
|
||||
echo "::error::No extension zip found in the downloaded artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Authenticate with service account
|
||||
id: cws-auth
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'NODE'
|
||||
import { sign } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const rawCredentials = process.env.SERVICE_ACCOUNT_JSON;
|
||||
if (!rawCredentials) {
|
||||
throw new Error(
|
||||
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not configured",
|
||||
);
|
||||
}
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(rawCredentials);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not valid JSON",
|
||||
);
|
||||
}
|
||||
if (!credentials.client_email || !credentials.private_key) {
|
||||
throw new Error(
|
||||
"Service account JSON must contain client_email and private_key",
|
||||
);
|
||||
}
|
||||
|
||||
const encode = (value) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
kid: credentials.private_key_id,
|
||||
};
|
||||
const claim = {
|
||||
iss: credentials.client_email,
|
||||
scope: "https://www.googleapis.com/auth/chromewebstore",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + 3600,
|
||||
};
|
||||
const unsignedJwt = `${encode(header)}.${encode(claim)}`;
|
||||
const signature = sign(
|
||||
"RSA-SHA256",
|
||||
Buffer.from(unsignedJwt),
|
||||
credentials.private_key,
|
||||
).toString("base64url");
|
||||
const assertion = `${unsignedJwt}.${signature}`;
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Service account token exchange failed (${response.status}): ${responseText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenResponse = JSON.parse(responseText);
|
||||
if (!tokenResponse.access_token) {
|
||||
throw new Error("Service account token response has no access_token");
|
||||
}
|
||||
|
||||
console.log(`::add-mask::${tokenResponse.access_token}`);
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`access_token=${tokenResponse.access_token}\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload and publish Chrome Web Store extension
|
||||
shell: bash
|
||||
env:
|
||||
ACCESS_TOKEN: ${{ steps.cws-auth.outputs.access_token }}
|
||||
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
|
||||
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ITEM_NAME="publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}"
|
||||
API_ROOT="https://chromewebstore.googleapis.com"
|
||||
UPLOAD_RESPONSE="$RUNNER_TEMP/cws-upload-response.json"
|
||||
STATUS_RESPONSE="$RUNNER_TEMP/cws-status-response.json"
|
||||
PUBLISH_RESPONSE="$RUNNER_TEMP/cws-publish-response.json"
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$UPLOAD_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
--header "Content-Type: application/zip" \
|
||||
--data-binary "@$ARCHIVE" \
|
||||
"$API_ROOT/upload/v2/$ITEM_NAME:upload")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store upload failed with HTTP $HTTP_STATUS"
|
||||
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_STATE="$(jq -r '.uploadState // empty' "$UPLOAD_RESPONSE")"
|
||||
echo "Chrome Web Store upload state: $UPLOAD_STATE"
|
||||
|
||||
if [ "$UPLOAD_STATE" = "IN_PROGRESS" ] || [ "$UPLOAD_STATE" = "UPLOAD_IN_PROGRESS" ]; then
|
||||
for attempt in $(seq 1 30); do
|
||||
sleep 5
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$STATUS_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
"$API_ROOT/v2/$ITEM_NAME:fetchStatus")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store status check failed with HTTP $HTTP_STATUS"
|
||||
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_STATE="$(jq -r '.lastAsyncUploadState // empty' "$STATUS_RESPONSE")"
|
||||
echo "Upload status check $attempt/30: $UPLOAD_STATE"
|
||||
if [ "$UPLOAD_STATE" = "SUCCEEDED" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$UPLOAD_STATE" != "IN_PROGRESS" ] && [ "$UPLOAD_STATE" != "UPLOAD_IN_PROGRESS" ]; then
|
||||
echo "::error::Chrome Web Store upload did not succeed: $UPLOAD_STATE"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$UPLOAD_STATE" != "SUCCEEDED" ]; then
|
||||
echo "::error::Chrome Web Store upload did not complete: $UPLOAD_STATE"
|
||||
jq . "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$PUBLISH_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"publishType":"DEFAULT_PUBLISH"}' \
|
||||
"$API_ROOT/v2/$ITEM_NAME:publish")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store publish failed with HTTP $HTTP_STATUS"
|
||||
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Chrome Web Store submission created successfully:"
|
||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||
|
||||
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bsk-protocol",
|
||||
@@ -168,7 +168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk-protocol"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"semver",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.30589 17.9712C6.21652 18.4603 6.63298 18.8722 7.12749 18.7838C7.88158 18.6491 8.86635 18.4075 9.25454 18.0236C9.49544 17.7853 9.61164 17.4432 9.57757 17.0726C9.5435 16.702 9.36195 16.3331 9.07287 16.0472C8.78378 15.7613 8.41084 15.5818 8.03608 15.5481C7.66133 15.5144 7.31545 15.6293 7.07455 15.8676C6.6865 16.2513 6.4422 17.2253 6.30589 17.9712Z" fill="#E94500"/>
|
||||
<path d="M7.77425 8.64281C10.7374 4.12636 15.4876 3.7066 17.9364 4.13499C18.3335 4.20461 18.6474 4.48261 18.7821 4.84105C20.6715 9.87191 18.4167 13.7056 16.5008 15.5334C16.1262 15.8909 15.9395 16.3745 16.0282 16.8215L16.12 17.2834C16.24 17.887 15.871 18.4819 15.2723 18.6496L12.7811 19.3469C12.1057 19.536 11.4124 19.1112 11.285 18.4289L11.036 17.0959C10.9844 16.82 10.8353 16.5712 10.6151 16.3948L8.12777 14.4035C7.90665 14.2266 7.62818 14.1345 7.34359 14.1428L5.72835 14.1897C4.9877 14.2112 4.40832 13.5657 4.51644 12.8401L4.89632 10.2912C4.98194 9.71734 5.47627 9.2895 6.06234 9.28148L6.69124 9.27171C7.11716 9.26594 7.52243 9.02649 7.77425 8.64281ZM14.4959 7.91429C13.8284 7.91436 13.2869 8.45568 13.2869 9.12327C13.2871 9.79078 13.8284 10.3322 14.4959 10.3323C15.1635 10.3323 15.7048 9.79082 15.7049 9.12327C15.7049 8.45564 15.1636 7.91429 14.4959 7.91429Z" fill="#E94500"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAPTURE_SUPPRESS, type CaptureSuppressAck } from "@/lib/capture-suppress-bridge";
|
||||
import { CAPTURE_HIDDEN_ATTR, createCaptureSuppressController } from "../capture-suppress";
|
||||
|
||||
/**
|
||||
* rAF is stubbed with a manual queue so tests can drive the compositor
|
||||
* frame-by-frame and assert the ack only lands after the second frame.
|
||||
*/
|
||||
describe("capture-suppress controller", () => {
|
||||
let rafQueue: FrameRequestCallback[];
|
||||
let host: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
rafQueue.push(cb);
|
||||
return rafQueue.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
document.body.innerHTML = "";
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
});
|
||||
|
||||
/** Run every queued rAF callback, draining microtasks between rounds. */
|
||||
async function flushFrames(rounds = 4): Promise<void> {
|
||||
for (let round = 0; round < rounds && rafQueue.length > 0; round += 1) {
|
||||
const callbacks = rafQueue.splice(0);
|
||||
for (const cb of callbacks) cb(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
it("begin hides the host immediately but only acks after two frames", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "begin" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(true);
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// First frame commits the style change; the ack must still be pending.
|
||||
await flushFrames(1);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// Second frame: the compositor has produced an overlay-free frame.
|
||||
await flushFrames();
|
||||
const expected: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
expect(sendResponse).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it("end removes the attribute once the count returns to zero", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
const sendResponse = vi.fn();
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "end" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("reference-counts concurrent begins so one end does not unhide early", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(controller.pendingCount).toBe(2);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a stray end as a no-op", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, sendResponse);
|
||||
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("re-applies the attribute when the host is remounted mid-capture", () => {
|
||||
let currentHost: HTMLElement | null = host;
|
||||
const controller = createCaptureSuppressController(() => currentHost);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
|
||||
// Host lost and rebuilt before `end` arrives.
|
||||
currentHost = null;
|
||||
const rebuilt = document.createElement("div");
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a remounted host visible once suppression has ended", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const rebuilt = document.createElement("div");
|
||||
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Content-script side of the capture-suppress bridge: hides the overlay
|
||||
* shadow host while the background captures a screenshot.
|
||||
*
|
||||
* `begin` sets `data-bsk-capture-hidden` on the host (overlay.css maps it
|
||||
* to `display: none !important`, which removes the whole shadow subtree
|
||||
* from compositing) and only acks after two animation frames — the first
|
||||
* frame commits the style change, the second guarantees the compositor
|
||||
* has produced a frame without the overlay, so the capture API cannot
|
||||
* pick up a stale frame that still contains it. `end` is
|
||||
* reference-counted so overlapping captures do not unhide the host early.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
|
||||
export const CAPTURE_HIDDEN_ATTR = "data-bsk-capture-hidden";
|
||||
|
||||
/**
|
||||
* Safety net for hidden/background tabs where rAF callbacks never fire:
|
||||
* the attribute is already set synchronously, so any frame the compositor
|
||||
* produces from now on excludes the overlay. Waiting longer would just
|
||||
* deadlock the capture.
|
||||
*/
|
||||
const FRAME_FALLBACK_MS = 500;
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
resolve();
|
||||
}, FRAME_FALLBACK_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export interface CaptureSuppressController {
|
||||
/**
|
||||
* Handle one bridge message. Returns true when the ack is sent
|
||||
* asynchronously and the caller must keep the message channel open.
|
||||
*/
|
||||
handleMessage(
|
||||
message: CaptureSuppressMessage,
|
||||
sendResponse: (ack: CaptureSuppressAck) => void,
|
||||
): boolean;
|
||||
/** Re-apply the hidden attribute when the overlay host is (re)mounted. */
|
||||
onHostMounted(host: HTMLElement): void;
|
||||
/** Number of in-flight `begin` phases (exposed for tests). */
|
||||
readonly pendingCount: number;
|
||||
}
|
||||
|
||||
export function createCaptureSuppressController(
|
||||
getHost: () => HTMLElement | null,
|
||||
): CaptureSuppressController {
|
||||
let pending = 0;
|
||||
|
||||
const ack: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
|
||||
function setHidden(host: HTMLElement | null, hidden: boolean): void {
|
||||
if (!host) return;
|
||||
if (hidden) host.setAttribute(CAPTURE_HIDDEN_ATTR, "");
|
||||
else host.removeAttribute(CAPTURE_HIDDEN_ATTR);
|
||||
}
|
||||
|
||||
return {
|
||||
get pendingCount() {
|
||||
return pending;
|
||||
},
|
||||
handleMessage(message, sendResponse) {
|
||||
if (message.phase === "begin") {
|
||||
pending += 1;
|
||||
setHidden(getHost(), true);
|
||||
void (async () => {
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
sendResponse(ack);
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
// `end` without a matching `begin` (e.g. a retried message) must not
|
||||
// drive the counter negative or clear a concurrent capture's flag.
|
||||
pending = Math.max(0, pending - 1);
|
||||
if (pending === 0) setHidden(getHost(), false);
|
||||
sendResponse(ack);
|
||||
return false;
|
||||
},
|
||||
onHostMounted(host) {
|
||||
// Host lost + rebuilt mid-capture: the fresh host must stay hidden
|
||||
// until the last `end` arrives.
|
||||
setHidden(host, pending > 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,3 +14,10 @@
|
||||
:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Screenshot suppression: the host lives in the main DOM, so hiding it
|
||||
removes the whole shadow subtree from compositing and captured frames
|
||||
only contain page content. */
|
||||
:host([data-bsk-capture-hidden]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { flushSync } from "react-dom";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
|
||||
import { ControlOverlay } from "@/content/ControlOverlay";
|
||||
import { createCaptureSuppressController } from "@/content/capture-suppress";
|
||||
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
|
||||
import overlayCss from "@/content/overlay.css?inline";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
isRecordContentMessage,
|
||||
type RecordCaptureController,
|
||||
} from "@/content/record-capture";
|
||||
import {
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
isCaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
import {
|
||||
HELP_ACK,
|
||||
HELP_FINISH,
|
||||
@@ -70,6 +76,8 @@ export default defineContentScript({
|
||||
let hostLossReported = false;
|
||||
let remountInProgress = false;
|
||||
|
||||
const captureSuppress = createCaptureSuppressController(() => overlayHost);
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: "browser-skill-overlay",
|
||||
position: "inline",
|
||||
@@ -81,6 +89,8 @@ export default defineContentScript({
|
||||
overlayHost = shadowHost;
|
||||
overlayContainer = container;
|
||||
hostLossReported = false;
|
||||
// A host rebuilt mid-capture must stay hidden until `end` arrives.
|
||||
captureSuppress.onHostMounted(shadowHost);
|
||||
const app = document.createElement("div");
|
||||
app.className = "bsk-overlay-root";
|
||||
container.append(app);
|
||||
@@ -238,12 +248,17 @@ export default defineContentScript({
|
||||
| BorrowCancelMessage
|
||||
| HelpRequestMessage
|
||||
| HelpCancelMessage
|
||||
| CaptureSuppressMessage
|
||||
| OverlayAgentOverlayResetMessage
|
||||
| OverlayAgentStateMessage
|
||||
| OverlayAutomationBypassMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpAckMessage) => void,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
|
||||
) => {
|
||||
if (isCaptureSuppressMessage(message)) {
|
||||
return captureSuppress.handleMessage(message, sendResponse);
|
||||
}
|
||||
|
||||
if (isRecordContentMessage(message)) {
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
message,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckLine,
|
||||
RiFileCopyLine,
|
||||
} from "@remixicon/react";
|
||||
import { RiArrowLeftLine, RiArrowRightSLine, RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
import { PROTOCOL_VERSION } from "@/transport/handshake";
|
||||
import functionIconUrl from "../../../assets/function.svg";
|
||||
import { ConnectionStatusIndicator } from "./connection-status-indicator";
|
||||
import { POPUP_FEATURES, type PopupView } from "./features";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
@@ -156,12 +151,17 @@ export function App() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
className="group size-8 shrink-0 rounded-md"
|
||||
aria-label={t("popup.launcher.title")}
|
||||
onClick={() => setView("features")}
|
||||
data-slot="popup-launcher"
|
||||
>
|
||||
<RiApps2Line className="size-4" aria-hidden />
|
||||
<img
|
||||
src={functionIconUrl}
|
||||
alt=""
|
||||
className="size-6 opacity-35 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
data-slot="popup-launcher-icon"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressMessage,
|
||||
withOverlaysHiddenForCapture,
|
||||
} from "../capture-suppress-bridge";
|
||||
|
||||
function recordingSendToTab(events: string[]) {
|
||||
return vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
|
||||
events.push(message.phase);
|
||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
describe("withOverlaysHiddenForCapture", () => {
|
||||
it("sends begin → fn → end and returns fn's result", async () => {
|
||||
const events: string[] = [];
|
||||
const sendToTab = recordingSendToTab(events);
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(
|
||||
7,
|
||||
async () => {
|
||||
events.push("capture");
|
||||
return "shot";
|
||||
},
|
||||
sendToTab,
|
||||
);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "begin" });
|
||||
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "end" });
|
||||
});
|
||||
|
||||
it("still sends end when fn throws, then rethrows", async () => {
|
||||
const events: string[] = [];
|
||||
const sendToTab = recordingSendToTab(events);
|
||||
|
||||
await expect(
|
||||
withOverlaysHiddenForCapture(
|
||||
7,
|
||||
async () => {
|
||||
events.push("capture");
|
||||
throw new Error("capture exploded");
|
||||
},
|
||||
sendToTab,
|
||||
),
|
||||
).rejects.toThrow("capture exploded");
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
});
|
||||
|
||||
it("captures directly when the tab has no content script", async () => {
|
||||
const sendToTab = vi.fn(async () => {
|
||||
throw new Error("Could not establish connection. Receiving end does not exist.");
|
||||
});
|
||||
const fn = vi.fn(async () => "shot");
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(7, fn, sendToTab);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
// begin failed → no matching end may be sent.
|
||||
expect(sendToTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows end failures so the capture result stands", async () => {
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
|
||||
if (message.phase === "end") throw new Error("tab navigated away");
|
||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||
});
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(7, async () => "shot", sendToTab);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(sendToTab).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wire protocol and background-side helper for hiding the extension's
|
||||
* in-page overlay while a screenshot is captured, so captured frames only
|
||||
* contain the page itself.
|
||||
*
|
||||
* The background wraps every capture in `withOverlaysHiddenForCapture`,
|
||||
* which sends `begin` (the content script hides the overlay host and only
|
||||
* acks once the compositor has produced an overlay-free frame) and always
|
||||
* follows up with `end`, even when the capture throws. Tabs without the
|
||||
* content script (chrome://, the Web Store, ...) host no overlay, so a
|
||||
* failed `begin` falls through to capturing directly.
|
||||
*/
|
||||
|
||||
export const CAPTURE_SUPPRESS = "bsk/capture-suppress";
|
||||
|
||||
export type CaptureSuppressPhase = "begin" | "end";
|
||||
|
||||
export interface CaptureSuppressMessage {
|
||||
type: typeof CAPTURE_SUPPRESS;
|
||||
phase: CaptureSuppressPhase;
|
||||
}
|
||||
|
||||
export interface CaptureSuppressAck {
|
||||
type: typeof CAPTURE_SUPPRESS;
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export function isCaptureSuppressMessage(msg: unknown): msg is CaptureSuppressMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === CAPTURE_SUPPRESS && (m.phase === "begin" || m.phase === "end");
|
||||
}
|
||||
|
||||
/** Minimal `chrome.tabs.sendMessage` surface so tests can inject a fake. */
|
||||
export type CaptureSuppressSendToTab = (
|
||||
tabId: number,
|
||||
message: CaptureSuppressMessage,
|
||||
) => Promise<unknown>;
|
||||
|
||||
const defaultSendToTab: CaptureSuppressSendToTab = (tabId, message) =>
|
||||
chrome.tabs.sendMessage(tabId, message);
|
||||
|
||||
/**
|
||||
* Run `fn` with the target tab's overlay hidden. `end` is sent from a
|
||||
* `finally` so the overlay always reappears, and a missing content script
|
||||
* (or any other `begin` failure) simply skips suppression — there is no
|
||||
* overlay to hide in that tab.
|
||||
*/
|
||||
export async function withOverlaysHiddenForCapture<T>(
|
||||
tabId: number,
|
||||
fn: () => Promise<T>,
|
||||
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
|
||||
): Promise<T> {
|
||||
let began = false;
|
||||
try {
|
||||
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "begin" });
|
||||
began = true;
|
||||
} catch {
|
||||
// No content script in the target tab → no overlay can leak into the
|
||||
// capture; proceed without suppression.
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (began) {
|
||||
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "end" }).catch((err) => {
|
||||
console.debug("[bsk capture] overlay restore failed", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderVom } from "@browser-skill/vom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CAPTURE_SUPPRESS, type CaptureSuppressMessage } from "@/lib/capture-suppress-bridge";
|
||||
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "@/lib/overlay-bridge";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
@@ -345,6 +346,98 @@ describe("handleScreenshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleScreenshot overlay suppression", () => {
|
||||
/**
|
||||
* Fake background→content bridge that mirrors the content script's
|
||||
* contract: `begin` hides the overlay, `end` restores it. Records the
|
||||
* phase order so tests can assert begin → capture → end.
|
||||
*/
|
||||
function fakeSuppressBridge(events: string[]) {
|
||||
let overlayHidden = false;
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
|
||||
events.push(message.phase);
|
||||
overlayHidden = message.phase === "begin";
|
||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||
});
|
||||
return { sendToTab, isHidden: () => overlayHidden };
|
||||
}
|
||||
|
||||
it("hides the overlay around a visible-tab capture", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const events: string[] = [];
|
||||
const bridge = fakeSuppressBridge(events);
|
||||
const capture = vi.fn(async (_w: number) => {
|
||||
events.push("capture");
|
||||
// The overlay must already be hidden when the capture runs.
|
||||
expect(bridge.isHidden()).toBe(true);
|
||||
return `data:image/png;base64,${TINY_PNG}`;
|
||||
});
|
||||
const res = await handleScreenshot(
|
||||
sm,
|
||||
{ session_id: "aa11" },
|
||||
{ ...makeScreenshotDeps({ captureVisibleTab: capture }), sendToTab: bridge.sendToTab },
|
||||
);
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.image_base64).toBe(TINY_PNG);
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
expect(bridge.isHidden()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the overlay around a CDP element capture", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e5", 999, { tabId: 7 });
|
||||
const events: string[] = [];
|
||||
const bridge = fakeSuppressBridge(events);
|
||||
const { cdp } = makeFakeCdp({
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
|
||||
"Page.captureScreenshot": () => {
|
||||
events.push("capture");
|
||||
expect(bridge.isHidden()).toBe(true);
|
||||
return { data: TINY_PNG };
|
||||
},
|
||||
});
|
||||
const res = await handleScreenshot(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
|
||||
{
|
||||
...makeScreenshotDeps({
|
||||
cdp,
|
||||
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
|
||||
query: vi.fn(),
|
||||
captureVisibleTab: vi.fn(),
|
||||
}),
|
||||
sendToTab: bridge.sendToTab,
|
||||
},
|
||||
);
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.image_base64).toBe(TINY_PNG);
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
expect(bridge.isHidden()).toBe(false);
|
||||
});
|
||||
|
||||
it("still captures when the tab has no content script to ack", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const sendToTab = vi.fn(async () => {
|
||||
throw new Error("Could not establish connection. Receiving end does not exist.");
|
||||
});
|
||||
const capture = vi.fn(async (_w: number) => `data:image/png;base64,${TINY_PNG}`);
|
||||
const res = await handleScreenshot(
|
||||
sm,
|
||||
{ session_id: "aa11" },
|
||||
{ ...makeScreenshotDeps({ captureVisibleTab: capture }), sendToTab },
|
||||
);
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.image_base64).toBe(TINY_PNG);
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
// The failed begin must not be followed by an end.
|
||||
expect(sendToTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildVomScene
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
type VomScene,
|
||||
} from "@browser-skill/vom";
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import {
|
||||
type CaptureSuppressSendToTab,
|
||||
withOverlaysHiddenForCapture,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type {
|
||||
GetHtmlParams,
|
||||
@@ -114,6 +118,12 @@ export interface ScreenshotDeps {
|
||||
cdp?: SharedCdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
captureApi: ChromeTabsCaptureApi;
|
||||
/**
|
||||
* Bridge used to hide the in-page overlay while a screenshot is taken,
|
||||
* so captured frames only contain page content. Defaults to
|
||||
* `chrome.tabs.sendMessage`; tests inject a fake.
|
||||
*/
|
||||
sendToTab?: CaptureSuppressSendToTab;
|
||||
}
|
||||
|
||||
function defaultScreenshotDeps(): ScreenshotDeps {
|
||||
@@ -192,7 +202,12 @@ export async function handleScreenshot(
|
||||
if (isRpcError(node)) return node;
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||
const captured = await captureElementScreenshot(deps.cdp, target.tabId, node.backendNodeId);
|
||||
const cdp = deps.cdp;
|
||||
const captured = await withOverlaysHiddenForCapture(
|
||||
target.tabId,
|
||||
() => captureElementScreenshot(cdp, target.tabId, node.backendNodeId),
|
||||
deps.sendToTab,
|
||||
);
|
||||
if (isRpcError(captured)) return captured;
|
||||
return withShotDialogs({
|
||||
image_base64: captured.image_base64,
|
||||
@@ -212,7 +227,11 @@ export async function handleScreenshot(
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" });
|
||||
const dataUrl = await withOverlaysHiddenForCapture(
|
||||
target.tabId,
|
||||
() => deps.captureApi.captureVisibleTab(target.windowId, { format: "png" }),
|
||||
deps.sendToTab,
|
||||
);
|
||||
const image_base64 = stripDataUrlPrefix(dataUrl);
|
||||
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
|
||||
return withShotDialogs({
|
||||
|
||||
@@ -21,6 +21,7 @@ pub enum HarnessId {
|
||||
Workbuddy,
|
||||
PiAgent,
|
||||
Hermes,
|
||||
KimiCode,
|
||||
}
|
||||
|
||||
impl HarnessId {
|
||||
@@ -36,6 +37,7 @@ impl HarnessId {
|
||||
HarnessId::Workbuddy,
|
||||
HarnessId::PiAgent,
|
||||
HarnessId::Hermes,
|
||||
HarnessId::KimiCode,
|
||||
];
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
@@ -55,6 +57,7 @@ impl HarnessId {
|
||||
HarnessId::Workbuddy => "workbuddy",
|
||||
HarnessId::PiAgent => "pi",
|
||||
HarnessId::Hermes => "hermes",
|
||||
HarnessId::KimiCode => "kimi-code",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ impl HarnessId {
|
||||
HarnessId::Workbuddy => "WorkBuddy",
|
||||
HarnessId::PiAgent => "Pi",
|
||||
HarnessId::Hermes => "Hermes Agent",
|
||||
HarnessId::KimiCode => "Kimi Code",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +91,10 @@ impl HarnessId {
|
||||
HarnessId::Workbuddy => home.join(".workbuddy").join("skills"),
|
||||
HarnessId::PiAgent => home.join(".pi").join("agent").join("skills"),
|
||||
HarnessId::Hermes => hermes_home_for_user_home(home).join("skills"),
|
||||
// Kimi Code CLI 用户级 skills 目录:`$KIMI_CODE_HOME/skills`,
|
||||
// 默认 ~/.kimi-code/skills,见
|
||||
// https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/skills.md
|
||||
HarnessId::KimiCode => kimi_code_home_for_user_home(home).join("skills"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +223,23 @@ impl HarnessId {
|
||||
push("`hermes` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::KimiCode => {
|
||||
let kimi_home = kimi_code_home_for_user_home(home);
|
||||
if kimi_home.is_dir() {
|
||||
let label = if std::env::var("KIMI_CODE_HOME")
|
||||
.ok()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
format!("$KIMI_CODE_HOME ({})", kimi_home.display())
|
||||
} else {
|
||||
"~/.kimi-code".to_string()
|
||||
};
|
||||
push(&label);
|
||||
}
|
||||
if command_exists("kimi") {
|
||||
push("`kimi` on PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detected = !signals.is_empty();
|
||||
@@ -265,6 +290,7 @@ pub fn parse_harness_id(raw: &str) -> Result<HarnessId> {
|
||||
"workbuddy" | "work-buddy" => Ok(HarnessId::Workbuddy),
|
||||
"pi" | "pi-agent" | "pi_agent" | "piagent" => Ok(HarnessId::PiAgent),
|
||||
"hermes" | "hermes-agent" | "hermes_agent" | "hermesagent" => Ok(HarnessId::Hermes),
|
||||
"kimi-code" | "kimi" | "kimicode" | "kimi-cli" | "kimi_cli" => Ok(HarnessId::KimiCode),
|
||||
other => bail!(
|
||||
"unknown harness '{other}'; expected one of: {}",
|
||||
HarnessId::ALL
|
||||
@@ -280,6 +306,18 @@ pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
|
||||
}
|
||||
|
||||
/// Kimi Code CLI 的数据根目录:`KIMI_CODE_HOME` 设置时用之,否则 `~/.kimi-code`
|
||||
/// (见 kimi-code 官方 skills 文档:用户级 skills 在 `$KIMI_CODE_HOME/skills`)。
|
||||
fn kimi_code_home_for_user_home(home: &Path) -> PathBuf {
|
||||
if let Ok(override_home) = std::env::var("KIMI_CODE_HOME") {
|
||||
let trimmed = override_home.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
home.join(".kimi-code")
|
||||
}
|
||||
|
||||
/// Mirrors [`get_hermes_home()`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_constants.py):
|
||||
/// `HERMES_HOME` when set, else `%LOCALAPPDATA%\\hermes` on native Windows, else `~/.hermes`.
|
||||
fn hermes_home_for_user_home(home: &Path) -> PathBuf {
|
||||
@@ -360,6 +398,12 @@ fn command_exists(name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 读写 KIMI_CODE_HOME 环境变量的测试共用的互斥锁(Rust 测试默认并行)。
|
||||
fn kimi_env_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
&LOCK
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aliases() {
|
||||
assert_eq!(parse_harness_id("codebody").unwrap(), HarnessId::Codebuddy);
|
||||
@@ -369,6 +413,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(parse_harness_id("pi-agent").unwrap(), HarnessId::PiAgent);
|
||||
assert_eq!(parse_harness_id("hermes-agent").unwrap(), HarnessId::Hermes);
|
||||
assert_eq!(parse_harness_id("kimi").unwrap(), HarnessId::KimiCode);
|
||||
assert_eq!(parse_harness_id("kimi-code").unwrap(), HarnessId::KimiCode);
|
||||
assert_eq!(parse_harness_id("kimi-cli").unwrap(), HarnessId::KimiCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -424,6 +471,52 @@ mod tests {
|
||||
HarnessId::Hermes.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.hermes/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::KimiCode.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.kimi-code/skills")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_kimi_code_from_home_layout() {
|
||||
// 与改动 KIMI_CODE_HOME 的测试互斥,避免并行读写环境变量产生竞争。
|
||||
let _guard = kimi_env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".kimi-code")).unwrap();
|
||||
let report = HarnessId::KimiCode.report_for_home(home);
|
||||
assert!(report.detected);
|
||||
assert_eq!(report.skills_dir, home.join(".kimi-code").join("skills"));
|
||||
assert_eq!(HarnessId::KimiCode.cli_name(), "kimi-code");
|
||||
assert_eq!(HarnessId::KimiCode.display_name(), "Kimi Code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_skills_dir_honors_kimi_code_home_env() {
|
||||
let _guard = kimi_env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let custom = tmp.path().join("custom-kimi-code");
|
||||
std::fs::create_dir_all(&custom).unwrap();
|
||||
let previous = std::env::var("KIMI_CODE_HOME").ok();
|
||||
// SAFETY: 持有 kimi_env_lock,覆盖所有读写 KIMI_CODE_HOME 的测试。
|
||||
unsafe {
|
||||
std::env::set_var("KIMI_CODE_HOME", &custom);
|
||||
}
|
||||
let home = Path::new("/home/user");
|
||||
assert_eq!(
|
||||
HarnessId::KimiCode.skills_dir_for_home(home),
|
||||
custom.join("skills")
|
||||
);
|
||||
unsafe {
|
||||
match previous {
|
||||
Some(value) => std::env::set_var("KIMI_CODE_HOME", value),
|
||||
None => std::env::remove_var("KIMI_CODE_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user