diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..33b0907
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,150 @@
+name: release
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: read
+
+concurrency:
+ # 发布不允许被后续运行打断。
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ # 复用测试工作流的全部门禁(lint / test / 协议一致性),门禁不过不发布。
+ test:
+ uses: ./.github/workflows/test.yaml
+ permissions:
+ contents: read
+
+ build:
+ needs: test
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - goos: darwin
+ goarch: amd64
+ arch: x86_64
+ format: tar.gz
+ - goos: darwin
+ goarch: arm64
+ arch: arm64
+ format: tar.gz
+ - goos: linux
+ goarch: amd64
+ arch: x86_64
+ format: tar.gz
+ - goos: linux
+ goarch: arm64
+ arch: arm64
+ format: tar.gz
+ - goos: windows
+ goarch: amd64
+ arch: x86_64
+ format: zip
+ - goos: windows
+ goarch: arm64
+ arch: arm64
+ format: zip
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: 构建并打包
+ env:
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ CGO_ENABLED: "0"
+ run: |
+ set -euo pipefail
+ VERSION="${GITHUB_REF_NAME#v}"
+ BUILD_DATE=$(git show -s --format=%cI HEAD)
+ COMMIT_TIMESTAMP=$(git show -s --format=%ct HEAD)
+ PACKAGE="sctl_${VERSION}_${GOOS}_${{ matrix.arch }}"
+ STAGE="dist/${PACKAGE}"
+ BINARY=sctl
+ if [ "$GOOS" = windows ]; then
+ BINARY=sctl.exe
+ fi
+
+ mkdir -p "$STAGE/docs"
+ go build -trimpath -ldflags "-s -w \
+ -X github.com/scriptscat/sctl/internal/cli.Version=${VERSION} \
+ -X github.com/scriptscat/sctl/internal/cli.Commit=${GITHUB_SHA} \
+ -X github.com/scriptscat/sctl/internal/cli.BuildDate=${BUILD_DATE}" \
+ -o "$STAGE/$BINARY" ./cmd/sctl
+ cp README.md LICENSE* "$STAGE/"
+ cp -R docs/. "$STAGE/docs/"
+ find "$STAGE" -exec touch -d "@${COMMIT_TIMESTAMP}" {} +
+
+ if [ "${{ matrix.format }}" = zip ]; then
+ (cd dist && zip -X -q -r "${PACKAGE}.zip" "$PACKAGE")
+ else
+ tar --sort=name --mtime="@${COMMIT_TIMESTAMP}" --owner=0 --group=0 --numeric-owner \
+ -C dist -czf "dist/${PACKAGE}.tar.gz" "$PACKAGE"
+ fi
+ - uses: actions/upload-artifact@v7
+ with:
+ name: release-${{ matrix.goos }}-${{ matrix.goarch }}
+ path: dist/*.${{ matrix.format }}
+ if-no-files-found: error
+
+ release:
+ name: Create Release
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # 创建 GitHub Release 并上传产物
+ id-token: write # 构建来源证明(build provenance)签名
+ attestations: write
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/download-artifact@v8
+ with:
+ path: artifacts
+ pattern: release-*
+ merge-multiple: true
+ - name: 生成校验和
+ run: cd artifacts && sha256sum * > checksums.txt
+ - name: 判断是否为预发布
+ id: prerelease
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ if [[ "$TAG" == *-* ]]; then
+ echo "flag=--prerelease" >> "$GITHUB_OUTPUT"
+ fi
+ - name: 创建 GitHub 草稿 Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PRERELEASE_FLAG: ${{ steps.prerelease.outputs.flag }}
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ --draft \
+ --title "$GITHUB_REF_NAME" \
+ --generate-notes \
+ $PRERELEASE_FLAG \
+ artifacts/*
+ - name: 生成构建来源证明
+ # 只对 checksums.txt 签名即可:它覆盖了全部产物的 sha256,
+ # 用户先 `sha256sum -c` 再 `gh attestation verify checksums.txt` 就形成完整信任链。
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-path: artifacts/checksums.txt
+ - name: 输出发布摘要
+ run: |
+ {
+ echo "## sctl ${GITHUB_REF_NAME} 发布产物"
+ echo
+ echo '```'
+ cat artifacts/checksums.txt
+ echo '```'
+ echo
+ echo "Release 以**草稿**形式创建,确认 changelog 后手动点击 Publish。"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..5b24eab
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,74 @@
+name: test
+
+on:
+ push:
+ branches: [main, "release/**"]
+ pull_request:
+ # 供 release 工作流复用:打 tag 发布前跑同一套门禁,避免两边规则漂移。
+ workflow_call:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ # PR 上新提交会取消上一次运行;main / release 分支保留每次运行。
+ group: test-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ lint:
+ name: lint
+ runs-on: ubuntu-latest
+ env:
+ # 与本地开发保持同一版本,避免"本地过 CI 挂"。升级时同步改 docs/development.md。
+ GOLANGCI_LINT_VERSION: v2.12.2
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+ - uses: golangci/golangci-lint-action@v9
+ with:
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+
+ test:
+ name: test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+ - run: go build ./...
+ - run: go vet ./...
+ - run: go test -race ./...
+
+ protocol-drift:
+ # protocol.json 是扩展与守卫共用的单一事实源(见 docs/protocol.md):两份镜像一旦漂移,
+ # 双方对 action / scope / 限值的理解就会分叉,且只在运行时才暴露。这里做逐字节比对。
+ #
+ # 扩展侧该文件随 MCP 桥接 PR 合入 main 后此 job 才能通过;在那之前把仓库变量
+ # EXT_PROTOCOL_REF 指到对应 PR 分支。
+ name: protocol-drift
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - name: 拉取扩展侧协议镜像
+ env:
+ EXT_REF: ${{ vars.EXT_PROTOCOL_REF || 'main' }}
+ run: |
+ set -euo pipefail
+ url="https://raw.githubusercontent.com/scriptscat/scriptcat/${EXT_REF}/src/app/service/service_worker/mcp/protocol.json"
+ if ! curl -fsSL "$url" -o ext-protocol.json; then
+ echo "::error::拉取扩展侧 protocol.json 失败(ref=${EXT_REF})。扩展侧尚未合入时,请把仓库变量 EXT_PROTOCOL_REF 指到对应分支。"
+ exit 1
+ fi
+ - name: 比对协议单源
+ run: |
+ if ! diff -u ext-protocol.json internal/pkg/protocol/protocol.json; then
+ echo "::error::protocol.json 与扩展侧镜像不一致,两侧须同步更新。"
+ exit 1
+ fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..57b6fee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/sctl
+/bin/
+dist/
+# 一次性端到端验证脚本与证据(见 docs/verification.md),永远不进版本库
+/e2e/scratch/
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..05388de
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,52 @@
+version: "2"
+
+# 注意:golangci-lint 只分析当前 GOOS 下参与构建的文件,`_windows.go` 一类
+# 带构建标签的代码在 Linux 测试 CI 上不会被检查;跨平台产物由发布流程编译。
+
+linters:
+ default: standard # errcheck govet ineffassign staticcheck unused
+ enable:
+ - bodyclose # HTTP body 未关闭
+ - errorlint # %w / errors.Is 用法
+ - gocritic
+ - misspell
+ - nilerr # 拿到 err 却返回 nil
+ - nolintlint # //nolint 必须写明理由
+ - revive
+ - unconvert
+ - usestdlibvars
+ - whitespace
+ settings:
+ errcheck:
+ check-type-assertions: true
+ nolintlint:
+ require-explanation: true
+ require-specific: true
+ revive:
+ rules:
+ - name: exported
+ disabled: true # 内部包为主,不强制导出符号注释
+ - name: unused-parameter
+ disabled: true
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ rules:
+ # 测试里大量 defer close / 断言,放宽噪音较大的检查。
+ - path: _test\.go
+ linters:
+ - bodyclose
+ - errcheck
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
+ settings:
+ goimports:
+ local-prefixes:
+ - github.com/scriptscat/sctl
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9e65041
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,126 @@
+# Repository Guidelines
+
+This file is the entry point for AI coding agents and contributors working on sctl.
+
+> **It is the single source of truth relative to `CLAUDE.md`** — `CLAUDE.md` only contains `@AGENTS.md` and
+> re-imports this file; don't split guidance between the two. Everything beyond engineering principles and the
+> architecture quick-map is owned by the docs linked below (ownership table in
+> [`docs/README.md`](./docs/README.md)) — cross-link them, don't copy their content here.
+
+> **Before writing any code, read [`docs/development.md`](./docs/development.md)** — build and test commands,
+> static analysis, environment variables, the version floor, branches, CI, and releases.
+
+> **Before changing package structure, adding a package, or altering dependency direction, read
+> [`docs/architecture.md`](./docs/architecture.md)** — process model, directory layout, per-package
+> responsibilities, dependency direction.
+
+> **Before changing the envelope, actions, limits, or handshake, read
+> [`docs/protocol.md`](./docs/protocol.md)** — `internal/pkg/protocol/protocol.json` is the single source of
+> truth shared with the extension and both sides must stay in sync; the doc only explains semantics, and the
+> json wins on conflict.
+
+> **Before touching authentication, keys, enrollment, or auditing, read
+> [`docs/threat-model.md`](./docs/threat-model.md)** — security boundaries, attack surface, accepted
+> trade-offs, and the inventory of credentials on disk.
+
+> **Before claiming a change "actually works", read [`docs/verification.md`](./docs/verification.md)** — how to
+> write one-shot verification scripts under `e2e/scratch/`, what counts as evidence, and which side effects to
+> inspect for surfaces you cannot drive.
+
+> **Before adding, rewriting, or reviewing any documentation, read
+> [`docs/doc-maintenance.md`](./docs/doc-maintenance.md)** — ownership rules and truth discipline
+> (*if you can't `git grep` it on this branch, don't write it down*).
+
+## Project Overview
+
+sctl is ScriptCat's local control tool: a bridge daemon, an MCP server, and script management commands,
+shipped as a single cross-platform binary. Go, built on the [cago](https://github.com/cago-frame/cago)
+framework and [cobra](https://github.com/spf13/cobra).
+
+```text
+sctl mcp / CLI verbs ──/control/* HTTP──▶ sctl serve (daemon) ──WS──▶ ScriptCat extension (approval authority)
+internal/client/ internal/daemon/ internal/pkg/ (shared by both sides)
+```
+
+The authority always lives on the extension side: the daemon approves no write on its own — it forwards the
+request and blocks until a human decides in the browser. Full process model and package responsibilities are
+in [`docs/architecture.md`](./docs/architecture.md).
+
+## Engineering Principles
+
+These are non-negotiable, regardless of what the owning docs say about mechanics. All of them are held by
+review today; the one exception is called out in the item itself.
+
+- **Reproduce before you fix.** Reproduce a reported bug yourself and capture the failing evidence, then pin it
+ down with a failing test, and only then change code. The order is not negotiable: a fix that starts from an
+ assumption often repairs a problem that never existed while burying the real cause deeper. If it doesn't
+ reproduce, say so plainly and stop — "it's obviously wrong" is not an exception, and neither is a one-line
+ change. How to reproduce and what counts as evidence are in
+ [`docs/verification.md`](./docs/verification.md).
+
+- **Tests first.** Write the failing test before the implementation. Test names state **behavior** ("list
+ returns exit code 3 when the extension is not connected"), not implementation detail ("calls dispatch"). When
+ a test fails, fix the code, not the test. Delete meaningless tests outright — tautologies, tests that only
+ assert a mock, pure pass-throughs — but confirm against the source, one by one, that each really protects
+ nothing before deleting it.
+
+- **Fix root causes, not symptoms.** No `//nolint` to paper over a diagnostic, no swallowed errors, no empty
+ branch added just to silence something. A `//nolint` must name the specific rule and give a reason — that
+ much is mechanically enforced by `nolintlint` in `.golangci.yaml` — but whether the reason is a genuine
+ exception rather than "make it pass" is still something only review can judge.
+
+- **Validate at boundaries, don't second-guess internally.** Everywhere untrusted data enters — WS envelopes,
+ `/control/*` requests, payloads from the extension, command-line arguments — must be validated. Between
+ trusted internal layers, add no `if x == nil` fallbacks, no swallowed errors, no "just in case" runtime
+ shims. An assumption you cannot hold should panic or return an error rather than be masked by a branch that
+ never fires — that branch only makes the bug that *does* fire harder to find.
+
+- **Layer by process role; dependencies point one way.** `client/` (request side) and `daemon/` (guard side) do
+ not depend on each other and communicate across processes only through `/control/*`. `internal/pkg/` is the
+ shared layer and may only be depended upon from above. `bridge` knows nothing about the HTTP control plane.
+ The single exception is the guard side referencing `client/control` one-way. Once a shortcut breaks the
+ direction, the two process roles are welded together at compile time and separating them again means a
+ rewrite. The current dependency graph and the reason for the exception are in
+ [`docs/architecture.md`](./docs/architecture.md).
+
+- **Sensitive files hit disk in exactly one place.** Long-term keys, the control token, and the client store
+ all go through `internal/pkg/fsutil.WriteFileAtomic`. A non-atomic write leaves truncated content behind on a
+ crash, and a bare `os.WriteFile` also loses the 0600 permission bits. The inventory of credentials on disk is
+ in [`docs/threat-model.md`](./docs/threat-model.md).
+
+- **stdout belongs to the CLI alone.** stdout carries `sctl mcp`'s JSON-RPC channel; a single byte written
+ there by the daemon side or a library corrupts MCP frames. Diagnostics always go through
+ `internal/pkg/logging`, which writes to stderr plus the log files under the data directory — never stdout.
+
+- **Extend through the existing extension points.** A new action lands in `protocol.json` first. A new verb
+ reuses the `dispatch` / `dispatchBlocking` skeleton in `internal/cli/dispatch.go` instead of re-implementing
+ connection, cancellation, and exit-code mapping. A new `/control/*` handler is registered in
+ `controlapi.Handler.Register`, on the mux that `internal/daemon/component.go` assembles. Inject dependencies
+ through constructors and depend on narrow
+ interfaces rather than concrete types — `controlapi.Bridge` is the template for that pattern. Never branch on
+ a type string in shared code.
+
+- **Reuse before you rebuild.** `git grep` for an existing helper before writing a new one. Extract a shared
+ implementation the second time the same logic appears — one concept, one implementation, so a single fix
+ lands everywhere. But don't pre-abstract for hypothetical needs: three repeated lines beat a premature
+ generic helper.
+
+- **Stay in scope.** A bug fix touches only the files that bug requires. Correcting a stale comment or an
+ incorrect doc line right under your cursor is in scope; drive-by refactors and rename sweeps are not.
+
+- **Comments explain "why"; no dead code.** A comment states a constraint or reason the code cannot express —
+ why 0600, why the symlink must be resolved first here. Comments that restate the steps get deleted. Likewise:
+ no dead code, no commented-out blocks, no `// removed` markers — git remembers.
+
+- **Documentation doesn't claim what isn't there.** Before asserting that a file, function, or flag exists,
+ verify it on the current branch with `git grep` / `git ls-files`. The discipline and the per-claim
+ verification table are in [`docs/doc-maintenance.md`](./docs/doc-maintenance.md).
+
+## Before You Commit
+
+```bash
+go build ./... && go vet ./... && go test ./... -race && golangci-lint run ./...
+```
+
+Never claim "fixed" or "passing" without real output as evidence — see
+[`docs/verification.md`](./docs/verification.md).
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+ .
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..facba58
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,31 @@
+.DEFAULT_GOAL := help
+
+GO ?= go
+GOLANGCI_LINT ?= golangci-lint
+DEV_VERSION ?= 0.1.0
+BUILD_DIR ?= bin
+BINARY := $(BUILD_DIR)/sctl
+VERSION_PACKAGE := github.com/scriptscat/sctl/internal/cli
+
+.PHONY: help build test lint dev clean
+
+help: ## 显示可用命令
+ @awk 'BEGIN {FS = ":.*## "; printf "用法: make <命令>\n\n命令:\n"} /^[a-zA-Z_-]+:.*## / {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+build: ## 构建 bin/sctl
+ mkdir -p $(BUILD_DIR)
+ $(GO) build -o $(BINARY) ./cmd/sctl
+
+test: ## 运行竞态检测测试
+ $(GO) test ./... -race
+
+lint: ## 运行静态检查
+ $(GOLANGCI_LINT) run ./...
+
+dev: ## 构建并启动本地 daemon(DEV_VERSION=0.1.0)
+ mkdir -p $(BUILD_DIR)
+ $(GO) build -ldflags "-X $(VERSION_PACKAGE).Version=$(DEV_VERSION)" -o $(BINARY) ./cmd/sctl
+ $(BINARY) serve
+
+clean: ## 删除本地构建产物
+ rm -rf $(BUILD_DIR)
diff --git a/README.md b/README.md
index 5aba2c3..3f31091 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,53 @@
# sctl
+
+ScriptCat 的本地控制工具:桥接 daemon、MCP server 与脚本管理命令,单二进制跨平台分发。基于 [cago](https://github.com/cago-frame/cago) 框架 + [cobra](https://github.com/spf13/cobra)。
+
+> ⚠️ 开发中(WIP)。
+
+```text
+MCP 客户端(Claude/Codex…)─ stdio ─→ sctl mcp ─┐(本机内部连接:loopback 控制 API)
+CLI 动词(sctl scripts list / install …)───────┤
+ ▼
+ sctl serve(daemon,WS 仅监听 127.0.0.1:8643)
+ ▲ WebSocket(扩展主动连接 + 双向 HMAC 握手)
+ ScriptCat 浏览器扩展(审批与授权的权威端)
+```
+
+`sctl mcp` / CLI 动词与常驻 `sctl serve` 是**独立进程**,经 daemon listener 上的 `/control/*` HTTP/JSON 控制 API 通信,未运行时自动拉起。详见 [`docs/architecture.md`](./docs/architecture.md)。
+
+## 子命令
+
+| 命令 | 说明 |
+|---|---|
+| `sctl serve` | 运行桥接 daemon(cago 应用;WS 仅监听 loopback,并挂载控制 API) |
+| `sctl connect` | 生成一次性配对码,与扩展建立外部接入(只需一次;此后 CLI 与所有 MCP agent 都继承信任) |
+| `sctl mcp [--name ]` | stdio MCP server;经外部接入继承信任、暴露全部脚本工具(未运行时自动拉起 serve;`--name` 仅作审计标签) |
+| `sctl status` | daemon 与扩展连接状态,附守卫侧安全事件摘要(不自动拉起 daemon) |
+| `sctl scripts list / info / source ` | 读脚本(`source` 输出到 stdout,可重定向) |
+| `sctl install ` | 请求安装脚本(URL 或本地文件;本地文件上送 staged code) |
+| `sctl enable / disable / rm ` | 写操作,阻塞等待浏览器人工确认 |
+| `sctl version` | 版本与协议信息 |
+
+全局标志:`--json`(结构化输出,供脚本消费)、`--log-level`(日志走 stderr 与数据目录下的 `logs/`,绝不写 stdout;`sctl mcp` 的 stdout 由 MCP 协议独占)。
+
+### 写操作阻塞语义与退出码
+
+写动词与 MCP 写工具**阻塞**至用户在浏览器确认页决策。请求方 **Ctrl-C** 会切断连接 → daemon 向扩展发 `bridge.cancel` 作废该操作。CLI 退出码:
+
+| 码 | 含义 |
+|---|---|
+| 0 | 批准 / 成功 |
+| 1 | 用户拒绝(`USER_REJECTED`) |
+| 2 | 作废(超时 `OPERATION_EXPIRED` / Ctrl-C 取消 / 扩展断开) |
+| 3 | 其他错误(校验失败、`NOT_FOUND`、连接失败…) |
+
+## 文档
+
+- [`AGENTS.md`](./AGENTS.md) —— 贡献者与 AI 代理的入口页:工程原则、架构速览,以及做某类改动前先读哪份文档。
+- [`docs/README.md`](./docs/README.md) —— 全部文档的索引兼归属表(架构、协议、威胁模型、开发、验证、文档维护)。
+
+索引只在 `docs/README.md` 维护一份;这里不复制一遍,免得两处漂移。
+
+## License
+
+GPL-3.0,与扩展主仓库一致。全文见 [LICENSE](./LICENSE)。
diff --git a/cmd/sctl/main.go b/cmd/sctl/main.go
new file mode 100644
index 0000000..481e83c
--- /dev/null
+++ b/cmd/sctl/main.go
@@ -0,0 +1,29 @@
+// sctl — ScriptCat 控制工具:本地桥接 daemon、MCP stdio server 与脚本管理命令。
+// 桥接协议见 docs/protocol.md。
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/scriptscat/sctl/internal/cli"
+)
+
+func main() {
+ root := cli.NewRootCmd()
+ err := root.Execute()
+ if err == nil {
+ return
+ }
+ // 写动词以 ExitError 携带自定义退出码(0 批准 / 1 拒绝 / 2 作废 / 3 其他)。
+ var ee *cli.ExitError
+ if errors.As(err, &ee) {
+ if ee.Message != "" {
+ fmt.Fprintln(os.Stderr, "error:", ee.Message)
+ }
+ os.Exit(ee.Code)
+ }
+ fmt.Fprintln(os.Stderr, "error:", err)
+ os.Exit(1)
+}
diff --git a/configs/config.yaml b/configs/config.yaml
new file mode 100644
index 0000000..6cc4585
--- /dev/null
+++ b/configs/config.yaml
@@ -0,0 +1,8 @@
+bridge:
+ address: 127.0.0.1:8643
+debug: false
+env: PROD
+logger:
+ disableConsole: false
+ level: info
+source: ""
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..0e01b8a
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,28 @@
+# sctl Documentation Index
+
+The entry point is [`AGENTS.md`](../AGENTS.md) at the repository root: engineering principles, the
+architecture quick-map, and the routing rules for which doc to read before which kind of change. This file is
+the index of everything, and doubles as the **ownership table**.
+
+Each fact is expanded in exactly one doc; everywhere else cross-links to it. The reasoning and the
+verification method are in [`doc-maintenance.md`](./doc-maintenance.md).
+
+| Doc | Owns |
+|---|---|
+| [`../AGENTS.md`](../AGENTS.md) | Engineering principles and the architecture quick-map. Single source of truth relative to `CLAUDE.md`, which only `@`-imports it. |
+| [`architecture.md`](./architecture.md) | Process model, directory layout, per-package responsibilities, dependency direction. **Read before changing package structure or dependency direction.** |
+| [`protocol.md`](./protocol.md) | The extension ↔ daemon WS bridge protocol: envelope, handshake, actions, limits, error codes. **Read before changing the protocol.** |
+| [`threat-model.md`](./threat-model.md) | Security boundaries, attack surface and trade-offs, credentials on disk, daemon-side auditing. **Read before touching auth, keys, pairing, or auditing.** |
+| [`development.md`](./development.md) | Build commands, test design and commands, static analysis, environment variables, the version floor, branches / CI / releases. **Read before writing code.** |
+| [`verification.md`](./verification.md) | How to confirm a change "actually works": evidence, one-shot scripts under `e2e/scratch/`, reproduction discipline. **Read before claiming something is fixed.** |
+| [`doc-maintenance.md`](./doc-maintenance.md) | Documentation ownership rules, truth discipline, and the per-claim verification table. **Read before changing docs.** |
+
+Readers who want to *write* user scripts should go to [docs.scriptcat.org](https://docs.scriptcat.org/)
+instead; this directory targets sctl contributors and maintainers only.
+
+## Single source of truth
+
+- The only authority for protocol constants is
+ [`internal/pkg/protocol/protocol.json`](../internal/pkg/protocol/protocol.json). It mirrors the copy in the
+ [extension repository](https://github.com/scriptscat/scriptcat); how the two are kept from drifting is in
+ [development.md](./development.md#protocol-single-source).
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..23ab70a
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,77 @@
+# Architecture
+
+## Process model
+
+```text
+MCP client (Claude/Codex…) ─ stdio ─→ sctl mcp ─┐ (local internal connection: loopback control API)
+CLI verbs (sctl scripts list / install …)───────┤
+ ▼
+ sctl serve (daemon; WS listens on 127.0.0.1:8643 only)
+ ▲ WebSocket (extension dials in + mutual HMAC handshake)
+ ScriptCat browser extension (authority for approval and authorization)
+```
+
+`sctl mcp` and the CLI verbs are **separate processes** from the resident `sctl serve`. They talk over the
+`/control/*` HTTP/JSON control API on the daemon's listener — same port as the extension's WS surface, separate
+path, authenticated with the 0600 control token the daemon writes. When a frontend finds no daemon running it
+spawns one detached. The bind race between several frontends starting cold is resolved by "if the bind fails,
+connect to the instance that won".
+
+The authority always lives on the extension side: the daemon approves no write on its own — it forwards the
+request and blocks until a human decides in the browser. Details in [threat-model.md](./threat-model.md).
+
+## Directory layout
+
+`internal/` is grouped by **process role**: `daemon/` is the guard side (`sctl serve`), `client/` is the
+request side (`sctl mcp` and the CLI verbs), and the flat top-level packages are shared by both by definition.
+The layering convention is borrowed from [cago](https://github.com/cago-frame/cago) — `configs/`,
+`internal/pkg/`, and store playing the repository role.
+
+```text
+cmd/sctl/main.go # cobra entry point (unwraps ExitError → os.Exit)
+configs/config.yaml # cago config (bridge.address etc.; falls back to built-in defaults if absent)
+
+internal/cli/ # subcommand definitions; spans both sides, hence top level
+ cli.go # root command, global flags, JSON output helpers
+ serve.go # bootstraps the cago app and mounts the daemon Component
+ mcp.go # sctl mcp (serves all tools; --name is an audit label)
+ connect.go status.go version.go
+ scripts.go write.go # read verbs / write verbs
+ dispatch.go # action forwarding and bridge error → exit code mapping
+
+internal/daemon/ # ── sctl serve side ──
+ component.go # cago Component: assembles listener + bridge + controlapi
+ bridge/ # WS service core
+ server.go # Server struct, Serve, Origin whitelist, handshake admission, connection registry
+ conn.go # single connection: handshake, read loop, send
+ call.go # action forwarding, pending-call table, bridge.cancel
+ pairing.go # extension enrollment window (out-of-band code → key K)
+ envelope.go # envelope, payload structs, error codes
+ controlapi/ # /control/* handlers (controller role), depends on the narrow Bridge interface
+ auth/ # mutual HMAC handshake, enrollment-code derivation (HKDF), key delivery (AES-GCM)
+ store/ # 0600 persistence of the long-term key K (repository role)
+ ratelimit/ # per-key sliding-window rate limiting
+
+internal/client/ # ── sctl mcp / CLI verb side ──
+ control/ # control API client, shared DTOs, control token, detached auto-spawn
+ mcpserver/ # go-sdk stdio MCP server: all tools (flat trust), progress while waiting
+
+internal/pkg/ # ── shared by both sides ──
+ protocol/ # protocol.json itself + embedded parsing
+ audit/ # daemon-side security events (the Event type crosses the control API, hence shared)
+ paths/ # data directory and derived paths
+ logging/ # unified zap logging (stderr + /logs/*.log, never stdout)
+ fsutil/ # atomic file writes
+```
+
+## Dependency direction
+
+`cli` → `daemon` (for `serve` only) and `client`. `daemon/controlapi` → `daemon/bridge`, never the reverse:
+the control API sees the guard only through the narrow `controlapi.Bridge` interface and `bridge` knows
+nothing about HTTP paths. The `/control/*` routes are registered by `controlapi.Handler.Register`, on the mux
+`internal/daemon/component.go` assembles and hands to `bridge.Server.Serve` (which owns only `/`). The shared
+DTOs live in `client/control` and are referenced one-way by `controlapi`.
+
+Two further cross-package conventions: sensitive files reach disk only through `internal/pkg/fsutil`, and
+stdout belongs to `internal/cli` alone — the latter because stdout is claimed exclusively by `sctl mcp`'s
+JSON-RPC channel. Both are held by review today.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..6c6d72d
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,173 @@
+# Development
+
+```bash
+go build ./...
+go vet ./...
+go test ./... -race
+go build -o sctl ./cmd/sctl && ./sctl version
+```
+
+## Language conventions
+
+- Documentation (this directory, `AGENTS.md`, `docs/README.md`) is written in English — it is read mostly by
+ coding agents.
+- Code comments are written in Simplified Chinese, matching the extension repository.
+- The user-facing `README.md` and all CLI output stay in Simplified Chinese.
+
+## Testing
+
+Tests protect observable contracts, not coverage percentages or the current implementation shape. Before writing
+a test, identify:
+
+1. **Contract** — what a caller, CLI user, peer process, or operator can observe;
+2. **Trigger** — the input, event, state, or sequence that exercises it;
+3. **Outcome** — the returned value, exit code, response, persisted state, audit event, or external call required
+ by the contract; and
+4. **Regression** — a plausible broken implementation that the test would reject.
+
+If no relevant regression can be named, the proposed test is probably a tautology or an assertion about an
+implementation detail. For a reported bug, follow the reproduction-first workflow in
+[verification.md](./verification.md#reproduction-is-step-one-of-a-fix), then commit the smallest test that fails
+for the confirmed cause before changing production code.
+
+### Choose cases by behavior, not sample count
+
+Start with a representative normal case, then add cases only where the expected outcome or production branch is
+different. Consider each boundary that the changed contract actually has:
+
+| Category | Cases to consider |
+|---|---|
+| Values and collections | empty / one / many; first / last; exactly at a size, count, or time limit; immediately below and above it |
+| Invalid or failed input | malformed envelopes or requests, missing required fields, unavailable extension, rejection, permission denial, timeout, cancellation, and partial I/O |
+| State and lifecycle | before / after connection or pairing, repeated calls, idempotency, cleanup, expiry, revocation, and stale work completing after newer work |
+| Ordering and concurrency | overlapping requests, out-of-order responses, duplicate delivery, disconnect during an operation, and exactly-once effects where promised |
+| Compatibility and security | protocol limits, untrusted paths or URLs, authorization scope, credential permissions, and accepted legacy forms where the contract retains them |
+
+This is a selection guide, not a requirement to enumerate every row for every change. If `< limit`, `== limit`,
+and `> limit` produce distinct results, cover all three. If ten ordinary strings take the same path, one
+representative is normally enough. Empty input deserves a separate case only when emptiness changes behavior.
+For a bug fix, preserve a normal-case assertion when the repair could accidentally reject previously supported
+input.
+
+Use the narrowest realistic boundary that exposes the contract:
+
+- pure unit tests for parsing, mapping, validation, selection, and state transitions;
+- package tests for persistence, bridge lifecycle, rate limiting, audit behavior, retries, and ordering across an
+ interface;
+- handler or client tests for `/control/*` request validation, authentication, status mapping, and cancellation;
+- CLI tests for user-visible output, stderr/stdout separation, and exit codes; and
+- real-process verification when the behavior depends on process wiring or the extension. Do not replace that
+ boundary with a heavily mocked unit test merely because it is easier to run.
+
+### Assert the contract
+
+- Prefer exact assertions on domain values, HTTP responses, exit codes, files, audit events, and visible output.
+ Use truthiness or substring checks only when omitted details are intentionally outside the contract.
+- Assert a collaborator call when the call itself is the contract — for example, a write must not occur before
+ extension approval, or an audit event must be emitted exactly once. Otherwise assert the resulting behavior,
+ not every internal call.
+- Test names state the trigger and observable outcome, not the helper used to implement it. One test may contain
+ several related assertions for one behavior; do not split each field into a separate setup-heavy test or merge
+ unrelated contracts into one scenario.
+- Mock external or expensive boundaries and keep the production path under test real. Assert how sctl validates,
+ transforms, persists, forwards, or reacts to a mock result — never merely that a mock returned its configured
+ value.
+- Keep fixtures small enough that the outcome-changing input is obvious. Prefer existing test harnesses and
+ helpers over rebuilding daemon, bridge, or control-plane setup in each package.
+
+### Do not write low-value tests
+
+A test earns its maintenance cost when it exercises sctl logic and fails for a meaningful regression. Do not add,
+and remove when encountered within the current task's scope:
+
+- tautologies that restate a constant, fixture, or type definition;
+- tests whose only assertion is that a configured mock returns its configured value;
+- pure pass-through tests with no branch, transformation, validation, side effect, or stable user-visible
+ contract between input and output;
+- assertions about private helper calls, temporary data structures, or source layout that may change without any
+ observable behavior changing;
+- repeated examples from the same equivalence class that reject no regression beyond an existing case; or
+- static counts and broad snapshots whose changes cannot distinguish a regression from an intentional edit.
+
+Thin tests can still be valuable: protocol drift checks, exact exit-code or permission-bit assertions, error type
+identity, serialization compatibility, security blocklists, and the only coverage of a real branch all protect
+stable contracts. Judge each test against its production path rather than by line count.
+
+Before deleting or consolidating a test, read the source it covers, search nearby package and integration tests
+for the same contract, and name the regression signal that would be lost. A failing or slow test is not
+automatically low value: fix production regressions; update a genuinely changed contract; reproduce and remove
+the cause of flakes; and move misclassified integration work to the appropriate boundary. Do not weaken a valid
+test merely to make the suite pass, and do not turn a focused change into a repository-wide cleanup.
+
+## Static analysis
+
+CI uses [golangci-lint](https://golangci-lint.run) v2 (configuration in `.golangci.yaml`). Installing the same
+version locally avoids "green locally, red in CI":
+
+```bash
+go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
+golangci-lint fmt ./... # gofmt + goimports (with this repo's prefix grouping)
+golangci-lint run ./...
+```
+
+When bumping the version, update `GOLANGCI_LINT_VERSION` in `.github/workflows/test.yaml` to match.
+
+## Environment variables
+
+| Variable | Effect |
+|---|---|
+| `SCTL_DATA_DIR` | Overrides the data directory (keys / tokens / client store / logs) |
+| `SCTL_BRIDGE_ADDR` | Overrides both the daemon's bind address and the frontend's connect address (custom port, multiple instances, isolated testing) |
+
+How to drive a real daemon in isolation with these two variables, and what evidence makes a change count as
+"verified", is owned by [verification.md](./verification.md).
+
+## Version floor
+
+The extension checks `daemonVersion >= versions.minDaemonVersion` (currently `0.1.0`). A plain `go build`
+produces `0.0.0-dev`, which is **below the floor and will be rejected as "too old" and disconnected by the
+extension**. For smoke tests and integration against the real extension, build with the version injected (or
+use a release binary):
+
+```bash
+go build -ldflags "-X github.com/scriptscat/sctl/internal/cli.Version=0.1.0" -o sctl ./cmd/sctl
+```
+
+That version is delivered to the extension in `hello.daemonVersion`.
+
+## Branches and CI
+
+| Trigger | What runs |
+|---|---|
+| Every PR (any target branch) | `lint` + `test` (`-race`) + `protocol-drift` |
+| push to `main` / `release/**` | Same as above |
+| push tag `v*` | Reuses the full test gate first; only builds and publishes once it passes |
+
+## Releases
+
+The version comes from the tag; the whole release flow is driven by `.github/workflows/release.yaml`:
+
+```bash
+git tag -a v0.1.0 -m "v0.1.0" # use v0.1.0-rc.1 for prereleases; the workflow marks them automatically
+git push origin v0.1.0
+```
+
+It produces 6 artifacts (darwin/linux/windows × amd64/arm64) plus `checksums.txt`, and generates build
+provenance for the checksum file. The release is **created as a draft**; publish it manually on GitHub after
+reviewing the changelog.
+
+Artifacts are built with `-trimpath` and use the commit time as the archive member timestamp, so rebuilding
+the same tag is byte-for-byte reproducible. Version, commit, and build time are injected into `internal/cli`
+via `-ldflags` and are visible through `sctl version`.
+
+Cross-compilation, packaging, checksums, and the GitHub Release upload all happen inside that workflow.
+
+## Protocol single source
+
+`internal/pkg/protocol/protocol.json` mirrors the extension's copy and is compiled into the binary with
+`go:embed`. CI's `protocol-drift` job fetches the authoritative copy from the extension repository and diffs
+it byte for byte; both sides must be updated together.
+
+The job reads that copy from the `main` branch of `scriptscat/scriptcat` by default. Until the extension-side
+bridge lands there, point the repository variable `EXT_PROTOCOL_REF` at the branch that carries it — otherwise
+the fetch fails and the job goes red on every PR.
diff --git a/docs/doc-maintenance.md b/docs/doc-maintenance.md
new file mode 100644
index 0000000..791f951
--- /dev/null
+++ b/docs/doc-maintenance.md
@@ -0,0 +1,85 @@
+# Documentation Maintenance
+
+> **Read this before adding, rewriting, or reviewing any documentation** — [`AGENTS.md`](../AGENTS.md),
+> everything under `docs/`, the Markdown in `.github/`, and any package-local README that may appear later.
+> Don't work from a fixed list: run `git ls-files '*.md'` to discover the current set. This guide has two
+> jobs: keep the doc set **organized** (links resolve, the index is current, facts aren't duplicated), and
+> keep every claim **true for the current branch**.
+
+## One fact, one owning doc
+
+Each fact — a port, an exit code, a version number — is expanded in exactly **one** doc; everywhere else
+cross-links to it. A fact copied into two places will drift, and usually only surfaces once someone acts on
+the wrong copy. The ownership table is in [README.md](./README.md), which doubles as the index.
+
+When you move a fact, move it to the doc that **owns** it and cross-link — don't copy. When you rename or move
+a file, update the index and every doc referencing it **in the same change**.
+
+## Truth discipline
+
+**If you can't `git grep` it on the current branch, don't write it down.**
+
+Always verify with git-aware commands:
+
+```bash
+git grep -n "someSymbol" # not rg
+git ls-files 'internal/**/*.go' # not ls / find
+git ls-tree -r --name-only HEAD
+```
+
+`rg` / `ls` / `find` also match **untracked** local files, so work that hasn't landed yet looks as though it
+already shipped — exactly the fourth failure mode above. Anything not yet landed either stays in its own
+branch's docs or is explicitly marked as planned.
+
+When code and docs change in the same PR, checking against the old `HEAD` is not enough — check against **the
+tree that will exist after the change lands**. Re-check after a rebase or conflict resolution: resolving a
+conflict can quietly reintroduce a stale fact or drop a doc update.
+
+## How to verify each kind of claim
+
+When a doc makes one of these claims, verify it accordingly. Every count must be **enumerated** from the
+authoritative source, never recalled from memory or copied from earlier prose.
+
+| Claim in the docs | Verify with |
+|---|---|
+| A source file / directory exists | `git ls-files --error-unmatch internal/daemon/component.go` |
+| A function / type exists **under that exact name** | `git grep -n 'func WriteFileAtomic' -- internal` — renames are the #1 source of drift |
+| Dependency direction between packages | `go list -f '{{.ImportPath}} {{join .Imports " "}}' ./...` |
+| Protocol constants (port, timeouts, TTLs, limits, action set) | `internal/pkg/protocol/protocol.json` is the sole authority, shared with the extension; docs only explain semantics |
+| The version floor | `grep minDaemonVersion internal/pkg/protocol/protocol.json` |
+| "N MCP tools" | Count the `actions` keys in `protocol.json`, not the comment in `internal/client/mcpserver/tools.go` |
+| "N release artifacts" | The build matrix in `.github/workflows/release.yaml` |
+| Toolchain versions | `GOLANGCI_LINT_VERSION` in `.github/workflows/test.yaml` must match [development.md](./development.md) |
+| CLI subcommands / flags | `git grep -n 'Use:' -- internal/cli`, or just `./sctl --help` |
+| Exit codes | The `exitOK` / `exitRejected` / `exitVoided` / `exitError` constants in `internal/cli/cli.go` |
+| Which jobs CI runs | The `jobs:` section of `.github/workflows/test.yaml` |
+| A relative link's target exists | `git ls-files --error-unmatch ` — only tracked files count |
+
+When you introduce a new kind of concrete claim — another version number, another mirrored file — add a row to
+the table above. A claim type missing from the table will eventually lie without anyone noticing.
+
+## Cross-document consistency
+
+The same rule must not appear in two docs with **different** conditions. For any rule you touch, establish
+which doc owns it, when it triggers, what it requires, and whether it has a documented exception. When you
+change an upstream rule, confirm that the carve-out a downstream doc grants for it still holds.
+
+Absolute phrasing ("always", "must", "never", "all") most often hides an unwritten exception. When you see it,
+first confirm the absolute really has no exception, then decide whether to keep or loosen it — some absolutes
+are deliberate non-negotiables.
+
+## When you find a discrepancy
+
+The **code on the current branch** wins; change the doc to match it. The exception is when the code is
+genuinely wrong — then fix the code and say so in the description.
+
+Delete stale content **outright**; don't stack corrections on top of it. A line saying "note: the sentence
+above no longer applies" only leaves the next reader distrusting both. Don't preserve an old value as history
+with wording like "used to be" or "kept for compatibility", and don't demote it to a comment — git remembers.
+
+## Honest completion claims
+
+Only say "fully checked", "verified", or "all fixed" when the evidence actually covers that scope. The
+accurate summary is usually narrower: which docs got a fact check and what backs it, versus which ones only
+got a structural pass or were deliberately left alone. A check you couldn't complete gets stated, not quietly
+skipped.
diff --git a/docs/protocol.md b/docs/protocol.md
new file mode 100644
index 0000000..da81df2
--- /dev/null
+++ b/docs/protocol.md
@@ -0,0 +1,248 @@
+# ScriptCat Bridge Protocol (WS, protocol v1)
+
+> The single authority for constants is
+> [`internal/pkg/protocol/protocol.json`](../internal/pkg/protocol/protocol.json); this document only explains
+> the semantics, and the json wins on any conflict. How that file is kept in sync with the extension-side
+> mirror is described in [development.md](./development.md#protocol-single-source). This document covers the
+> protocol only; security trade-offs are in [threat-model.md](./threat-model.md).
+
+## 1. Roles and topology
+
+```text
+MCP client ─ stdio ─→ sctl mcp ──┐ (separate local processes, not part of this protocol)
+CLI verbs (sctl install …)───────┤
+ ▼
+ sctl serve (daemon, WS server, binds 127.0.0.1:8643 only)
+ ▲ this protocol = the messages on this WS connection
+ ScriptCat extension (offscreen WS client)
+```
+
+This protocol defines only the **extension ↔ daemon** WS connection. `sctl mcp` / CLI verbs are separate
+local processes that reach the daemon over the `/control/*` HTTP/JSON API on the same listener (see
+[architecture.md](./architecture.md)); that channel is private to this binary, needs no cross-implementation
+interoperability, and is not specified here.
+
+Role convention: the daemon is the server but **not the authority** — the authority over script data and over
+write decisions lives on the extension side. Trust is flat: enrollment establishes one shared key, and the
+daemon does no per-client authorization of its own.
+
+## 2. Transport and envelope
+
+- WebSocket, JSON text frames, single-frame limit `limits.maxFrameBytes` (4 MiB); exceeding it yields
+ `PAYLOAD_TOO_LARGE` or an immediate disconnect;
+- the daemon listens on loopback only; plaintext `ws://` is allowed on loopback only, remote must use `wss://`
+ (not implemented in v1);
+- all messages share one envelope:
+
+```jsonc
+{ "v": 1, "type": "", "requestId": "", "payload": { } }
+```
+
+- `envelopeTypes` is grouped into the protocol's two layers — `session` (the crypto handshake, version/liveness
+ and session lifecycle) and `bridge` (the capability RPC of §4). The grouping is by function, not name prefix:
+ `bridge.shutdown` ends the whole session, so it is a `session` type. `bridge.request.payload` is opaque to the
+ session layer, which lets the two layers evolve independently;
+- `requestId` is generated by the initiator and echoed back verbatim by the responder; push messages
+ (`hello`, `bridge.cancel`, …) also carry their own `requestId`, used only for log correlation;
+- unknown `type`: ignore it and log it (forward compatible); a `v` other than 1: disconnect immediately;
+- heartbeat: either side may send `ping` (empty payload) and the peer replies `pong`; the suggested interval
+ is `limits.pingIntervalMs`, and two intervals without a response counts as a dropped connection.
+
+## 3. Connection lifecycle
+
+```text
+connect → auth.challenge → auth.response → auth.ok → hello → [business messages…] → close
+```
+
+Before the handshake completes the only message the daemon accepts is `auth.response`; anything else causes an
+immediate disconnect. Authentication
+that does not finish within `limits.authTimeoutMs` (5s) is disconnected. **Authentication failures do not echo
+a reason**: the connection is always closed with close code 1008 and no detailed reason (it gives a prober
+nothing; there is no Origin check, the handshake itself is the only gate).
+
+### 3.1 Session handshake (already paired, mutual HMAC)
+
+K is the 256-bit shared key established by pairing. Nonces are 32 random bytes, always lowercase hex; every
+comparison is constant-time.
+
+```text
+daemon → ext auth.challenge { nonceD }
+ext → daemon auth.response { mode: "session", nonceE,
+ hmac: HMAC(K, ctx.sessionExt || nonceD || nonceE) }
+daemon verifies → auth.ok { hmac: HMAC(K, ctx.sessionDaemon || nonceE || nonceD) }
+ext verifies → handshake complete
+```
+
+A verification failure on either side disconnects. Nonces are regenerated per connection, so replays are
+useless.
+
+### 3.2 Enrollment handshake (first-time / re-enrollment)
+
+Enrollment is the one and only step that needs an out-of-band code. Precondition: the user runs `sctl connect`
+in a terminal, the daemon generates a one-time code (Crockford base32, 8 characters, displayed as `XXXX-XXXX`,
+TTL 2 minutes, voided after 3 failed verifications) and opens an enrollment window; **the code is shown only in
+the terminal and never travels over the wire to any connection**. The user enters it into the extension's
+「外部接入 / External Access」 page. The wire `mode` for this handshake stays `pairing`.
+
+Once enrollment establishes the long-term key K, trust is flat: the CLI and every MCP agent inherit it through
+the extension ↔ daemon channel and never enroll again (see §6).
+
+Temporary keys are derived from the enrollment code (see `crypto.context` for salt/info):
+
+```text
+Kp_mac = HKDF-SHA-256(code, salt=pairKdfSalt, info=pairKdfInfoMac)
+Kp_enc = HKDF-SHA-256(code, salt=pairKdfSalt, info=pairKdfInfoEnc)
+```
+
+The handshake is the same as §3.1 but with `mode: "pairing"`, the MAC contexts swapped for `ctx.pairExt` /
+`ctx.pairDaemon`, and `Kp_mac` as the key. Once the daemon's check passes it generates a new long-term key K
+and attaches it to `auth.ok`:
+
+```jsonc
+{ "hmac": "…", "key": { "ciphertext": "", "iv": "" } }
+```
+
+The extension decrypts and persists K (`chrome.storage.local`); the daemon persists it to disk (0600). The
+enrollment window then closes. **Re-enrollment replaces**: the daemon keeps exactly one K (a single extension
+instance), and on replacement it immediately drops live connections that use the old key.
+
+### 3.3 hello and version negotiation
+
+Right after a successful handshake the daemon pushes:
+
+```jsonc
+{ "type": "hello", "payload": { "daemonVersion": "0.1.0", "protocolVersion": 1 } }
+```
+
+The extension checks: a mismatched `protocolVersion`, or `daemonVersion < versions.minDaemonVersion` →
+disconnect and set the UI state to "version too old".
+
+### 3.4 Closing
+
+- the daemon is about to exit → it pushes `bridge.shutdown` (empty payload) and then closes; the extension
+ enters exponential-backoff reconnection;
+- the extension disables external access / trips the kill switch → simply closing the connection is enough (no
+ dedicated message). The kill switch also discards K on the extension side, so every downstream client
+ (CLI + MCP) loses trust at once and re-enrollment is required.
+
+## 4. bridge actions (daemon → ext requests, executed by the extension)
+
+The daemon forwards MCP tool calls / CLI verbs as `bridge.request`:
+
+```jsonc
+{ "type": "bridge.request", "requestId": "…", "payload": {
+ "protocolVersion": 1,
+ "clientId": "' for an MCP instance>",
+ "action": "",
+ "input": { }
+} }
+```
+
+The reply is `bridge.response` (one of two payload shapes):
+
+```jsonc
+{ "ok": true, "result": { } }
+{ "ok": false, "error": { "code": "", "message": "…" } }
+```
+
+Trust is flat: a request that arrives over the enrolled channel is already authorized (enrollment established
+K). `clientId` is a **self-reported, unauthenticated audit label only** — it attributes the request in the
+extension's audit log and is never an authorization input (the extension never displays it on an approval
+screen either). `input` is validated against a strict whitelist; any extra field is `INVALID_REQUEST`. The
+per-operation gates that remain are human ones, applied by operation type regardless of client: write approval
+and source disclosure (§5).
+
+| action | input | result (when ok) |
+|---|---|---|
+| `scripts.list` | `{}` | `{ scripts: ScriptSummary[] }` |
+| `scripts.metadata.get` | `{ uuid }` | `ScriptMetadata` |
+| `scripts.source.get` | `{ uuid }` | `ScriptSource` (≤ `limits.maxSourceBytes`, beyond that `PAYLOAD_TOO_LARGE`) |
+| `scripts.install.request` | `{ url }` or `{ code }` (exactly one; both or neither = `INVALID_REQUEST`) | `{ uuid, name, version?, enabled }` (enabled defaults to false; it is true only when the user ticks "enable now" on the confirmation page) |
+| `scripts.toggle.request` | `{ uuid, enable: boolean }` | `{ uuid, enabled }` |
+| `scripts.delete.request` | `{ uuid }` | `{ uuid, deleted: true }` |
+
+The `ScriptSummary` / `ScriptMetadata` / `ScriptSource` shapes follow #1573 (`ScriptSource.contentTrust` is
+always `"untrusted-user-script-source"`; script-controlled text is always returned as structured data and must
+never be concatenated into a tool description or into Markdown). The metadata layer does not return the real
+update URL (it may contain a token), only `hasUpdateUrl`.
+
+## 5. Write operations and blocking semantics
+
+Write actions (`actions[*].write == true`) and source disclosure (`blocking: "disclosure"`) **pend** on the
+extension side; `bridge.response` is only sent once the user has decided on the confirmation page / disclosure
+dialog:
+
+- approved → execute (re-verify the TOCTOU anchors first: the staged `contentHash` and the target script's
+ `existingCodeHash`; a mismatch → `CONFLICT`) → `ok: true`;
+- rejected → `USER_REJECTED`;
+- pending for `limits.writeDecisionTtlMs` (5 minutes) → voided → `OPERATION_EXPIRED`;
+- the relevant global policy is "always-allow" → no pending, execute and return immediately. There are two such
+ policies, both applied identically to the CLI and to MCP: the **write policy** (install / toggle / delete)
+ and the **source-read policy** (source disclosure). Neither exempts the other, and the CLI is **not** exempt
+ from source disclosure (see [threat-model.md](./threat-model.md)).
+
+**Disconnect voids the request (bridge.cancel)**: if any link in the requester chain disappears (MCP client
+timeout or `notifications/cancelled`, shim exit, CLI Ctrl-C, internal session drop), the daemon immediately
+sends:
+
+```jsonc
+{ "type": "bridge.cancel", "requestId": "", "payload": {} }
+```
+
+The extension voids the corresponding operation, closes/invalidates the confirmation page, and sends **no**
+`bridge.response` for that requestId; the daemon ignores a reply that arrives after the cancel. Approval and
+voiding are **arbitrated serially on the extension side, first come first served, effective exactly once** —
+a dead request can never be approved. A full WS disconnect = an implicit cancel of every in-flight request.
+
+Confirmation-page semantics: accidentally closing the tab is not a rejection, the request stays pending (until
+it is voided or times out) and the extension can reopen it from the "pending confirmation" entry in the
+popup / settings page; multiple pending requests are shown one at a time.
+
+## 6. Flat trust (no per-client management)
+
+There is no per-client pairing, token minting, scope grant, or revocation. Enrollment (§3.2) establishes the
+single long-term key K; from then on the CLI and every MCP agent reach the daemon over the same enrolled
+channel and inherit its trust. Revocation collapses to the global kill switch (discard K, §3.4) or removing the
+server from an agent's own MCP config.
+
+The `pair.request` / `pair.decision` / `client.revoke` / `client.sync` envelope types are **removed from
+`envelopeTypes`** under flat trust — neither the daemon nor the extension produces or handles them, and a peer
+that still receives one ignores it (the forward-compatible path of §2). `McpClientRecord` and the per-client
+store are gone on both sides.
+
+## 7. Error codes
+
+| code | Trigger |
+|---|---|
+| `INVALID_REQUEST` | envelope/input validation failed, unknown action, extra fields |
+| `UNAUTHENTICATED` | retained in `errorCodes` for version stability; unused under flat trust (a handshake failure has no error code, it disconnects) |
+| `INSUFFICIENT_SCOPE` | retained for version stability; unused under flat trust (there are no per-client scopes) |
+| `WRITE_MODE_DISABLED` | retained for version stability; unused (write-session gating was removed — the kill switch drops K instead) |
+| `USER_APPROVAL_REQUIRED` | reserved; the blocking flow does not return it |
+| `USER_REJECTED` | the user pressed reject on the confirmation page / disclosure dialog |
+| `OPERATION_EXPIRED` | pending longer than writeDecisionTtlMs without a decision |
+| `CONFLICT` | the TOCTOU re-check failed at the moment of approval (staged/target hash mismatch, script changed) |
+| `NOT_FOUND` | the uuid does not exist |
+| `RATE_LIMITED` | over the daemon-side or extension-side rate limit |
+| `PAYLOAD_TOO_LARGE` | source over maxSourceBytes / frame over maxFrameBytes |
+| `INTERNAL_ERROR` | anything else; message carries no internal detail |
+
+Rate-limit defaults (implementation-tunable, not protocol constants): 5 enrollment attempts per minute; 60
+reads and 10 writes per minute keyed by the client audit label. Audit events never record a token, source
+code, or a URL containing credentials.
+
+## 8. Conformance and security notes
+
+- **Conformance**: both the extension-side conformance test and sctl CI compare their own bundled
+ `protocol.json` byte for byte against the authoritative copy; new enum members go through a
+ `protocolVersion` bump or the forward-compatible "ignore what you don't know" path;
+- **Origin whitelist**: the WS server rejects any request whose `Origin` is present and not an extension origin
+ (`chrome-extension://` / `moz-extension://` / `safari-web-extension://`); an empty Origin is allowed. This is
+ a cheap pre-filter for honest web pages only — a non-browser process can forge any Origin, so the handshake
+ remains the real gate;
+- **Threat boundary**: a local process with the user's full privileges is an explicit non-goal (it can read
+ the daemon's key file), see [threat-model.md](./threat-model.md);
+- **Extension implementation note** (not a protocol constraint): a pending request must not rely on a Promise
+ dangling in service-worker memory (an MV3 service worker can sleep); `bridge.response` is driven by the
+ decision event and sent back through offscreen, and the offscreen document is kept alive by the WS
+ connection.
diff --git a/docs/threat-model.md b/docs/threat-model.md
new file mode 100644
index 0000000..fd58804
--- /dev/null
+++ b/docs/threat-model.md
@@ -0,0 +1,117 @@
+# sctl Threat Model (bridge daemon + local control API)
+
+> Status: kept in sync with the sctl v0.1 daemon/CLI/MCP implementation. The authority for constants is
+> [`internal/pkg/protocol/protocol.json`](../internal/pkg/protocol/protocol.json), and the protocol semantics
+> are in [`protocol.md`](./protocol.md). This document covers only the security boundary and its trade-offs.
+
+## 1. Positioning and overall trade-offs
+
+sctl replaces the **"no listener"** design of the Native Messaging rewrite with a **"loopback WS listener plus
+a mutual authentication handshake"**. The selling point deliberately traded away is "no TCP listener on the
+host at all"; what it buys is zero new browser permissions, no installer, and single-binary distribution. The
+boundary therefore has to be rebuilt head-on: for a malicious web page the situation moves from "no entry
+point" to "can see that the port is open, and gets disconnected when the handshake fails".
+
+**There are only two trust anchors:**
+- the **long-term shared key K** between the extension and the daemon, established once by **enrollment**: the
+ daemon prints a one-time code in the terminal (never over the wire), the user types it into the extension's
+ 「外部接入 / External Access」 page, and K is derived and delivered under that code. Trust is **flat** — after
+ enrollment the CLI and every MCP agent inherit K through the extension ↔ daemon channel and never enroll
+ again; there is no per-client pairing, token, scope, or revocation.
+- the **control token** between the local frontend (`sctl mcp` / CLI verbs) and the daemon (written to a 0600
+ file once the daemon has bound its port; only same-user processes can read it).
+
+**The second gate, present throughout:** every write operation (install / toggle / delete) and every source
+disclosure is ultimately decided by **human approval in the browser**, applied identically to the CLI and to
+MCP; even if a malicious local process gets the control token and calls sctl, the write still needs the user to
+press approve on the extension's confirmation page (unless the corresponding global policy is set to
+"always-allow").
+
+## 2. Attack surface and countermeasures
+
+| Threat | Countermeasure | Residual risk |
+|---|---|---|
+| A web page connects straight to the daemon with `new WebSocket("ws://127.0.0.1:8643")` | An **Origin whitelist** rejects any connection whose `Origin` is present and not an extension origin (`chrome-extension://` etc.) — a cheap pre-filter that a browser page cannot get past (the browser stamps Origin, page JS cannot forge it). Beyond that, a connection must complete the mutual HMAC handshake before it can send or receive any business message; without credentials it fails the challenge-response and is disconnected on the 5s timeout **with no reason echoed back** (close 1008). A non-browser process can forge any Origin, so the handshake remains the real gate. Both rejections are recorded in the daemon-side audit (§6) | A page can probe that the port is open |
+| A web page impersonates the local frontend with `fetch("http://127.0.0.1:8643/control/…")` | Apart from `/control/health`, every control API requires an `X-Sctl-Control-Token` header, compared in constant time against the daemon's 0600 token; a web page cannot read that file, so it gets a 401 and the action never runs at all | Port / health information can be probed (see below) |
+| A local process grabs 8643 to impersonate the daemon, or connects in while impersonating the extension | **Mutual** HMAC-SHA-256 challenge-response between the extension and the daemon ([protocol.md](./protocol.md) §3.1); the long-term key K comes from a one-time enrollment code and never travels in plaintext; nonces are regenerated per connection, so replays are useless | See the "malicious same-user process" row |
+| A local process (CLI or MCP agent) that reaches the daemon requests a privileged action | Flat trust deliberately grants any control-token holder full read/list and the ability to *request* writes; the gate is not per-client authorization but the **per-operation human gate**: writes need browser approval and source reads need disclosure approval, both keyed by script (extension session), plus read/write rate limits. There is no per-client scope to escalate | Any same-user process that can run sctl can list scripts and request writes (writes still gated); an accepted trade-off for single-enrollment simplicity — see §3 |
+| Write operations are abused (installing a malicious script / bulk deletion) | Two-phase confirmation plus a TOCTOU re-check at the moment of approval (staged `contentHash`, target `existingCodeHash`); calls are purely blocking, so a requester disconnect voids them. The install page's own enable toggle decides the enabled state (installs are usable immediately, like a normal install). "Always-allow" is an explicit security-downgrade switch (amber warning in the UI) | Under "always-allow" a write is no longer confirmed by a human — the user takes that risk |
+| Source code leaks | Source disclosure is gated by its own **source-read policy** (approval by default), applied to the CLI and MCP alike — the CLI is **not** exempt; reading is a privacy matter and is not covered by the write policy. Script-controlled text is always returned as structured data (`contentTrust: untrusted-user-script-source`) and must never be concatenated into a tool description | Under a "always-allow" source-read policy, reads are no longer confirmed — the user takes that risk |
+| The port's existence is found by scanning | Accepted: the extension is the client and cannot read a discovery file, so the default port 8643 has to be fixed; authentication is the backstop | The open port is visible |
+| A malicious same-user process reads the daemon key file / control token (0600) | **Explicit non-goal** — a process holding the user's full privileges is already past the capability boundary of any local scheme | See the explicit non-goals in §3 |
+
+## 3. Explicit non-goals
+
+- **A malicious local process with the user's full privileges**: it can read `pairing.key` / `control.token`
+ (both 0600) and can ptrace this user's processes. No purely local scheme can stop it; browser-side human
+ approval of write operations is the only mitigation still in effect (unless the user turned on
+ "always-allow").
+- **Per-client isolation**: flat trust intentionally drops per-agent tokens, scopes, and individual
+ revocation. Any same-user process that holds the control token has the same capabilities (full read/list,
+ request writes). Revocation collapses to the global kill switch (discard K) or removing the server from an
+ agent's own MCP config. This is the deliberate trade for single-enrollment simplicity; the per-operation
+ human gates (write approval, source disclosure) are what still bound damage.
+- **`clientId` as an authorization input**: the label a request carries (`sctl-cli`, `scriptcat-`, or an
+ MCP client's self-reported `clientInfo.name`) is unauthenticated and forgeable. It is used only for audit
+ attribution and is never shown on an approval screen or used to gate anything.
+- **Plaintext `ws://` and remote access**: loopback only; the daemon refuses to bind a non-loopback address.
+ Remote `wss://` (TLS plus server identity) is a separate later design and is not implemented in v1.
+
+## 4. The control channel (internal local connection) in detail
+
+`sctl mcp` / CLI verbs and the daemon are **separate processes** that talk over the `/control/*` HTTP/JSON API
+on the daemon's listener (same port as the extension WS surface, separate path). Security properties:
+
+- **The only transport gate is the control token**: the daemon generates and writes the 0600 token file only
+ **after** it has successfully bound the port (the loser of a port race does not write, so it cannot
+ overwrite the winner's file); once the frontend sees a 200 from `/control/health` it knows the token is
+ ready.
+- **One identity dimension**: the control token (always required, proving same-user) is the whole of it. A
+ request may carry an optional `X-Sctl-Client` label, but that only sets the audit attribution — it grants
+ and constrains nothing.
+- **No exemption for writes**: the control token only proves "same user on this host", it does not bypass
+ browser-side human approval. Even holding the control token, a malicious local process still needs the user
+ to press approve in the extension before a write happens (unless the corresponding "always-allow" policy is
+ on).
+- **The health check is unauthenticated**: it returns only `{ok, version}` and leaks no key, script, or client
+ information; it is equivalent to the already-accepted risk that an open port is probeable.
+- **Disconnect voids the request**: the frontend request (HTTP connection) drops → the daemon's request ctx is
+ cancelled → `bridge.cancel` is sent to the extension to void the in-flight write operation.
+
+## 5. Credentials persisted to disk
+
+| File | Mode | Contents | Impact if leaked |
+|---|---|---|---|
+| `/pairing.key` | 0600 | The extension's long-term key K (hex), established by enrollment | Allows impersonating the extension when connecting to the daemon; still bound by write approval |
+| `/control.token` | 0600 | The local control-channel token (regenerated on every serve start) | Allows impersonating the local frontend to call the control API; writes still need browser approval |
+
+Three iron rules: **a token's plaintext never goes over the wire, never enters a log, never enters a URL**;
+audit events **never record** a token, source code, or a URL containing credentials; keys are always persisted
+to disk as 0600 in a 0700 directory and written atomically (temp file plus rename, no window with overly wide
+permissions).
+
+## 6. Daemon-side audit
+
+The authoritative audit store lives on the **extension side** (the audit view via the extension's existing
+logger, `component: local-access`): what an accepted request did is determined by that record.
+
+The daemon side only fills in the part the extension **cannot see** — events that were blocked before an
+extension session was ever established and therefore leave no extension-side record at all:
+
+| Event | Trigger |
+|---|---|
+| `origin.rejected` | A WS connection was rejected by the Origin whitelist (present, non-extension Origin) |
+| `handshake.failed` | Handshake HMAC verification failed / the 5s timeout elapsed / a non-`auth.response` message was sent during the handshake |
+| `pairing.failed` | The enrollment handshake HMAC failed, or there was no valid enrollment code |
+| `pairing.rate_limited` | Enrollment attempts exceeded 5 per minute |
+| `request.rate_limited` | A client's read/write requests went over the limit and were rejected before being forwarded to the extension |
+| `handshake.ok` | A session was established |
+
+To view: `sctl status` prints one summary line aggregated by type, and `sctl status --json` outputs the full
+events.
+
+Boundary: the queryable store is **in memory only** (a fixed-capacity ring buffer, cleared whenever the daemon
+restarts). Every event is additionally emitted once as a structured `warn` log line, so it also lands in
+`/logs/sctl.log` (0600 in a 0700 directory) with the rest of the daemon's logs. What keeps that safe
+is the closed event field set — time / type / client identifier / reason category, with no outlet for an
+arbitrary payload — which is what enforces the iron rules of §5 on both outlets.
diff --git a/docs/verification.md b/docs/verification.md
new file mode 100644
index 0000000..cffa71b
--- /dev/null
+++ b/docs/verification.md
@@ -0,0 +1,87 @@
+# Verification
+
+Green unit tests only prove **the behaviors you asserted** — not that the change actually works. This document
+owns the latter: before saying "fixed" or "it works", you need real output as evidence.
+
+## Order: cheap signals first, then drive the real thing
+
+```bash
+go build ./... && go vet ./... # nothing else matters if it doesn't compile
+go test ./... -race
+golangci-lint run ./...
+```
+
+Only once this set passes is it worth spending time starting processes. Doing it the other way round is just
+debugging compile errors through a slow feedback loop.
+
+## Driving a real daemon
+
+`SCTL_DATA_DIR` and `SCTL_BRIDGE_ADDR` (defined in
+[development.md](./development.md#environment-variables)) let you start a daemon on an isolated data directory
+and port without touching the one you use day to day:
+
+```bash
+go build -o sctl ./cmd/sctl
+export SCTL_DATA_DIR=$(mktemp -d) SCTL_BRIDGE_ADDR=127.0.0.1:18643
+./sctl serve & # start the daemon (writes control.token)
+./sctl status # should report truthfully that no extension is connected
+./sctl scripts list # no extension connected → "extension not connected" error, exit code 3
+```
+
+Cover **the boundaries that motivated the change**: if you touched exit-code mapping, exercise every code; if
+you touched the pairing window, walk the expiry path too. Running only the happy path is not verification.
+
+Mind the version floor before integrating with a real extension (see
+[development.md](./development.md#version-floor)): the `0.0.0-dev` produced by a plain `go build` is below
+`minDaemonVersion` and the extension will reject it as too old and disconnect.
+
+## One-shot end-to-end verification, not a new test suite
+
+One-shot verification scripts and their evidence go under the git-ignored `e2e/scratch/`, one directory per
+scenario, and are discarded after use:
+
+```text
+e2e/scratch//
+├── run.sh # one-shot driver script
+├── report.md # what was run, what was observed, the conclusion
+└── *.log # raw output
+```
+
+- **Don't** run the entire heavy test suite just to check one thing, and **don't** casually leave a scratch
+ script behind as a permanent case.
+- A behavior genuinely worth guarding long-term is a separate decision: judge it once, then commit it as a
+ **proper** test. It is not a by-product of verification.
+- `e2e/scratch/` never enters version control. Reference the conclusions from your local `report.md` in the PR
+ or the conversation; don't commit or paste raw logs.
+
+## Reproduction is step one of a fix
+
+The order for fixing a bug is: first write a script under `e2e/scratch/` that reproduces it reliably
+(**proving the bug exists**), then narrow that into a failing committed test, and only then change code.
+
+A scratch reproduction **cannot** replace the failing test you are going to commit — it only answers "is this
+real?", while the committed test answers "will it happen again?".
+
+If it doesn't reproduce, say so plainly and stop. Fixing a problem that was never confirmed to exist usually
+just buries the real cause deeper.
+
+## Surfaces you can't drive: watch their side effects
+
+You cannot click through the browser-extension side, but every decision it makes leaves a trace on the daemon
+side. The observable outlets are:
+
+- `./sctl status` — whether the extension is connected, how many clients are paired, a summary of recent
+ security events;
+- audit events (`internal/pkg/audit`) — handshakes, pairing, revocation, and rejections are all recorded;
+- stderr logs with `--log-level` turned up;
+- on-disk state under `$SCTL_DATA_DIR` (keys, client store).
+
+The full chain that needs a human clicking in the browser (pair → list → source disclosure → install approval
+→ revoke → kill switch) is cross-repository integration work. It waits until the extension-side build is ready
+and then follows the verification doc in the main repository; on this side, check the side effects above first.
+
+## Before claiming completion
+
+- Report the commands you ran and their real output, not "it should be fine";
+- If a test failed, say it failed and attach the output; if you skipped a step, say which one;
+- For what you genuinely finished and verified, state it plainly, without vague hedging.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..08ceaf2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,34 @@
+module github.com/scriptscat/sctl
+
+go 1.26.3
+
+require (
+ github.com/cago-frame/cago v0.0.0-20260715061808-cfde8cba47ae
+ github.com/coder/websocket v1.8.15
+ github.com/google/uuid v1.6.0
+ github.com/modelcontextprotocol/go-sdk v1.6.1
+ github.com/smartystreets/goconvey v1.8.1
+ github.com/spf13/cobra v1.10.2
+ go.uber.org/zap v1.28.0
+)
+
+require (
+ github.com/google/jsonschema-go v0.4.3 // indirect
+ github.com/gopherjs/gopherjs v1.17.2 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jtolds/gls v4.20.0+incompatible // indirect
+ github.com/kr/pretty v0.3.1 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
+ github.com/smarty/assertions v1.15.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e95bb63
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,78 @@
+github.com/cago-frame/cago v0.0.0-20260715061808-cfde8cba47ae h1:mxPi5pZkXWfJS1zX9JJkWaUYhReq3xDSwVfUxuvS84k=
+github.com/cago-frame/cago v0.0.0-20260715061808-cfde8cba47ae/go.mod h1:OLDUyIuil1ATOw8ST5SCsbjumOCAbZQS/5nZ6XpgG0c=
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
+github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
+github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
+github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
+github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..dc89439
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,104 @@
+// Package cli 定义 sctl 的 cobra 子命令。serve 引导一个 cago 应用并挂载桥接 Component;
+// 其余命令是驱动常驻 daemon 的本机内部控制客户端(见 internal/client/control),或纯本地操作。
+//
+// 输出约定:stdout 只承载用户可读结果 / --json 结构化输出 / MCP 协议(sctl mcp);诊断日志一律
+// 走全局 stderr/文件 logger(cmd/sctl 的 PersistentPreRunE 已初始化)。
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/pkg/logging"
+)
+
+// Version / Commit / BuildDate 由发布工作流通过 -ldflags 注入。
+// 源码构建时保留 dev 占位值,便于区分“自己 go build 的”与“发布产物”。
+var (
+ Version = "0.0.0-dev"
+ Commit = "none"
+ BuildDate = "unknown"
+)
+
+// 全局标志(绑定为包级变量,任意子命令直接读取)。
+var (
+ jsonOutput bool
+ logLevel string
+)
+
+// 退出码约定(对外文档见 README.md「写操作阻塞语义与退出码」):写动词按用户决策映射,
+// 其余错误统一 exitError。
+const (
+ exitOK = 0
+ exitRejected = 1 // 用户在浏览器确认页拒绝
+ exitVoided = 2 // 作废 / 超时 / Ctrl-C 取消 / 扩展断开
+ exitError = 3 // 其余错误(校验失败、NOT_FOUND、连接失败、内部错误…)
+)
+
+// ExitError 携带自定义退出码,由 cmd/sctl 的 main 解包为 os.Exit。Message 非空时打印到 stderr。
+type ExitError struct {
+ Code int
+ Message string
+}
+
+func (e *ExitError) Error() string { return e.Message }
+
+// NewRootCmd 组装 sctl 根命令:全局 --log-level / --json 标志,并挂载全部子命令。
+func NewRootCmd() *cobra.Command {
+ root := &cobra.Command{
+ Use: "sctl",
+ Short: "ScriptCat 控制工具:本地桥接 daemon、MCP server 与脚本管理命令",
+ SilenceUsage: true,
+ SilenceErrors: true,
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ logging.Setup(logLevel)
+ return nil
+ },
+ }
+ root.PersistentFlags().StringVar(&logLevel, "log-level", "info", "日志级别 debug|info|warn|error(始终输出到 stderr)")
+ root.PersistentFlags().BoolVar(&jsonOutput, "json", false, "以 JSON 输出结构化结果(供脚本消费)")
+ root.AddCommand(
+ newServeCmd(),
+ newMcpCmd(),
+ newConnectCmd(),
+ newStatusCmd(),
+ newVersionCmd(),
+ newScriptsCmd(),
+ newInstallCmd(),
+ newToggleCmd(true),
+ newToggleCmd(false),
+ newRmCmd(),
+ )
+ return root
+}
+
+// printResultJSON 把桥接返回的原始结果 JSON 原样美化打印到 stdout(--json 路径)。
+func printResultJSON(raw json.RawMessage) error {
+ var buf []byte
+ var pretty any
+ if err := json.Unmarshal(raw, &pretty); err != nil {
+ // 非法 JSON 时原样输出,避免吞掉信息。
+ buf = raw
+ } else {
+ b, err := json.MarshalIndent(pretty, "", " ")
+ if err != nil {
+ return err
+ }
+ buf = b
+ }
+ fmt.Fprintln(os.Stdout, string(buf))
+ return nil
+}
+
+// printValueJSON 把任意值以美化 JSON 打印到 stdout。
+func printValueJSON(v any) error {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(os.Stdout, string(b))
+ return nil
+}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..45738b9
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,231 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// stubDaemon 起一个假 daemon 控制端点:健康检查恒 200,/control/call 恒返回给定 CallResult,
+// 并把前端指向它(SCTL_BRIDGE_ADDR + 临时 SCTL_DATA_DIR 里的 control.token)。
+func stubDaemon(t *testing.T, result control.CallResult) {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc(control.PathCall, func(w http.ResponseWriter, _ *http.Request) {
+ _ = json.NewEncoder(w).Encode(result)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://"))
+ dir := t.TempDir()
+ t.Setenv("SCTL_DATA_DIR", dir)
+ So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil)
+}
+
+// runCLI 执行一次子命令,返回退出码与 stdout。
+func runCLI(args ...string) (int, string) {
+ code, out, _ := runCLICapture(args...)
+ return code, out
+}
+
+// runCLICapture 执行一次子命令,分别返回退出码、stdout 与 stderr。
+func runCLICapture(args ...string) (int, string, string) {
+ oldOut, oldErr := os.Stdout, os.Stderr
+ rOut, wOut, _ := os.Pipe()
+ rErr, wErr, _ := os.Pipe()
+ os.Stdout, os.Stderr = wOut, wErr
+ outCh, errCh := make(chan string, 1), make(chan string, 1)
+ go func() {
+ b, _ := io.ReadAll(rOut)
+ outCh <- string(b)
+ }()
+ go func() {
+ b, _ := io.ReadAll(rErr)
+ errCh <- string(b)
+ }()
+
+ root := NewRootCmd()
+ root.SetArgs(args)
+ err := root.Execute()
+
+ wOut.Close()
+ wErr.Close()
+ os.Stdout, os.Stderr = oldOut, oldErr
+ return exitCodeOf(err), <-outCh, <-errCh
+}
+
+func exitCodeOf(err error) int {
+ if err == nil {
+ return exitOK
+ }
+ var ee *ExitError
+ if errors.As(err, &ee) {
+ return ee.Code
+ }
+ return -1
+}
+
+// stubDaemonStatus 起一个假 daemon,/control/status 恒返回给定状态。
+func stubDaemonStatus(t *testing.T, st control.StatusResult) {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc(control.PathHealth, func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc(control.PathStatus, func(w http.ResponseWriter, _ *http.Request) {
+ _ = json.NewEncoder(w).Encode(st)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ t.Setenv("SCTL_BRIDGE_ADDR", strings.TrimPrefix(srv.URL, "http://"))
+ dir := t.TempDir()
+ t.Setenv("SCTL_DATA_DIR", dir)
+ So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil)
+}
+
+func TestBlockingHint(t *testing.T) {
+ Convey("写动词在等待浏览器裁决期间给出提示", t, func() {
+ Convey("提示走 stderr,不污染 stdout 的结构化输出", func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`{"uuid":"u1","name":"x"}`)})
+ code, out, errOut := runCLICapture("install", "--json", "https://example.com/x.user.js")
+ So(code, ShouldEqual, exitOK)
+ So(errOut, ShouldContainSubstring, "等待浏览器确认")
+ // stdout 必须是干净可解析的 JSON,提示混进来就会解析失败。
+ var parsed map[string]any
+ So(json.Unmarshal([]byte(out), &parsed), ShouldBeNil)
+ })
+
+ Convey("只读动词不提示", func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`[]`)})
+ _, _, errOut := runCLICapture("scripts", "list", "--json")
+ So(errOut, ShouldNotContainSubstring, "等待浏览器确认")
+ })
+
+ Convey("daemon 连不上时不该先报等待确认", func() {
+ t.Setenv("SCTL_BRIDGE_ADDR", "127.0.0.1:1")
+ dir := t.TempDir()
+ t.Setenv("SCTL_DATA_DIR", dir)
+ So(os.WriteFile(filepath.Join(dir, "control.token"), []byte("tok"), 0o600), ShouldBeNil)
+ code, _, errOut := runCLICapture("rm", "u1")
+ So(code, ShouldEqual, exitError)
+ So(errOut, ShouldNotContainSubstring, "等待浏览器确认")
+ })
+ })
+}
+
+func TestStatusSecurityEvents(t *testing.T) {
+ Convey("status 呈现守卫侧安全事件", t, func() {
+ Convey("有事件时人读输出给出按类型聚合的摘要", func() {
+ stubDaemonStatus(t, control.StatusResult{
+ DaemonVersion: "0.1.0",
+ SecurityCount: 3,
+ Security: []audit.Event{
+ {Type: audit.TypeHandshakeFailed, Reason: audit.ReasonHMACMismatch},
+ {Type: audit.TypeHandshakeFailed, Reason: audit.ReasonHMACMismatch},
+ {Type: audit.TypeRequestRateLimited, Client: "c1"},
+ },
+ })
+ code, out := runCLI("status")
+ So(code, ShouldEqual, exitOK)
+ So(out, ShouldContainSubstring, "近期安全事件")
+ So(out, ShouldContainSubstring, "handshake.failed×2")
+ So(out, ShouldContainSubstring, "request.rate_limited×1")
+ })
+
+ Convey("无事件时不打印安全事件行", func() {
+ stubDaemonStatus(t, control.StatusResult{DaemonVersion: "0.1.0"})
+ code, out := runCLI("status")
+ So(code, ShouldEqual, exitOK)
+ So(out, ShouldNotContainSubstring, "近期安全事件")
+ })
+
+ Convey("--json 输出完整事件供脚本消费", func() {
+ stubDaemonStatus(t, control.StatusResult{
+ DaemonVersion: "0.1.0",
+ SecurityCount: 1,
+ Security: []audit.Event{{Type: audit.TypeHandshakeFailed, Reason: audit.ReasonHMACMismatch}},
+ })
+ code, out := runCLI("status", "--json")
+ So(code, ShouldEqual, exitOK)
+ var got control.StatusResult
+ So(json.Unmarshal([]byte(out), &got), ShouldBeNil)
+ So(got.Security, ShouldHaveLength, 1)
+ So(got.Security[0].Reason, ShouldEqual, audit.ReasonHMACMismatch)
+ })
+ })
+}
+
+func TestWriteVerbExitCodes(t *testing.T) {
+ Convey("写动词按用户决策映射退出码", t, func() {
+ Convey("批准 → 退出码 0", func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`{"uuid":"u1","name":"x","enabled":false}`)})
+ code, _ := runCLI("install", "https://example.com/x.user.js")
+ So(code, ShouldEqual, exitOK)
+ })
+
+ Convey("拒绝(USER_REJECTED)→ 退出码 1", func() {
+ stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "USER_REJECTED"}})
+ code, _ := runCLI("rm", "u1")
+ So(code, ShouldEqual, exitRejected)
+ })
+
+ Convey("作废(OPERATION_EXPIRED)→ 退出码 2", func() {
+ stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "OPERATION_EXPIRED"}})
+ code, _ := runCLI("enable", "u1")
+ So(code, ShouldEqual, exitVoided)
+ })
+
+ Convey("其他错误(NOT_FOUND)→ 退出码 3", func() {
+ stubDaemon(t, control.CallResult{OK: false, Error: &control.CallError{Code: "NOT_FOUND", Message: "no such script"}})
+ code, _ := runCLI("disable", "u1")
+ So(code, ShouldEqual, exitError)
+ })
+ })
+}
+
+func TestJSONOutput(t *testing.T) {
+ Convey("--json 输出结构化结果", t, func() {
+ Convey("scripts list --json 打印原始结果 JSON", func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`{"scripts":[{"uuid":"u1","name":"demo","enabled":true,"version":"1.0"}]}`)})
+ code, out := runCLI("--json", "scripts", "list")
+ So(code, ShouldEqual, exitOK)
+ // 输出可被解析回结构化对象,且含 scripts 键。
+ var parsed map[string]any
+ So(json.Unmarshal([]byte(out), &parsed), ShouldBeNil)
+ So(parsed, ShouldContainKey, "scripts")
+ })
+
+ Convey("scripts list 人读表格含表头与脚本名", func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`{"scripts":[{"uuid":"u1","name":"demo","enabled":true,"version":"1.0"}]}`)})
+ code, out := runCLI("scripts", "list")
+ So(code, ShouldEqual, exitOK)
+ So(out, ShouldContainSubstring, "UUID")
+ So(out, ShouldContainSubstring, "demo")
+ })
+ })
+}
+
+func TestSourceToStdout(t *testing.T) {
+ Convey("scripts source 把源码原样写 stdout", t, func() {
+ stubDaemon(t, control.CallResult{OK: true, Result: json.RawMessage(`{"code":"// ==UserScript==\n","contentTrust":"untrusted-user-script-source"}`)})
+ code, out := runCLI("scripts", "source", "u1")
+ So(code, ShouldEqual, exitOK)
+ So(out, ShouldEqual, "// ==UserScript==\n")
+ })
+}
diff --git a/internal/cli/connect.go b/internal/cli/connect.go
new file mode 100644
index 0000000..647307c
--- /dev/null
+++ b/internal/cli/connect.go
@@ -0,0 +1,34 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+)
+
+// newConnectCmd 打开一次接入窗口并打印一次性配对码,供用户输入到扩展的「外部接入」页面完成接入
+// (2 分钟内有效)。接入只需一次:此后 CLI 与所有 MCP agent 都经这条可信通道继承信任。
+func newConnectCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "connect",
+ Short: "生成一次性配对码,与 ScriptCat 扩展建立外部接入",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
+ defer stop()
+ client, err := control.Dial(ctx)
+ if err != nil {
+ return err
+ }
+ code, err := client.Enroll(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(os.Stdout, "接入配对码: %s\n请在 ScriptCat 扩展设置的「工具 › 外部接入」中输入此码完成接入(2 分钟内有效)。\n此码只在本终端显示,请勿转发。\n", code)
+ return nil
+ },
+ }
+}
diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go
new file mode 100644
index 0000000..ff551f8
--- /dev/null
+++ b/internal/cli/dispatch.go
@@ -0,0 +1,85 @@
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/signal"
+
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+)
+
+// dispatch 是所有走桥接的动词的公共骨架:自动拉起并连上 daemon、以可被 Ctrl-C 取消的 ctx 发起
+// 阻塞调用,再把结果/错误映射为退出码。onOK 负责把成功结果打印出来。
+//
+// Ctrl-C 会取消 ctx → 切断到 daemon 的连接 → daemon 向扩展发 bridge.cancel 作废操作;此处映射为
+// exitVoided。桥接业务错误按 code 映射:USER_REJECTED→exitRejected,OPERATION_EXPIRED→exitVoided,
+// 其余→exitError。
+func dispatch(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error {
+ return dispatchAction(cmd, action, input, false, onOK)
+}
+
+// dispatchBlocking 与 dispatch 相同,但用于写动词:调用前告知用户正在等待浏览器裁决。
+func dispatchBlocking(cmd *cobra.Command, action string, input json.RawMessage, onOK func(result json.RawMessage) error) error {
+ return dispatchAction(cmd, action, input, true, onOK)
+}
+
+func dispatchAction(cmd *cobra.Command, action string, input json.RawMessage, blocking bool, onOK func(result json.RawMessage) error) error {
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
+ defer stop()
+
+ client, err := control.Dial(ctx)
+ if err != nil {
+ return &ExitError{Code: exitError, Message: err.Error()}
+ }
+ // 连上之后才提示,否则 daemon 不可用时会先报一句误导的「等待确认」。
+ // 走 stderr:stdout 只承载结果 / --json 输出(见包注释)。
+ if blocking {
+ fmt.Fprintln(os.Stderr, "等待浏览器确认…(可 Ctrl-C 取消并作废本次操作)")
+ }
+ res, err := client.Call(ctx, action, input)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return &ExitError{Code: exitVoided, Message: "已取消,操作作废"}
+ }
+ return &ExitError{Code: exitError, Message: err.Error()}
+ }
+ if res.OK {
+ if onOK != nil {
+ if err := onOK(res.Result); err != nil {
+ return &ExitError{Code: exitError, Message: err.Error()}
+ }
+ }
+ return nil
+ }
+ return mapBridgeError(res.Error)
+}
+
+// mapBridgeError 把桥接错误码映射为带退出码的 ExitError。
+func mapBridgeError(e *control.CallError) error {
+ if e == nil {
+ return &ExitError{Code: exitError, Message: "调用失败"}
+ }
+ switch e.Code {
+ case "USER_REJECTED":
+ return &ExitError{Code: exitRejected, Message: "操作被用户拒绝"}
+ case "OPERATION_EXPIRED":
+ return &ExitError{Code: exitVoided, Message: "操作已作废或超时"}
+ default:
+ return &ExitError{Code: exitError, Message: e.Error()}
+ }
+}
+
+// mustInput 把一个可 JSON 序列化的输入编码为 bridge action 的 input。
+func mustInput(v any) json.RawMessage {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ // 输入均为本地构造的简单结构,序列化失败是编程错误。
+ panic(err)
+ }
+ return raw
+}
diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go
new file mode 100644
index 0000000..8efb31e
--- /dev/null
+++ b/internal/cli/mcp.go
@@ -0,0 +1,52 @@
+package cli
+
+import (
+ "os"
+ "os/signal"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/client/mcpserver"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// newMcpCmd 构造 `sctl mcp`(stdio MCP server)。扁平信任下 MCP agent 经已接入的可信通道继承信任、
+// 无需各自配对;--name 仅作审计标签,区分同机多份 MCP 配置在扩展审计日志中的归因。
+func newMcpCmd() *cobra.Command {
+ var name string
+ cmd := &cobra.Command{
+ Use: "mcp",
+ Short: "以 stdio 运行 MCP server(daemon 未运行时自动拉起)",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runMcpServe(cmd, name)
+ },
+ }
+ cmd.Flags().StringVar(&name, "name", "default", "MCP 实例名(审计标签,区分多份配置)")
+ return cmd
+}
+
+// runMcpServe 连上 daemon 后以 stdio 提供 MCP 服务,暴露全部脚本工具(授权由扩展侧审批闸门把关)。
+// stdout 由 MCP 协议独占——本函数除经 transport 外绝不写 stdout。
+func runMcpServe(cmd *cobra.Command, name string) error {
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
+ defer stop()
+
+ client, err := control.Dial(ctx)
+ if err != nil {
+ return err
+ }
+ p, err := protocol.Load()
+ if err != nil {
+ return err
+ }
+
+ srv := mcpserver.New(mcpserver.Deps{
+ Name: "scriptcat-" + name,
+ Version: Version,
+ Proto: p,
+ Caller: client.WithClientLabel("scriptcat-" + name),
+ })
+ return srv.Run(ctx, &mcp.StdioTransport{})
+}
diff --git a/internal/cli/scripts.go b/internal/cli/scripts.go
new file mode 100644
index 0000000..0332e9b
--- /dev/null
+++ b/internal/cli/scripts.go
@@ -0,0 +1,106 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "text/tabwriter"
+
+ "github.com/spf13/cobra"
+)
+
+// newScriptsCmd 构造 `sctl scripts` 及其只读子命令 list / info / source。
+func newScriptsCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "scripts",
+ Short: "查看扩展中的用户脚本(list / info / source)",
+ }
+ cmd.AddCommand(newScriptsListCmd(), newScriptsInfoCmd(), newScriptsSourceCmd())
+ return cmd
+}
+
+func newScriptsListCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "列出已安装脚本",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return dispatch(cmd, "scripts.list", mustInput(struct{}{}), func(result json.RawMessage) error {
+ if jsonOutput {
+ return printResultJSON(result)
+ }
+ return printScriptList(result)
+ })
+ },
+ }
+}
+
+func newScriptsInfoCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "info ",
+ Short: "查看单个脚本的元数据",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // 元数据结构由扩展定义,统一以美化 JSON 呈现(--json 与人读同源)。
+ return dispatch(cmd, "scripts.metadata.get", mustInput(map[string]string{"uuid": args[0]}), printResultJSON)
+ },
+ }
+}
+
+func newScriptsSourceCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "source ",
+ Short: "输出单个脚本的源码到 stdout(可重定向)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return dispatch(cmd, "scripts.source.get", mustInput(map[string]string{"uuid": args[0]}), func(result json.RawMessage) error {
+ if jsonOutput {
+ return printResultJSON(result)
+ }
+ return printScriptSource(result)
+ })
+ },
+ }
+}
+
+// scriptSummary 是 scripts.list 结果元素的宽松视图:只取人读表格所需字段,其余忽略;
+// --json 路径始终原样输出完整结果,不受此结构约束。
+type scriptSummary struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Version string `json:"version"`
+}
+
+func printScriptList(result json.RawMessage) error {
+ var payload struct {
+ Scripts []scriptSummary `json:"scripts"`
+ }
+ if err := json.Unmarshal(result, &payload); err != nil {
+ // 结构不符预期时退回原样 JSON,不吞信息。
+ return printResultJSON(result)
+ }
+ if len(payload.Scripts) == 0 {
+ fmt.Fprintln(os.Stdout, "(无已安装脚本)")
+ return nil
+ }
+ tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(tw, "UUID\tNAME\tENABLED\tVERSION")
+ for _, s := range payload.Scripts {
+ fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", s.UUID, s.Name, s.Enabled, s.Version)
+ }
+ return tw.Flush()
+}
+
+func printScriptSource(result json.RawMessage) error {
+ var payload struct {
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(result, &payload); err == nil && payload.Code != "" {
+ // 源码原样输出(不加尾换行,忠实可重定向为 .user.js)。
+ _, err := os.Stdout.WriteString(payload.Code)
+ return err
+ }
+ // 无 code 字段时退回原样 JSON。
+ return printResultJSON(result)
+}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
new file mode 100644
index 0000000..5d8f1ca
--- /dev/null
+++ b/internal/cli/serve.go
@@ -0,0 +1,59 @@
+package cli
+
+import (
+ "context"
+ "os"
+
+ "github.com/cago-frame/cago"
+ "github.com/cago-frame/cago/configs"
+ "github.com/cago-frame/cago/configs/source"
+ "github.com/cago-frame/cago/pkg/logger"
+ "github.com/spf13/cobra"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/daemon"
+)
+
+// newServeCmd 引导 cago 应用并挂载桥接 Component。
+//
+// 不使用 component.Core():cago 的 Core 把日志写 stdout,而桥接 daemon 可能由 `sctl mcp` /
+// CLI 动词自动拉起、其 stdout 需保持洁净;日志已由全局 stderr logger 承载。
+func newServeCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "serve",
+ Short: "运行桥接 daemon(WS server,仅监听 127.0.0.1)",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ cfg, err := loadServeConfig(ctx)
+ if err != nil {
+ return err
+ }
+ logger.Ctx(ctx).Info("启动 sctl serve", zap.String("version", Version))
+ return cago.New(ctx, cfg).
+ RegistryCancel(daemon.Component(Version)).
+ Start()
+ },
+ }
+}
+
+// loadServeConfig 加载 cago 配置。有 ./configs/config.yaml 时从文件读;否则(如 sctl mcp 从任意
+// 目录自动拉起 daemon)退回内存空源,全部走内置默认(protocol.json + SCTL_* 环境变量),
+// 不因缺配置文件而拒绝启动。
+func loadServeConfig(ctx context.Context) (*configs.Config, error) {
+ const configFile = "./configs/config.yaml"
+ if _, err := os.Stat(configFile); err == nil {
+ return configs.NewConfig("sctl")
+ }
+ logger.Ctx(ctx).Info("未找到 configs/config.yaml,使用内置默认配置启动")
+ return configs.NewConfig("sctl", configs.WithSource(emptyConfigSource{}))
+}
+
+// emptyConfigSource 是一个「无任何键」的 cago 配置源:所有 Scan 保持零值并返回 nil,
+// 使 daemon 在没有配置文件时以内置默认运行。
+type emptyConfigSource struct{}
+
+func (emptyConfigSource) Scan(_ context.Context, _ string, _ any) error { return nil }
+
+func (emptyConfigSource) Has(_ context.Context, _ string) (bool, error) { return false, nil }
+
+func (emptyConfigSource) Watch(_ context.Context, _ string, _ func(source.Event)) error { return nil }
diff --git a/internal/cli/status.go b/internal/cli/status.go
new file mode 100644
index 0000000..f8f70eb
--- /dev/null
+++ b/internal/cli/status.go
@@ -0,0 +1,57 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// newStatusCmd 查询 daemon 与扩展连接状态。不自动拉起 daemon:未运行时如实报告。
+func newStatusCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "查看 daemon 与扩展连接状态",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ client, err := control.Connect(ctx)
+ if err != nil {
+ if jsonOutput {
+ return printValueJSON(control.StatusResult{})
+ }
+ fmt.Fprintln(os.Stdout, "daemon 未运行")
+ return nil
+ }
+ st, err := client.Status(ctx)
+ if err != nil {
+ return err
+ }
+ if jsonOutput {
+ return printValueJSON(st)
+ }
+ fmt.Fprintf(os.Stdout, "daemon 版本: %s\n扩展已连接: %v\n", st.DaemonVersion, st.ExtConnected)
+ // 人读输出只给一行摘要,完整事件走 --json,避免刷屏淹没状态本身。
+ if summary := formatSecuritySummary(st.Security); summary != "" {
+ fmt.Fprintf(os.Stdout, "近期安全事件: %s\n", summary)
+ }
+ return nil
+ },
+ }
+}
+
+// formatSecuritySummary 把安全事件聚合成一行「类型×次数」摘要,无事件时返回空串。
+func formatSecuritySummary(events []audit.Event) string {
+ counts := audit.Summarize(events)
+ if len(counts) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(counts))
+ for _, c := range counts {
+ parts = append(parts, fmt.Sprintf("%s×%d", c.Type, c.Count))
+ }
+ return fmt.Sprintf("%d 条(%s)", len(events), strings.Join(parts, ", "))
+}
diff --git a/internal/cli/version.go b/internal/cli/version.go
new file mode 100644
index 0000000..b33598f
--- /dev/null
+++ b/internal/cli/version.go
@@ -0,0 +1,39 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+
+ "github.com/spf13/cobra"
+
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// newVersionCmd 打印版本与协议信息。
+func newVersionCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "version",
+ Short: "打印版本与协议信息",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p, err := protocol.Load()
+ if err != nil {
+ return err
+ }
+ if jsonOutput {
+ return printValueJSON(map[string]any{
+ "version": Version,
+ "commit": Commit,
+ "buildDate": BuildDate,
+ "goVersion": runtime.Version(),
+ "platform": runtime.GOOS + "/" + runtime.GOARCH,
+ "protocolVersion": p.ProtocolVersion,
+ "minDaemonVersion": p.Versions.MinDaemonVersion,
+ })
+ }
+ fmt.Fprintf(os.Stdout, "sctl %s (protocol v%d, min daemon %s)\n", Version, p.ProtocolVersion, p.Versions.MinDaemonVersion)
+ fmt.Fprintf(os.Stdout, "commit %s, built %s, %s %s/%s\n", Commit, BuildDate, runtime.Version(), runtime.GOOS, runtime.GOARCH)
+ return nil
+ },
+ }
+}
diff --git a/internal/cli/write.go b/internal/cli/write.go
new file mode 100644
index 0000000..9627cda
--- /dev/null
+++ b/internal/cli/write.go
@@ -0,0 +1,104 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// newInstallCmd 请求安装一个用户脚本。参数是 URL 或本地文件路径:URL 上送 {url},本地文件读出后
+// 上送 {code}(staged code)。阻塞至扩展批准/拒绝;Ctrl-C 即作废。
+func newInstallCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "install ",
+ Short: "请求安装用户脚本(URL 或本地文件),阻塞至浏览器确认",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ input, err := buildInstallInput(args[0])
+ if err != nil {
+ return &ExitError{Code: exitError, Message: err.Error()}
+ }
+ return dispatchBlocking(cmd, "scripts.install.request", input, func(result json.RawMessage) error {
+ if jsonOutput {
+ return printResultJSON(result)
+ }
+ fmt.Fprintf(os.Stdout, "已安装: %s\n", summarizeInstall(result))
+ return nil
+ })
+ },
+ }
+}
+
+// buildInstallInput 判定参数是远程 URL 还是本地文件,构造对应的 bridge input。
+func buildInstallInput(arg string) (json.RawMessage, error) {
+ if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
+ return mustInput(map[string]string{"url": arg}), nil
+ }
+ code, err := os.ReadFile(arg)
+ if err != nil {
+ return nil, fmt.Errorf("读取脚本文件 %q: %w", arg, err)
+ }
+ return mustInput(map[string]string{"code": string(code)}), nil
+}
+
+func summarizeInstall(result json.RawMessage) string {
+ var r struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Enabled bool `json:"enabled"`
+ }
+ if err := json.Unmarshal(result, &r); err != nil {
+ return string(result)
+ }
+ return fmt.Sprintf("%s (%s) version=%s enabled=%v", r.Name, r.UUID, r.Version, r.Enabled)
+}
+
+// newToggleCmd 生成 enable(enable=true)或 disable(enable=false)命令。
+func newToggleCmd(enable bool) *cobra.Command {
+ use, short := "disable ", "请求禁用脚本,阻塞至浏览器确认"
+ if enable {
+ use, short = "enable ", "请求启用脚本,阻塞至浏览器确认"
+ }
+ return &cobra.Command{
+ Use: use,
+ Short: short,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ input := mustInput(map[string]any{"uuid": args[0], "enable": enable})
+ return dispatchBlocking(cmd, "scripts.toggle.request", input, func(result json.RawMessage) error {
+ if jsonOutput {
+ return printResultJSON(result)
+ }
+ verb := "禁用"
+ if enable {
+ verb = "启用"
+ }
+ fmt.Fprintf(os.Stdout, "已%s: %s\n", verb, args[0])
+ return nil
+ })
+ },
+ }
+}
+
+// newRmCmd 请求删除一个脚本。阻塞至扩展批准/拒绝;Ctrl-C 即作废。
+func newRmCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "rm ",
+ Short: "请求删除脚本,阻塞至浏览器确认",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ input := mustInput(map[string]string{"uuid": args[0]})
+ return dispatchBlocking(cmd, "scripts.delete.request", input, func(result json.RawMessage) error {
+ if jsonOutput {
+ return printResultJSON(result)
+ }
+ fmt.Fprintf(os.Stdout, "已删除: %s\n", args[0])
+ return nil
+ })
+ },
+ }
+}
diff --git a/internal/client/control/client.go b/internal/client/control/client.go
new file mode 100644
index 0000000..5ffce7b
--- /dev/null
+++ b/internal/client/control/client.go
@@ -0,0 +1,233 @@
+package control
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// ErrDaemonUnreachable 表示自动拉起后仍无法在超时内连上 daemon。
+var ErrDaemonUnreachable = errors.New("无法连接 sctl daemon")
+
+// 可注入点:测试用桩替换,避免真的 fork 进程。默认拉起一个 detached 的 `sctl serve`。
+var (
+ launchTimeout = 5 * time.Second
+ pollInterval = 50 * time.Millisecond
+ spawnDaemon = spawnServeProcess
+)
+
+// Client 是前端连接 daemon 控制 API 的 HTTP 客户端。零值不可用,须经 Dial 构造。
+type Client struct {
+ http *http.Client
+ base string
+ controlToken string
+ clientLabel string // 可选:调用方自报的客户端标签,仅用于审计归因(不构成授权)
+}
+
+// Dial 解析 daemon 地址、必要时自动拉起 daemon,并读取控制令牌返回可用客户端。
+func Dial(ctx context.Context) (*Client, error) {
+ return dial(ctx, true)
+}
+
+// Connect 与 Dial 相同,但 daemon 未运行时直接报错、不自动拉起(供 status 等只读探测命令使用)。
+func Connect(ctx context.Context) (*Client, error) {
+ return dial(ctx, false)
+}
+
+func dial(ctx context.Context, autoLaunch bool) (*Client, error) {
+ base, err := resolveBaseURL()
+ if err != nil {
+ return nil, err
+ }
+ c := &Client{http: &http.Client{}, base: base}
+ if autoLaunch {
+ if err := c.ensureDaemon(ctx); err != nil {
+ return nil, err
+ }
+ } else if !c.healthOK(ctx) {
+ return nil, ErrDaemonUnreachable
+ }
+ tok, err := ReadControlToken()
+ if err != nil {
+ return nil, fmt.Errorf("读取控制令牌(daemon 未就绪?): %w", err)
+ }
+ c.controlToken = tok
+ return c, nil
+}
+
+// WithClientLabel 返回携带指定审计标签的副本;后续调用把该标签随请求上报,仅供扩展侧审计归因。
+func (c *Client) WithClientLabel(label string) *Client {
+ cp := *c
+ cp.clientLabel = label
+ return &cp
+}
+
+func resolveBaseURL() (string, error) {
+ if addr := os.Getenv("SCTL_BRIDGE_ADDR"); addr != "" {
+ return "http://" + addr, nil
+ }
+ p, err := protocol.Load()
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("http://127.0.0.1:%d", p.Transport.DefaultPort), nil
+}
+
+// ensureDaemon 保证 daemon 可达:已在跑直接返回;否则拉起 detached 进程并轮询健康检查。
+// 端口竞态下失败方的 serve 会因绑定失败退出,但胜出者已在监听,健康检查照样成功
+// (「绑定失败即转为连接既有实例」)。
+func (c *Client) ensureDaemon(ctx context.Context) error {
+ if c.healthOK(ctx) {
+ return nil
+ }
+ if err := spawnDaemon(); err != nil {
+ return fmt.Errorf("拉起 sctl daemon: %w", err)
+ }
+ deadline := time.Now().Add(launchTimeout)
+ ticker := time.NewTicker(pollInterval)
+ defer ticker.Stop()
+ for {
+ if c.healthOK(ctx) {
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return ErrDaemonUnreachable
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ }
+ }
+}
+
+func (c *Client) healthOK(ctx context.Context) bool {
+ reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.base+PathHealth, nil)
+ if err != nil {
+ return false
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, resp.Body)
+ return resp.StatusCode == http.StatusOK
+}
+
+// Health 显式做一次健康检查(供 status 等命令使用)。
+func (c *Client) Health(ctx context.Context) error {
+ if c.healthOK(ctx) {
+ return nil
+ }
+ return ErrDaemonUnreachable
+}
+
+func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
+ var r io.Reader
+ if body != nil {
+ r = bytes.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.base+path, r)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set(HeaderControlToken, c.controlToken)
+ if c.clientLabel != "" {
+ req.Header.Set(HeaderClientLabel, c.clientLabel)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return req, nil
+}
+
+// Call 转发一次 bridge action 调用并阻塞至应答/作废。ctx 取消(如 CLI Ctrl-C)会切断连接,
+// daemon 侧据此发 bridge.cancel 作废该操作;此时 Do 返回 context.Canceled。
+func (c *Client) Call(ctx context.Context, action string, input json.RawMessage) (CallResult, error) {
+ if input == nil {
+ input = json.RawMessage(`{}`)
+ }
+ body, err := json.Marshal(CallRequest{Action: action, Input: input})
+ if err != nil {
+ return CallResult{}, err
+ }
+ req, err := c.newRequest(ctx, http.MethodPost, PathCall, body)
+ if err != nil {
+ return CallResult{}, err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return CallResult{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return CallResult{}, statusError(resp)
+ }
+ var res CallResult
+ if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
+ return CallResult{}, fmt.Errorf("解析控制应答: %w", err)
+ }
+ return res, nil
+}
+
+// Status 查询 daemon 与扩展连接概览。
+func (c *Client) Status(ctx context.Context) (StatusResult, error) {
+ req, err := c.newRequest(ctx, http.MethodGet, PathStatus, nil)
+ if err != nil {
+ return StatusResult{}, err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return StatusResult{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return StatusResult{}, statusError(resp)
+ }
+ var res StatusResult
+ if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
+ return StatusResult{}, err
+ }
+ return res, nil
+}
+
+// Enroll 打开一次接入窗口,返回展示形配对码(供 sctl connect 在终端展示,用户输入扩展页面)。
+func (c *Client) Enroll(ctx context.Context) (string, error) {
+ req, err := c.newRequest(ctx, http.MethodPost, PathEnroll, []byte(`{}`))
+ if err != nil {
+ return "", err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", statusError(resp)
+ }
+ var res EnrollResult
+ if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
+ return "", err
+ }
+ return res.Code, nil
+}
+
+// statusError 把非 200 控制响应转成带 body 摘要的错误(401 特别标注凭据问题)。
+func statusError(resp *http.Response) error {
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ if resp.StatusCode == http.StatusUnauthorized {
+ return fmt.Errorf("控制通道鉴权失败(控制令牌无效): %s", bytes.TrimSpace(msg))
+ }
+ return fmt.Errorf("控制请求失败 %d: %s", resp.StatusCode, bytes.TrimSpace(msg))
+}
diff --git a/internal/client/control/client_test.go b/internal/client/control/client_test.go
new file mode 100644
index 0000000..81c695a
--- /dev/null
+++ b/internal/client/control/client_test.go
@@ -0,0 +1,117 @@
+package control
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+// gatedHealthServer 返回一个健康检查服务:up 为 false 时 503(未就绪),true 时 200。
+func gatedHealthServer(up *atomic.Bool) *httptest.Server {
+ mux := http.NewServeMux()
+ mux.HandleFunc(PathHealth, func(w http.ResponseWriter, _ *http.Request) {
+ if up.Load() {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.WriteHeader(http.StatusServiceUnavailable)
+ })
+ return httptest.NewServer(mux)
+}
+
+func TestEnsureDaemon(t *testing.T) {
+ Convey("自动拉起与端口竞态回退", t, func() {
+ origSpawn := spawnDaemon
+ origTimeout := launchTimeout
+ origPoll := pollInterval
+ Reset(func() {
+ spawnDaemon = origSpawn
+ launchTimeout = origTimeout
+ pollInterval = origPoll
+ })
+ launchTimeout = 2 * time.Second
+ pollInterval = 5 * time.Millisecond
+ ctx := context.Background()
+
+ Convey("daemon 已在运行:直接连通,不拉起", func() {
+ var up atomic.Bool
+ up.Store(true)
+ srv := gatedHealthServer(&up)
+ defer srv.Close()
+ var spawned atomic.Int32
+ spawnDaemon = func() error { spawned.Add(1); return nil }
+
+ c := &Client{http: srv.Client(), base: srv.URL}
+ So(c.ensureDaemon(ctx), ShouldBeNil)
+ So(spawned.Load(), ShouldEqual, 0)
+ })
+
+ Convey("daemon 未运行:拉起后就绪即连通", func() {
+ var up atomic.Bool
+ srv := gatedHealthServer(&up)
+ defer srv.Close()
+ var spawned atomic.Int32
+ spawnDaemon = func() error {
+ spawned.Add(1)
+ up.Store(true) // 拉起即就绪
+ return nil
+ }
+
+ c := &Client{http: srv.Client(), base: srv.URL}
+ So(c.ensureDaemon(ctx), ShouldBeNil)
+ So(spawned.Load(), ShouldEqual, 1)
+ })
+
+ Convey("端口竞态:本进程拉起未建立监听,但轮询连上既有胜出者", func() {
+ var up atomic.Bool
+ srv := gatedHealthServer(&up)
+ defer srv.Close()
+ // spawn 自身不建立监听(模拟 serve 绑定失败即退出);胜出者稍后就绪。
+ spawnDaemon = func() error {
+ go func() {
+ time.Sleep(40 * time.Millisecond)
+ up.Store(true)
+ }()
+ return nil
+ }
+
+ c := &Client{http: srv.Client(), base: srv.URL}
+ So(c.ensureDaemon(ctx), ShouldBeNil)
+ })
+
+ Convey("拉起后仍不可达 → 超时错误", func() {
+ var up atomic.Bool // 始终 false
+ srv := gatedHealthServer(&up)
+ defer srv.Close()
+ launchTimeout = 150 * time.Millisecond
+ spawnDaemon = func() error { return nil }
+
+ c := &Client{http: srv.Client(), base: srv.URL}
+ So(c.ensureDaemon(ctx), ShouldEqual, ErrDaemonUnreachable)
+ })
+ })
+}
+
+func TestResolveBaseURL(t *testing.T) {
+ Convey("控制端点地址解析", t, func() {
+ Convey("SCTL_BRIDGE_ADDR 覆盖默认端口", func() {
+ t.Setenv("SCTL_BRIDGE_ADDR", "127.0.0.1:9999")
+ base, err := resolveBaseURL()
+ So(err, ShouldBeNil)
+ So(base, ShouldEqual, "http://127.0.0.1:9999")
+ })
+
+ Convey("未设置时回退协议默认端口 8643", func() {
+ os.Unsetenv("SCTL_BRIDGE_ADDR")
+ base, err := resolveBaseURL()
+ So(err, ShouldBeNil)
+ So(base, ShouldEqual, "http://127.0.0.1:8643")
+ })
+ })
+}
diff --git a/internal/client/control/protocol.go b/internal/client/control/protocol.go
new file mode 100644
index 0000000..9d07477
--- /dev/null
+++ b/internal/client/control/protocol.go
@@ -0,0 +1,83 @@
+// Package control 定义 sctl 前端(sctl mcp / CLI 动词)与常驻 daemon(sctl serve)之间的
+// 「本机内部连接」:一套跑在 daemon loopback listener 上、与扩展 WS 面同端口但独立路径的
+// HTTP/JSON 控制 API。它不是桥接协议(见 docs/protocol.md)的一部分——桥接协议只约定
+// 扩展 ↔ daemon 那条 WS 连接;控制 API 是同一二进制内部前端 → daemon 的私有通道。
+//
+// 信任模型(docs/threat-model.md §1 / §4)——扁平信任:
+// - 控制 API 的唯一传输闸门是「控制令牌」——daemon 绑定端口后写入 0600 文件、只有同用户
+// 进程能读。网页可以 new WebSocket / fetch 到该端口,但读不到令牌即被拒(防小人不防君子)。
+// - 带上控制令牌即拥有全部能力:CLI 与所有 MCP agent 经已接入的可信通道继承信任,不再逐客户端
+// 配对 / 铸令牌 / 撤销。可选的客户端标签仅用于审计归因,不构成授权。
+// - 无论哪种调用方,写操作仍需过扩展侧人工审批、源码读取仍需过披露闸门(第二道闸门)。
+package control
+
+import (
+ "encoding/json"
+
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// 控制 API 路径。健康检查故意不鉴权(仅暴露「端口开着」这一威胁模型已接受的信息)。
+const (
+ PathHealth = "/control/health"
+ PathCall = "/control/call"
+ PathEnroll = "/control/enroll"
+ PathStatus = "/control/status"
+)
+
+// 控制 API 请求头。
+const (
+ // HeaderControlToken 携带 0600 控制令牌,证明调用方是同用户本机进程。所有非健康检查请求必带。
+ HeaderControlToken = "X-Sctl-Control-Token"
+ // HeaderClientLabel 可选,携带调用方自报的客户端标签(如 MCP 客户端名)。仅用于审计归因,
+ // 不构成授权;缺省即内建 CLI 身份标签。自报、未认证、可伪造——只入审计,不上审批界面。
+ HeaderClientLabel = "X-Sctl-Client"
+)
+
+// CLIClientID 是缺省客户端标签(bridge.request.clientId 回填,仅供扩展侧审计展示)。
+const CLIClientID = "sctl-cli"
+
+// CallRequest 是 /control/call 的请求体:转发一次 bridge action 调用。
+type CallRequest struct {
+ Action string `json:"action"`
+ Input json.RawMessage `json:"input"`
+}
+
+// CallResult 是 /control/call 的响应体,镜像桥接的 bridge.response(ok 二选一)。
+type CallResult struct {
+ OK bool `json:"ok"`
+ Result json.RawMessage `json:"result,omitempty"`
+ Error *CallError `json:"error,omitempty"`
+}
+
+// CallError 是失败调用的结构化错误(code 取桥接 protocol.json errorCodes)。
+type CallError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *CallError) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Code + ": " + e.Message
+}
+
+// HealthResult 是 /control/health 的响应体(无鉴权)。
+type HealthResult struct {
+ OK bool `json:"ok"`
+ Version string `json:"version"`
+}
+
+// StatusResult 是 /control/status 的响应体:daemon 与扩展连接概览,附守卫侧安全事件。
+type StatusResult struct {
+ DaemonVersion string `json:"daemonVersion"`
+ ExtConnected bool `json:"extConnected"`
+ SecurityCount int `json:"securityCount"`
+ Security []audit.Event `json:"security,omitempty"`
+}
+
+// EnrollResult 是 /control/enroll 的响应体:打开接入窗口后返回展示形配对码。
+type EnrollResult struct {
+ Code string `json:"code"`
+}
diff --git a/internal/client/control/spawn_unix.go b/internal/client/control/spawn_unix.go
new file mode 100644
index 0000000..bc3958e
--- /dev/null
+++ b/internal/client/control/spawn_unix.go
@@ -0,0 +1,26 @@
+//go:build !windows
+
+package control
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+// spawnServeProcess 以 detached(新会话)方式拉起 `sctl serve`,使其脱离当前终端与 CLI 生命周期。
+// stdout/stderr 置 nil(os/exec 连到 /dev/null),保证不污染前端的 stdout(MCP 协议 / --json)。
+func spawnServeProcess() error {
+ self, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("定位自身可执行文件: %w", err)
+ }
+ cmd := exec.Command(self, "serve")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ // 不 Wait:daemon 常驻,父进程(CLI/mcp)不应阻塞在其上。Release 让子进程被 init 收养。
+ return cmd.Process.Release()
+}
diff --git a/internal/client/control/spawn_windows.go b/internal/client/control/spawn_windows.go
new file mode 100644
index 0000000..4184204
--- /dev/null
+++ b/internal/client/control/spawn_windows.go
@@ -0,0 +1,31 @@
+//go:build windows
+
+package control
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+// Windows detached 进程标志:新建进程组 + 脱离控制台,使 `sctl serve` 不随前端退出而终止。
+const (
+ createNewProcessGroup = 0x00000200 // CREATE_NEW_PROCESS_GROUP
+ detachedProcess = 0x00000008 // DETACHED_PROCESS
+)
+
+// spawnServeProcess 以 detached 方式拉起 `sctl serve`。stdout/stderr 置 nil(连到 NUL),
+// 保证不污染前端的 stdout(MCP 协议 / --json)。
+func spawnServeProcess() error {
+ self, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("定位自身可执行文件: %w", err)
+ }
+ cmd := exec.Command(self, "serve")
+ cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess}
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ return cmd.Process.Release()
+}
diff --git a/internal/client/control/token.go b/internal/client/control/token.go
new file mode 100644
index 0000000..78bd214
--- /dev/null
+++ b/internal/client/control/token.go
@@ -0,0 +1,42 @@
+package control
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/scriptscat/sctl/internal/pkg/fsutil"
+ "github.com/scriptscat/sctl/internal/pkg/paths"
+)
+
+// NewControlToken 生成 256-bit 随机控制令牌(小写 hex)。daemon 每次绑定端口后新生成一份,
+// 旧令牌随之失效——控制通道只在进程存活期内有效,无需持久语义。
+func NewControlToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("生成控制令牌: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// WriteControlToken 原子写入控制令牌到 paths.ControlTokenFile()(目录 0700 / 文件 0600)。
+// 必须在绑定端口成功之后调用:端口竞态的失败方不会走到这里,因此不会覆盖胜出者写下的令牌。
+func WriteControlToken(token string) error {
+ path := paths.ControlTokenFile()
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return fmt.Errorf("创建控制令牌目录: %w", err)
+ }
+ return fsutil.WriteFileAtomic(path, []byte(token), 0o600)
+}
+
+// ReadControlToken 读取控制令牌。文件不存在返回 os.ErrNotExist,调用方据此判断 daemon 未就绪。
+func ReadControlToken() (string, error) {
+ raw, err := os.ReadFile(paths.ControlTokenFile())
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(raw)), nil
+}
diff --git a/internal/client/mcpserver/mcpserver.go b/internal/client/mcpserver/mcpserver.go
new file mode 100644
index 0000000..82c9964
--- /dev/null
+++ b/internal/client/mcpserver/mcpserver.go
@@ -0,0 +1,133 @@
+// Package mcpserver 用官方 go-sdk 构建 sctl 的 stdio MCP server:把 protocol.json 定义的 bridge
+// action 暴露成 MCP 工具,并在阻塞等待(写审批 / 源码披露)期间周期发送 progress 通知(支持的
+// 客户端可借此续期工具超时)。
+//
+// 扁平信任:接入(enrollment)建立可信通道后,MCP agent 继承信任、无需各自配对,故 tools/list
+// 暴露全部工具;权威授权仍在扩展侧(写操作审批 / 源码披露闸门)。
+//
+// stdout 由 MCP 协议独占:本包绝不向 stdout 写任何东西,日志走全局 stderr/文件 logger。
+package mcpserver
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// progressInterval 是阻塞等待期间发送 progress 通知的间隔。取远小于常见 MCP 客户端 60s 级工具
+// 超时的值,使每次通知都能刷新其倒计时。以 var 暴露仅为便于测试压缩等待。
+var progressInterval = 10 * time.Second
+
+// BridgeCaller 抽象「向 daemon 转发一次 bridge action 调用」,便于测试注入桩。
+type BridgeCaller interface {
+ Call(ctx context.Context, action string, input json.RawMessage) (control.CallResult, error)
+}
+
+// Deps 是构建 MCP server 所需依赖。扁平信任下不再按客户端 scope 过滤:注册 protocol.json 中
+// 定义了的全部工具。
+type Deps struct {
+ Name string
+ Version string
+ Proto *protocol.Protocol
+ Caller BridgeCaller
+}
+
+// New 按依赖构建 MCP server,注册 protocol.json 里定义的全部 bridge action 工具。
+func New(d Deps) *mcp.Server {
+ srv := mcp.NewServer(&mcp.Implementation{Name: d.Name, Version: d.Version}, &mcp.ServerOptions{})
+ for _, td := range toolDefs {
+ if _, ok := d.Proto.Actions[td.action]; !ok {
+ continue
+ }
+ registerTool(srv, td, d.Caller)
+ }
+ return srv
+}
+
+func registerTool(srv *mcp.Server, td toolDef, caller BridgeCaller) {
+ tool := &mcp.Tool{
+ Name: td.name,
+ Description: td.description,
+ InputSchema: json.RawMessage(td.inputSchema),
+ }
+ action := td.action
+ srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ return handleCall(ctx, req, action, caller)
+ })
+}
+
+// handleCall 转发工具调用到 daemon,并在等待期间发 progress。桥接业务错误(拒绝/过期/scope 等)
+// 作为 IsError 工具结果返回(模型可见并自我纠正);传输/取消错误作为协议级错误返回。
+func handleCall(ctx context.Context, req *mcp.CallToolRequest, action string, caller BridgeCaller) (*mcp.CallToolResult, error) {
+ input := req.Params.Arguments
+ stop := startProgress(ctx, req)
+ res, err := caller.Call(ctx, action, input)
+ stop()
+ if err != nil {
+ return nil, err
+ }
+ if res.OK {
+ return okResult(res.Result), nil
+ }
+ return errorResult(res.Error), nil
+}
+
+// startProgress 若请求带 progressToken,则起一个 ticker 周期发 progress 通知,返回停止函数。
+// 无 token 时为 no-op。通知失败(客户端不接收)被忽略。
+func startProgress(ctx context.Context, req *mcp.CallToolRequest) func() {
+ token := req.Params.GetProgressToken()
+ if token == nil {
+ return func() {}
+ }
+ done := make(chan struct{})
+ go func() {
+ ticker := time.NewTicker(progressInterval)
+ defer ticker.Stop()
+ var n float64
+ for {
+ select {
+ case <-done:
+ return
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ n++
+ _ = req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
+ ProgressToken: token,
+ Message: "等待浏览器确认…",
+ Progress: n,
+ })
+ }
+ }
+ }()
+ return func() { close(done) }
+}
+
+func okResult(result json.RawMessage) *mcp.CallToolResult {
+ text := strings.TrimSpace(string(result))
+ if text == "" {
+ text = "{}"
+ }
+ out := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
+ if strings.HasPrefix(text, "{") {
+ out.StructuredContent = result
+ }
+ return out
+}
+
+func errorResult(e *control.CallError) *mcp.CallToolResult {
+ msg := "调用失败"
+ if e != nil {
+ msg = e.Code
+ if e.Message != "" {
+ msg += ": " + e.Message
+ }
+ }
+ return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: msg}}}
+}
diff --git a/internal/client/mcpserver/mcpserver_test.go b/internal/client/mcpserver/mcpserver_test.go
new file mode 100644
index 0000000..13549ce
--- /dev/null
+++ b/internal/client/mcpserver/mcpserver_test.go
@@ -0,0 +1,191 @@
+package mcpserver
+
+import (
+ "context"
+ "encoding/json"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// fakeCaller 是 BridgeCaller 的测试桩:可配置阻塞、返回值,并记录收到的调用。
+type fakeCaller struct {
+ mu sync.Mutex
+ actions []string
+ block chan struct{} // 非 nil 则 Call 阻塞至其关闭或 ctx 取消
+ result control.CallResult
+ err error
+ sawCtx atomic.Bool // Call 是否因 ctx 取消而返回
+}
+
+func (f *fakeCaller) Call(ctx context.Context, action string, input json.RawMessage) (control.CallResult, error) {
+ f.mu.Lock()
+ f.actions = append(f.actions, action)
+ block := f.block
+ f.mu.Unlock()
+ if block != nil {
+ select {
+ case <-block:
+ case <-ctx.Done():
+ f.sawCtx.Store(true)
+ return control.CallResult{}, ctx.Err()
+ }
+ }
+ return f.result, f.err
+}
+
+// connect 用内存 transport 把一个 mcpserver 与一个测试 MCP client 对接。
+func connect(t *testing.T, deps Deps, clientOpts *mcp.ClientOptions) *mcp.ClientSession {
+ t.Helper()
+ srv := New(deps)
+ st, ct := mcp.NewInMemoryTransports()
+ ctx := context.Background()
+ _, err := srv.Connect(ctx, st, nil)
+ So(err, ShouldBeNil)
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, clientOpts)
+ session, err := client.Connect(ctx, ct, nil)
+ So(err, ShouldBeNil)
+ t.Cleanup(func() { _ = session.Close() })
+ return session
+}
+
+func loadProto(t *testing.T) *protocol.Protocol {
+ t.Helper()
+ p, err := protocol.Load()
+ So(err, ShouldBeNil)
+ return p
+}
+
+func toolNames(res *mcp.ListToolsResult) []string {
+ names := make([]string, 0, len(res.Tools))
+ for _, tl := range res.Tools {
+ names = append(names, tl.Name)
+ }
+ return names
+}
+
+func TestToolsListExposesAllTools(t *testing.T) {
+ Convey("扁平信任:tools/list 暴露 protocol.json 定义的全部工具(不再按 scope 过滤)", t, func() {
+ p := loadProto(t)
+ caller := &fakeCaller{result: control.CallResult{OK: true, Result: json.RawMessage(`{}`)}}
+
+ session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil)
+ res, err := session.ListTools(context.Background(), &mcp.ListToolsParams{})
+ So(err, ShouldBeNil)
+ So(len(res.Tools), ShouldEqual, len(p.Actions))
+ So(toolNames(res), ShouldContain, "scripts_list")
+ So(toolNames(res), ShouldContain, "scripts_delete_request")
+ })
+}
+
+func TestToolCallResults(t *testing.T) {
+ Convey("工具调用结果映射", t, func() {
+ p := loadProto(t)
+
+ Convey("成功 → 内容含结果 JSON,非 IsError", func() {
+ caller := &fakeCaller{result: control.CallResult{OK: true, Result: json.RawMessage(`{"scripts":[]}`)}}
+ session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil)
+ res, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "scripts_list", Arguments: map[string]any{}})
+ So(err, ShouldBeNil)
+ So(res.IsError, ShouldBeFalse)
+ So(res.Content, ShouldNotBeEmpty)
+ So(res.Content[0].(*mcp.TextContent).Text, ShouldContainSubstring, "scripts")
+ So(caller.actions, ShouldResemble, []string{"scripts.list"})
+ })
+
+ Convey("桥接业务错误 → IsError 工具结果(模型可见)", func() {
+ caller := &fakeCaller{result: control.CallResult{OK: false, Error: &control.CallError{Code: "USER_REJECTED", Message: "用户拒绝"}}}
+ session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil)
+ res, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "scripts_delete_request", Arguments: map[string]any{"uuid": "x"}})
+ So(err, ShouldBeNil)
+ So(res.IsError, ShouldBeTrue)
+ So(res.Content[0].(*mcp.TextContent).Text, ShouldContainSubstring, "USER_REJECTED")
+ })
+ })
+}
+
+func TestBlockingProgressAndCancel(t *testing.T) {
+ Convey("阻塞等待期间发 progress,且请求取消可传播到调用", t, func() {
+ p := loadProto(t)
+ old := progressInterval
+ progressInterval = 20 * time.Millisecond
+ defer func() { progressInterval = old }()
+
+ Convey("带 progressToken 的阻塞调用会收到 progress 通知", func() {
+ var progressCount atomic.Int32
+ block := make(chan struct{})
+ caller := &fakeCaller{block: block, result: control.CallResult{OK: true, Result: json.RawMessage(`{"uuid":"x","enabled":true}`)}}
+ opts := &mcp.ClientOptions{
+ ProgressNotificationHandler: func(_ context.Context, _ *mcp.ProgressNotificationClientRequest) {
+ progressCount.Add(1)
+ },
+ }
+ session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, opts)
+
+ resCh := make(chan *mcp.CallToolResult, 1)
+ go func() {
+ res, _ := session.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "scripts_toggle_request",
+ Arguments: map[string]any{"uuid": "x", "enable": true},
+ Meta: mcp.Meta{"progressToken": "tok-1"},
+ })
+ resCh <- res
+ }()
+
+ // 等到至少收到一次 progress,再放行调用。
+ deadline := time.After(2 * time.Second)
+ for progressCount.Load() == 0 {
+ select {
+ case <-deadline:
+ t.Fatal("超时仍未收到 progress 通知")
+ case <-time.After(5 * time.Millisecond):
+ }
+ }
+ close(block)
+ res := <-resCh
+ So(res.IsError, ShouldBeFalse)
+ So(progressCount.Load(), ShouldBeGreaterThan, 0)
+ })
+
+ Convey("请求方取消 ctx → 调用观察到取消并返回错误", func() {
+ block := make(chan struct{})
+ defer close(block)
+ caller := &fakeCaller{block: block}
+ session := connect(t, Deps{Name: "s", Version: "v0", Proto: p, Caller: caller}, nil)
+
+ callCtx, cancel := context.WithCancel(context.Background())
+ errCh := make(chan error, 1)
+ go func() {
+ _, err := session.CallTool(callCtx, &mcp.CallToolParams{Name: "scripts_list", Arguments: map[string]any{}})
+ errCh <- err
+ }()
+ // 给调用一点时间抵达阻塞点,再取消。
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+
+ select {
+ case err := <-errCh:
+ So(err, ShouldNotBeNil)
+ case <-time.After(2 * time.Second):
+ t.Fatal("取消未传播到 CallTool")
+ }
+ // 客户端取消经 notifications/cancelled 异步传到服务端 handler ctx,轮询等待其传播到桥接调用侧。
+ deadline := time.After(2 * time.Second)
+ for !caller.sawCtx.Load() {
+ select {
+ case <-deadline:
+ t.Fatal("ctx 取消未传播到桥接调用")
+ case <-time.After(5 * time.Millisecond):
+ }
+ }
+ So(caller.sawCtx.Load(), ShouldBeTrue)
+ })
+ })
+}
diff --git a/internal/client/mcpserver/tools.go b/internal/client/mcpserver/tools.go
new file mode 100644
index 0000000..abf630e
--- /dev/null
+++ b/internal/client/mcpserver/tools.go
@@ -0,0 +1,58 @@
+package mcpserver
+
+// toolDef 把一个 bridge action 映射成一个 MCP 工具:工具名(点号换下划线以兼容各客户端)、
+// 静态描述(绝不拼入脚本可控文本,契合 contentTrust=untrusted-user-script-source),以及
+// 手写的 2020-12 输入 schema(权威校验仍在扩展侧;此处只作客户端提示)。
+type toolDef struct {
+ action string
+ name string
+ description string
+ inputSchema string
+}
+
+const (
+ schemaEmpty = `{"type":"object","properties":{},"additionalProperties":false}`
+ schemaUUID = `{"type":"object","properties":{"uuid":{"type":"string","description":"脚本 uuid"}},"required":["uuid"],"additionalProperties":false}`
+ schemaToggle = `{"type":"object","properties":{"uuid":{"type":"string","description":"脚本 uuid"},"enable":{"type":"boolean","description":"true 启用 / false 禁用"}},"required":["uuid","enable"],"additionalProperties":false}`
+ schemaInstall = `{"type":"object","properties":{"url":{"type":"string","description":"用户脚本 URL"},"code":{"type":"string","description":"用户脚本源码"}},"additionalProperties":false}`
+)
+
+// toolDefs 是全部 bridge action 的工具定义,顺序稳定,注册时按 protocol.json 是否定义该 action 过滤。
+var toolDefs = []toolDef{
+ {
+ action: "scripts.list",
+ name: "scripts_list",
+ description: "列出 ScriptCat 扩展中已安装的用户脚本摘要(uuid、名称、启用状态、版本)。",
+ inputSchema: schemaEmpty,
+ },
+ {
+ action: "scripts.metadata.get",
+ name: "scripts_metadata_get",
+ description: "按 uuid 读取单个脚本的元数据。",
+ inputSchema: schemaUUID,
+ },
+ {
+ action: "scripts.source.get",
+ name: "scripts_source_get",
+ description: "按 uuid 读取单个脚本的源码。首次读取会在浏览器弹出源码披露确认,需用户批准。",
+ inputSchema: schemaUUID,
+ },
+ {
+ action: "scripts.install.request",
+ name: "scripts_install_request",
+ description: "请求安装一个用户脚本(url 与 code 二选一)。需在浏览器中人工确认。",
+ inputSchema: schemaInstall,
+ },
+ {
+ action: "scripts.toggle.request",
+ name: "scripts_toggle_request",
+ description: "按 uuid 请求启用或禁用一个脚本。需在浏览器中人工确认。",
+ inputSchema: schemaToggle,
+ },
+ {
+ action: "scripts.delete.request",
+ name: "scripts_delete_request",
+ description: "按 uuid 请求删除一个脚本。需在浏览器中人工确认。",
+ inputSchema: schemaUUID,
+ },
+}
diff --git a/internal/daemon/auth/crypto.go b/internal/daemon/auth/crypto.go
new file mode 100644
index 0000000..e957fcb
--- /dev/null
+++ b/internal/daemon/auth/crypto.go
@@ -0,0 +1,216 @@
+// Package auth 实现桥接协议的认证原语:双向 HMAC 挑战应答握手(会话/配对两种模式)、
+// 配对码派生(HKDF)与长期密钥下发(AES-256-GCM)。密钥与客户端令牌的落盘存储见
+// internal/daemon/store。全部常量取自内嵌 protocol.json(见 internal/pkg/protocol),
+// 不在此硬编码。
+package auth
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hkdf"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// Mode 区分握手所用的上下文与密钥来源。
+type Mode int
+
+const (
+ // ModeSession 使用长期共享密钥 K 的会话握手(§3.1)。
+ ModeSession Mode = iota
+ // ModePairing 使用配对码派生密钥 Kp_mac 的首次配对握手(§3.2)。
+ ModePairing
+)
+
+// crockfordAlphabet 是 Crockford base32 字母表(去掉 I L O U 以避免混淆)。
+const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+// Crypto 承载从 protocol.json 读出的上下文字符串,提供握手与配对的密码学操作。
+type Crypto struct {
+ ctx map[string]string
+}
+
+// NewCrypto 用协议常量构造密码学助手。
+func NewCrypto(p *protocol.Protocol) *Crypto {
+ return &Crypto{ctx: p.Crypto.Context}
+}
+
+func (c *Crypto) extContext(mode Mode) string {
+ if mode == ModePairing {
+ return c.ctx["pairExt"]
+ }
+ return c.ctx["sessionExt"]
+}
+
+func (c *Crypto) daemonContext(mode Mode) string {
+ if mode == ModePairing {
+ return c.ctx["pairDaemon"]
+ }
+ return c.ctx["sessionDaemon"]
+}
+
+// handshakeHMAC 计算 HMAC-SHA256( key, context || first || second ) 并返回小写 hex。
+// nonce 以其小写 hex 字符串形式参与拼接(与扩展侧 WebCrypto 实现一致,免去 hex 解码)。
+func handshakeHMAC(key []byte, context, first, second string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(context + first + second))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// ExtHMAC 计算扩展 auth.response 应携带的 HMAC:context=ctx.*Ext,顺序 nonceD||nonceE。
+func (c *Crypto) ExtHMAC(mode Mode, key []byte, nonceD, nonceE string) string {
+ return handshakeHMAC(key, c.extContext(mode), nonceD, nonceE)
+}
+
+// DaemonHMAC 计算 daemon auth.ok 应携带的 HMAC:context=ctx.*Daemon,顺序 nonceE||nonceD。
+func (c *Crypto) DaemonHMAC(mode Mode, key []byte, nonceD, nonceE string) string {
+ return handshakeHMAC(key, c.daemonContext(mode), nonceE, nonceD)
+}
+
+// VerifyExtHMAC 恒定时间校验扩展应答的 HMAC。
+func (c *Crypto) VerifyExtHMAC(mode Mode, key []byte, nonceD, nonceE, got string) bool {
+ return constantTimeHexEqual(c.ExtHMAC(mode, key, nonceD, nonceE), got)
+}
+
+// VerifyDaemonHMAC 恒定时间校验 daemon auth.ok 的 HMAC(供扩展/测试使用)。
+func (c *Crypto) VerifyDaemonHMAC(mode Mode, key []byte, nonceD, nonceE, got string) bool {
+ return constantTimeHexEqual(c.DaemonHMAC(mode, key, nonceD, nonceE), got)
+}
+
+// constantTimeHexEqual 解码两个 hex 字符串后做恒定时间比较,任一非法 hex 即判否。
+func constantTimeHexEqual(want, got string) bool {
+ wb, err := hex.DecodeString(want)
+ if err != nil {
+ return false
+ }
+ gb, err := hex.DecodeString(got)
+ if err != nil {
+ return false
+ }
+ return hmac.Equal(wb, gb)
+}
+
+// DerivePairingKeys 从配对码派生 (Kp_mac, Kp_enc),各 32 字节。
+func (c *Crypto) DerivePairingKeys(code string) (kpMac, kpEnc []byte, err error) {
+ ikm := []byte(NormalizePairingCode(code))
+ salt := []byte(c.ctx["pairKdfSalt"])
+ kpMac, err = hkdf.Key(sha256.New, ikm, salt, c.ctx["pairKdfInfoMac"], 32)
+ if err != nil {
+ return nil, nil, fmt.Errorf("派生 Kp_mac: %w", err)
+ }
+ kpEnc, err = hkdf.Key(sha256.New, ikm, salt, c.ctx["pairKdfInfoEnc"], 32)
+ if err != nil {
+ return nil, nil, fmt.Errorf("派生 Kp_enc: %w", err)
+ }
+ return kpMac, kpEnc, nil
+}
+
+// SealKey 用 Kp_enc 以 AES-256-GCM 加密长期密钥 K,返回 base64(密文+tag) 与 base64(iv)。
+func (c *Crypto) SealKey(kpEnc, k []byte) (ctB64, ivB64 string, err error) {
+ block, err := aes.NewCipher(kpEnc)
+ if err != nil {
+ return "", "", fmt.Errorf("构造 AES cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", "", fmt.Errorf("构造 GCM: %w", err)
+ }
+ iv := make([]byte, gcm.NonceSize())
+ if _, err := rand.Read(iv); err != nil {
+ return "", "", fmt.Errorf("生成 iv: %w", err)
+ }
+ ct := gcm.Seal(nil, iv, k, nil)
+ return base64.StdEncoding.EncodeToString(ct), base64.StdEncoding.EncodeToString(iv), nil
+}
+
+// OpenKey 用 Kp_enc 解出下发的长期密钥 K(扩展侧逻辑,供测试对拍)。
+func (c *Crypto) OpenKey(kpEnc []byte, ctB64, ivB64 string) ([]byte, error) {
+ ct, err := base64.StdEncoding.DecodeString(ctB64)
+ if err != nil {
+ return nil, fmt.Errorf("解码密文: %w", err)
+ }
+ iv, err := base64.StdEncoding.DecodeString(ivB64)
+ if err != nil {
+ return nil, fmt.Errorf("解码 iv: %w", err)
+ }
+ block, err := aes.NewCipher(kpEnc)
+ if err != nil {
+ return nil, fmt.Errorf("构造 AES cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("构造 GCM: %w", err)
+ }
+ k, err := gcm.Open(nil, iv, ct, nil)
+ if err != nil {
+ return nil, fmt.Errorf("GCM 解密失败: %w", err)
+ }
+ return k, nil
+}
+
+// RandomNonceHex 生成 n 字节随机数并返回小写 hex 表示。
+func RandomNonceHex(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("生成 nonce: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// NewLongTermKey 生成 256-bit 长期共享密钥 K。
+func NewLongTermKey() ([]byte, error) {
+ k := make([]byte, 32)
+ if _, err := rand.Read(k); err != nil {
+ return nil, fmt.Errorf("生成长期密钥: %w", err)
+ }
+ return k, nil
+}
+
+// NewPairingCode 生成一次性配对码,返回规范形(8 字符)与展示形(XXXX-XXXX)。
+func NewPairingCode() (canonical, display string, err error) {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ return "", "", fmt.Errorf("生成配对码: %w", err)
+ }
+ var sb strings.Builder
+ for _, v := range b {
+ // 取每字节低 5 位映射到 32 字母表,分布均匀无模偏差。
+ sb.WriteByte(crockfordAlphabet[v&0x1f])
+ }
+ canonical = sb.String()
+ return canonical, FormatPairingCode(canonical), nil
+}
+
+// FormatPairingCode 把规范配对码格式化为 XXXX-XXXX 展示形。
+func FormatPairingCode(canonical string) string {
+ c := NormalizePairingCode(canonical)
+ if len(c) != 8 {
+ return c
+ }
+ return c[:4] + "-" + c[4:]
+}
+
+// NormalizePairingCode 归一化用户输入:去连字符/空格、大写,并按 Crockford 规则消歧混淆字符。
+func NormalizePairingCode(s string) string {
+ var sb strings.Builder
+ for _, r := range strings.ToUpper(s) {
+ switch r {
+ case '-', ' ', '\t':
+ continue
+ case 'O':
+ sb.WriteRune('0')
+ case 'I', 'L':
+ sb.WriteRune('1')
+ default:
+ sb.WriteRune(r)
+ }
+ }
+ return sb.String()
+}
diff --git a/internal/daemon/auth/crypto_test.go b/internal/daemon/auth/crypto_test.go
new file mode 100644
index 0000000..1237dda
--- /dev/null
+++ b/internal/daemon/auth/crypto_test.go
@@ -0,0 +1,151 @@
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "strings"
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+func TestSessionHandshake(t *testing.T) {
+ p, err := protocol.Load()
+ if err != nil {
+ t.Fatalf("加载协议失败: %v", err)
+ }
+ cfg := NewCrypto(p)
+
+ Convey("会话握手(双向 HMAC)", t, func() {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+ nonceD, _ := RandomNonceHex(p.Crypto.NonceBytes)
+ nonceE, _ := RandomNonceHex(p.Crypto.NonceBytes)
+
+ Convey("扩展应答的 HMAC 由 daemon 用同一密钥验证通过", func() {
+ extMAC := cfg.ExtHMAC(ModeSession, key, nonceD, nonceE)
+ So(cfg.VerifyExtHMAC(ModeSession, key, nonceD, nonceE, extMAC), ShouldBeTrue)
+ })
+
+ Convey("daemon 的 auth.ok HMAC 由扩展用同一密钥验证通过", func() {
+ okMAC := cfg.DaemonHMAC(ModeSession, key, nonceD, nonceE)
+ So(cfg.VerifyDaemonHMAC(ModeSession, key, nonceD, nonceE, okMAC), ShouldBeTrue)
+ })
+
+ Convey("ext 与 daemon 方向的 HMAC 不相等(上下文与顺序不同)", func() {
+ So(cfg.ExtHMAC(ModeSession, key, nonceD, nonceE),
+ ShouldNotEqual, cfg.DaemonHMAC(ModeSession, key, nonceD, nonceE))
+ })
+
+ Convey("密钥不符时验证失败", func() {
+ wrong := make([]byte, 32)
+ _, _ = rand.Read(wrong)
+ extMAC := cfg.ExtHMAC(ModeSession, key, nonceD, nonceE)
+ So(cfg.VerifyExtHMAC(ModeSession, wrong, nonceD, nonceE, extMAC), ShouldBeFalse)
+ })
+
+ Convey("nonce 被篡改时验证失败(抗重放)", func() {
+ extMAC := cfg.ExtHMAC(ModeSession, key, nonceD, nonceE)
+ other, _ := RandomNonceHex(p.Crypto.NonceBytes)
+ So(cfg.VerifyExtHMAC(ModeSession, key, other, nonceE, extMAC), ShouldBeFalse)
+ })
+
+ Convey("会话模式与配对模式上下文不同,MAC 不可互换", func() {
+ So(cfg.ExtHMAC(ModeSession, key, nonceD, nonceE),
+ ShouldNotEqual, cfg.ExtHMAC(ModePairing, key, nonceD, nonceE))
+ })
+
+ Convey("HMAC 输出为 64 位小写 hex", func() {
+ mac := cfg.ExtHMAC(ModeSession, key, nonceD, nonceE)
+ So(len(mac), ShouldEqual, 64)
+ So(mac, ShouldEqual, strings.ToLower(mac))
+ _, decErr := hex.DecodeString(mac)
+ So(decErr, ShouldBeNil)
+ })
+ })
+}
+
+func TestPairingKeyDelivery(t *testing.T) {
+ p, _ := protocol.Load()
+ cfg := NewCrypto(p)
+
+ Convey("配对密钥派生与下发", t, func() {
+ _, code, _ := NewPairingCode()
+
+ Convey("同一配对码派生出确定且互不相同的 mac / enc 密钥", func() {
+ mac1, enc1, err := cfg.DerivePairingKeys(code)
+ So(err, ShouldBeNil)
+ mac2, enc2, err := cfg.DerivePairingKeys(code)
+ So(err, ShouldBeNil)
+ So(mac1, ShouldResemble, mac2)
+ So(enc1, ShouldResemble, enc2)
+ So(mac1, ShouldNotResemble, enc1)
+ So(len(mac1), ShouldEqual, 32)
+ So(len(enc1), ShouldEqual, 32)
+ })
+
+ Convey("配对码大小写/连字符归一化后派生同一密钥", func() {
+ mac1, _, _ := cfg.DerivePairingKeys(code)
+ mac2, _, _ := cfg.DerivePairingKeys(FormatPairingCode(strings.ToLower(code)))
+ So(mac1, ShouldResemble, mac2)
+ })
+
+ Convey("AES-256-GCM 下发的长期密钥可被同一 enc 密钥解出原文", func() {
+ _, enc, _ := cfg.DerivePairingKeys(code)
+ k := make([]byte, 32)
+ _, _ = rand.Read(k)
+ ct, iv, err := cfg.SealKey(enc, k)
+ So(err, ShouldBeNil)
+ So(ct, ShouldNotBeBlank)
+ So(iv, ShouldNotBeBlank)
+ got, err := cfg.OpenKey(enc, ct, iv)
+ So(err, ShouldBeNil)
+ So(got, ShouldResemble, k)
+ })
+
+ Convey("enc 密钥不符时解密失败(GCM 认证)", func() {
+ _, enc, _ := cfg.DerivePairingKeys(code)
+ k := make([]byte, 32)
+ _, _ = rand.Read(k)
+ ct, iv, _ := cfg.SealKey(enc, k)
+ _, otherCode, _ := NewPairingCode()
+ _, wrongEnc, _ := cfg.DerivePairingKeys(otherCode)
+ _, err := cfg.OpenKey(wrongEnc, ct, iv)
+ So(err, ShouldNotBeNil)
+ })
+ })
+}
+
+func TestPairingCode(t *testing.T) {
+ Convey("一次性配对码", t, func() {
+ const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+ Convey("规范码为 8 个 crockford-base32 字符,展示为 XXXX-XXXX", func() {
+ canonical, display, err := NewPairingCode()
+ So(err, ShouldBeNil)
+ So(len(canonical), ShouldEqual, 8)
+ for _, c := range canonical {
+ So(strings.ContainsRune(crockford, c), ShouldBeTrue)
+ }
+ So(display, ShouldEqual, canonical[:4]+"-"+canonical[4:])
+ })
+
+ Convey("连续生成的码不相同(足够随机)", func() {
+ seen := map[string]bool{}
+ for i := 0; i < 64; i++ {
+ c, _, _ := NewPairingCode()
+ So(seen[c], ShouldBeFalse)
+ seen[c] = true
+ }
+ })
+
+ Convey("归一化去除连字符/空格并大写,兼容 crockford 混淆字符", func() {
+ So(NormalizePairingCode("abcd-efgh"), ShouldEqual, "ABCDEFGH")
+ So(NormalizePairingCode(" ab cd efgh "), ShouldEqual, "ABCDEFGH")
+ // crockford: o->0, i/l->1
+ So(NormalizePairingCode("oil1-2345"), ShouldEqual, "01112345")
+ })
+ })
+}
diff --git a/internal/daemon/bridge/audit_test.go b/internal/daemon/bridge/audit_test.go
new file mode 100644
index 0000000..4a03b3d
--- /dev/null
+++ b/internal/daemon/bridge/audit_test.go
@@ -0,0 +1,174 @@
+package bridge
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// waitForAuditEvent 轮询审计快照直到出现指定类型的事件。审计记录发生在连接关闭之后,
+// 测试观察到断开与记录落地之间存在时序差,故轮询而非直接断言。
+func waitForAuditEvent(h *testHarness, typ audit.Type) audit.Event {
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ for _, ev := range h.srv.audit.Snapshot() {
+ if ev.Type == typ {
+ return ev
+ }
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return audit.Event{}
+}
+
+// failSessionHandshake 用错误密钥发起会话握手,并等待连接被断开。
+// 返回握手中实际过线的密码学材料,供审计泄露检查比对。
+func (h *testHarness) failSessionHandshake() (nonceE, mac string) {
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ So(json.Unmarshal(challenge.Payload, &cp), ShouldBeNil)
+ wrong, _ := auth.NewLongTermKey()
+ nonceE, _ = auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac = h.crypto.ExtHMAC(auth.ModeSession, wrong, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac})
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, _, _ = e.ws.Read(ctx)
+ return nonceE, mac
+}
+
+func TestAuditRecording(t *testing.T) {
+ Convey("守卫在扩展建立会话前拦下的事件会进入审计", t, func() {
+ h := startTestServer(t)
+
+ Convey("会话握手 HMAC 失败记录为 handshake.failed", func() {
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+
+ h.failSessionHandshake()
+
+ ev := waitForAuditEvent(h, audit.TypeHandshakeFailed)
+ So(ev.Type, ShouldEqual, audit.TypeHandshakeFailed)
+ So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch)
+ So(ev.At.IsZero(), ShouldBeFalse)
+ })
+
+ Convey("握手期发送非 auth.response 消息记录为协议违规", func() {
+ e := dial(h.url)
+ e.read()
+ e.write(typeBridgeResponse, uuid.NewString(), struct{}{})
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, _, _ = e.ws.Read(ctx)
+
+ ev := waitForAuditEvent(h, audit.TypeHandshakeFailed)
+ So(ev.Reason, ShouldEqual, audit.ReasonProtocol)
+ })
+
+ Convey("配对握手 HMAC 失败记录为 pairing.failed", func() {
+ _, err := h.srv.BeginEnrollment()
+ So(err, ShouldBeNil)
+ wrongMac, _, _ := h.crypto.DerivePairingKeys("WRONGCODE")
+
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ So(json.Unmarshal(challenge.Payload, &cp), ShouldBeNil)
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModePairing, wrongMac, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modePairing, NonceE: nonceE, HMAC: mac})
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, _, _ = e.ws.Read(ctx)
+
+ ev := waitForAuditEvent(h, audit.TypePairingFailed)
+ So(ev.Reason, ShouldEqual, audit.ReasonHMACMismatch)
+ })
+
+ Convey("配对尝试超过限流上限后记录为 pairing.rate_limited", func() {
+ _, err := h.srv.BeginEnrollment()
+ So(err, ShouldBeNil)
+ wrongMac, _, _ := h.crypto.DerivePairingKeys("WRONGCODE")
+
+ // 限流器上限 5/分,第 6 次尝试应被限流挡下。
+ for i := 0; i < 6; i++ {
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ So(json.Unmarshal(challenge.Payload, &cp), ShouldBeNil)
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModePairing, wrongMac, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modePairing, NonceE: nonceE, HMAC: mac})
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ _, _, _ = e.ws.Read(ctx)
+ cancel()
+ }
+
+ ev := waitForAuditEvent(h, audit.TypePairingRateLimited)
+ So(ev.Type, ShouldEqual, audit.TypePairingRateLimited)
+ })
+
+ Convey("成功握手记录为 handshake.ok", func() {
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+ h.doSessionHandshake(key)
+
+ ev := waitForAuditEvent(h, audit.TypeHandshakeOK)
+ So(ev.Type, ShouldEqual, audit.TypeHandshakeOK)
+ })
+
+ Convey("客户端写请求超过限流上限后记录为 request.rate_limited", func() {
+ // 限流在转发前拦下,扩展侧不会产生任何记录,守卫侧不记就完全无痕。
+ // 写限流上限 10/分,第 11 次应被挡下。未连接扩展不影响限流记账。
+ for i := 0; i < 11; i++ {
+ _, _ = h.srv.Call(context.Background(), Request{
+ ClientID: "client-x",
+ Action: "scripts.delete.request",
+ }, true)
+ }
+
+ ev := waitForAuditEvent(h, audit.TypeRequestRateLimited)
+ So(ev.Type, ShouldEqual, audit.TypeRequestRateLimited)
+ So(ev.Client, ShouldEqual, "client-x")
+ })
+
+ Convey("审计不泄露握手中过线的密码学材料", func() {
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+ nonceE, mac := h.failSessionHandshake()
+ waitForAuditEvent(h, audit.TypeHandshakeFailed)
+
+ raw, err := json.Marshal(h.srv.audit.Snapshot())
+ So(err, ShouldBeNil)
+ So(string(raw), ShouldNotContainSubstring, mac)
+ So(string(raw), ShouldNotContainSubstring, nonceE)
+ })
+
+ Convey("事件字段集是封闭的,不含审计白名单外的键", func() {
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+ h.failSessionHandshake()
+ waitForAuditEvent(h, audit.TypeHandshakeFailed)
+
+ raw, err := json.Marshal(h.srv.audit.Snapshot())
+ So(err, ShouldBeNil)
+ var events []map[string]any
+ So(json.Unmarshal(raw, &events), ShouldBeNil)
+ So(events, ShouldNotBeEmpty)
+ allowed := map[string]bool{"at": true, "type": true, "client": true, "reason": true}
+ for _, ev := range events {
+ for k := range ev {
+ So(allowed[k], ShouldBeTrue)
+ }
+ }
+ })
+ })
+}
diff --git a/internal/daemon/bridge/call.go b/internal/daemon/bridge/call.go
new file mode 100644
index 0000000..8836882
--- /dev/null
+++ b/internal/daemon/bridge/call.go
@@ -0,0 +1,97 @@
+package bridge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// pendingCall 是一条挂起的 bridge.request:阻塞直到应答/取消/断开/超时。
+type pendingCall struct {
+ clientID string
+ respCh chan Response
+}
+
+// Call 把一次 bridge action 转发给扩展并阻塞等待应答。write=true 的写调用可能挂起数分钟
+// (等待用户在浏览器审批)。调用方 ctx 取消 / 超过 writeDecisionTTL / 扩展断开时:
+// - ctx 取消 / 超时:向扩展发 bridge.cancel 作废该操作,返回相应错误;
+// - 扩展断开:隐式作废全部在途请求,返回 ErrDisconnected。
+func (s *Server) Call(ctx context.Context, req Request, write bool) (Response, error) {
+ limiter := s.readLimit
+ if write {
+ limiter = s.writeLimit
+ }
+ if req.ClientID != "" && !limiter.Allow(req.ClientID) {
+ // 限流在转发前拦下,扩展侧不会有任何记录,守卫侧不记就完全无痕。
+ s.audit.Record(audit.Event{Type: audit.TypeRequestRateLimited, Client: req.ClientID})
+ return Response{}, &Error{Code: CodeRateLimited, Message: "rate limited"}
+ }
+
+ req.ProtocolVersion = protocolV
+ requestID := uuid.NewString()
+ pc := &pendingCall{clientID: req.ClientID, respCh: make(chan Response, 1)}
+
+ s.mu.Lock()
+ active := s.active
+ if active == nil {
+ s.mu.Unlock()
+ return Response{}, ErrNotConnected
+ }
+ s.pending[requestID] = pc
+ s.mu.Unlock()
+
+ defer func() {
+ s.mu.Lock()
+ delete(s.pending, requestID)
+ s.mu.Unlock()
+ }()
+
+ if err := active.send(typeBridgeRequest, requestID, req); err != nil {
+ return Response{}, fmt.Errorf("发送 bridge.request: %w", err)
+ }
+
+ timer := time.NewTimer(s.writeDecisionTTL)
+ defer timer.Stop()
+
+ select {
+ case resp := <-pc.respCh:
+ return resp, nil
+ case <-ctx.Done():
+ s.cancelToExt(active, requestID)
+ return Response{}, ctx.Err()
+ case <-timer.C:
+ s.cancelToExt(active, requestID)
+ return Response{}, &Error{Code: CodeOperationExpired, Message: "operation expired"}
+ case <-active.closed:
+ return Response{}, ErrDisconnected
+ }
+}
+
+// cancelToExt 向扩展发送 bridge.cancel,回填原 bridge.request 的 requestId,best-effort。
+func (s *Server) cancelToExt(c *conn, requestID string) {
+ if err := c.send(typeBridgeCancel, requestID, struct{}{}); err != nil {
+ s.log.Debug("发送 bridge.cancel 失败", zap.Error(err))
+ }
+}
+
+// handleBridgeResponse 把应答交付给对应的挂起调用;取消后迟到的应答无主,忽略。
+func (s *Server) handleBridgeResponse(env Envelope) {
+ var resp Response
+ if err := json.Unmarshal(env.Payload, &resp); err != nil {
+ s.log.Debug("解析 bridge.response 失败", zap.Error(err))
+ return
+ }
+ s.mu.Lock()
+ pc := s.pending[env.RequestID]
+ s.mu.Unlock()
+ if pc == nil {
+ return
+ }
+ pc.respCh <- resp
+}
diff --git a/internal/daemon/bridge/conn.go b/internal/daemon/bridge/conn.go
new file mode 100644
index 0000000..0c4c81f
--- /dev/null
+++ b/internal/daemon/bridge/conn.go
@@ -0,0 +1,224 @@
+package bridge
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+)
+
+// writeTimeout 是单帧写出的兜底超时,避免卡死的对端拖住写锁。
+const writeTimeout = 10 * time.Second
+
+// conn 是一条 WS 连接的会话状态:写串行化、生命周期取消、握手确立的长期密钥。
+type conn struct {
+ ws *websocket.Conn
+ srv *Server
+ log *zap.Logger
+ ctx context.Context
+ cancel context.CancelFunc
+
+ writeMu sync.Mutex
+ closed chan struct{}
+ closeOnce sync.Once
+
+ key []byte // 握手确立的长期共享密钥 K(会话建立后)
+}
+
+// authError 是被守卫判定为安全信号的握手失败,携带审计分类。handleWS 据此统一记录一次,
+// 避免在各失败点散落审计调用而漏记;不带此类型的失败(密钥读写等本地故障)不进审计。
+type authError struct {
+ evType audit.Type
+ reason audit.Reason
+ err error
+}
+
+func (e *authError) Error() string { return e.err.Error() }
+func (e *authError) Unwrap() error { return e.err }
+
+func authFail(evType audit.Type, reason audit.Reason, format string, args ...any) *authError {
+ return &authError{evType: evType, reason: reason, err: fmt.Errorf(format, args...)}
+}
+
+// handshake 执行每连接一次的双向 HMAC 挑战应答(会话或配对模式),须在 authTimeout 内完成。
+func (c *conn) handshake() error {
+ s := c.srv
+ ctx, cancel := context.WithTimeout(c.ctx, s.authTimeout)
+ defer cancel()
+
+ nonceD, err := auth.RandomNonceHex(s.proto.Crypto.NonceBytes)
+ if err != nil {
+ return err
+ }
+ if err := c.sendCtx(ctx, typeAuthChallenge, uuid.NewString(), authChallengePayload{NonceD: nonceD}); err != nil {
+ return fmt.Errorf("发送 auth.challenge: %w", err)
+ }
+
+ env, err := c.readEnvelope(ctx)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ return authFail(audit.TypeHandshakeFailed, audit.ReasonTimeout, "等待 auth.response 超时: %w", err)
+ }
+ return fmt.Errorf("读取 auth.response: %w", err)
+ }
+ // 握手完成前,除 auth.response 外的任何消息导致立即断开(§3)。
+ if env.Type != typeAuthResponse {
+ return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "握手期收到非 auth.response 消息: %s", env.Type)
+ }
+ var resp authResponsePayload
+ if err := json.Unmarshal(env.Payload, &resp); err != nil {
+ return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "解析 auth.response: %w", err)
+ }
+
+ switch resp.Mode {
+ case modeSession:
+ return c.handshakeSession(ctx, nonceD, resp)
+ case modePairing:
+ return c.handshakePairing(ctx, nonceD, resp)
+ default:
+ return authFail(audit.TypeHandshakeFailed, audit.ReasonProtocol, "未知握手模式: %q", resp.Mode)
+ }
+}
+
+// handshakeSession 用已配对长期密钥 K 完成会话握手(§3.1)。
+func (c *conn) handshakeSession(ctx context.Context, nonceD string, resp authResponsePayload) error {
+ s := c.srv
+ key, ok, err := s.keys.Load()
+ if err != nil {
+ return fmt.Errorf("加载长期密钥: %w", err)
+ }
+ if !ok {
+ return errors.New("尚未配对,无长期密钥")
+ }
+ if !s.crypto.VerifyExtHMAC(auth.ModeSession, key, nonceD, resp.NonceE, resp.HMAC) {
+ return authFail(audit.TypeHandshakeFailed, audit.ReasonHMACMismatch, "会话握手 HMAC 校验失败")
+ }
+ okMAC := s.crypto.DaemonHMAC(auth.ModeSession, key, nonceD, resp.NonceE)
+ if err := c.sendCtx(ctx, typeAuthOK, uuid.NewString(), authOKPayload{HMAC: okMAC}); err != nil {
+ return fmt.Errorf("发送 auth.ok: %w", err)
+ }
+ c.key = key
+ return nil
+}
+
+// handshakePairing 用接入码派生密钥完成首次接入握手,并以 AES-256-GCM 下发新长期密钥 K(§3.2)。
+// 线上模式标识沿用 "pairing"(与扩展侧一致),语义即「接入 enrollment」。
+func (c *conn) handshakePairing(ctx context.Context, nonceD string, resp authResponsePayload) error {
+ s := c.srv
+ if !s.enrollAttempts.Allow("enrollment") {
+ return authFail(audit.TypePairingRateLimited, audit.ReasonPairExhausted, "接入尝试过于频繁")
+ }
+ code, err := s.activeEnrollmentCode()
+ if err != nil {
+ return authFail(audit.TypePairingFailed, audit.ReasonPairExpired, "%w", err)
+ }
+ kpMac, kpEnc, err := s.crypto.DerivePairingKeys(code)
+ if err != nil {
+ return fmt.Errorf("派生接入密钥: %w", err)
+ }
+ if !s.crypto.VerifyExtHMAC(auth.ModePairing, kpMac, nonceD, resp.NonceE, resp.HMAC) {
+ s.failEnrollmentAttempt()
+ return authFail(audit.TypePairingFailed, audit.ReasonHMACMismatch, "接入握手 HMAC 校验失败")
+ }
+
+ k, err := auth.NewLongTermKey()
+ if err != nil {
+ return err
+ }
+ ct, iv, err := s.crypto.SealKey(kpEnc, k)
+ if err != nil {
+ return fmt.Errorf("加密下发长期密钥: %w", err)
+ }
+ okMAC := s.crypto.DaemonHMAC(auth.ModePairing, kpMac, nonceD, resp.NonceE)
+ if err := c.sendCtx(ctx, typeAuthOK, uuid.NewString(), authOKPayload{HMAC: okMAC, Key: &keyDelivery{Ciphertext: ct, IV: iv}}); err != nil {
+ return fmt.Errorf("发送 auth.ok: %w", err)
+ }
+ // 下发成功后再落盘,避免下发失败却持久化了扩展拿不到的密钥。
+ if err := s.keys.Save(k); err != nil {
+ return fmt.Errorf("持久化长期密钥: %w", err)
+ }
+ s.clearEnrollment()
+ c.key = k
+ return nil
+}
+
+// readLoop 是握手后的消息分发循环,任何读错误即关闭连接返回。
+func (c *conn) readLoop() {
+ for {
+ env, err := c.readEnvelope(c.ctx)
+ if err != nil {
+ c.close(websocket.StatusNormalClosure, "")
+ return
+ }
+ // v 不等于 1:立即断开(§2)。
+ if env.V != protocolV {
+ c.close(websocket.StatusProtocolError, "unsupported protocol version")
+ return
+ }
+ switch env.Type {
+ case typeBridgeResponse:
+ c.srv.handleBridgeResponse(env)
+ case typePing:
+ if err := c.send(typePong, env.RequestID, struct{}{}); err != nil {
+ c.log.Debug("回复 pong 失败", zap.Error(err))
+ }
+ case typePong:
+ // 心跳应答,v1 不做主动存活探测,无需处理。
+ default:
+ // 未知/非预期类型:忽略并记日志(前向兼容,§2)。
+ c.log.Debug("忽略未知或非预期消息", zap.String("type", env.Type))
+ }
+ }
+}
+
+func (c *conn) readEnvelope(ctx context.Context) (Envelope, error) {
+ _, data, err := c.ws.Read(ctx)
+ if err != nil {
+ return Envelope{}, err
+ }
+ var env Envelope
+ if err := json.Unmarshal(data, &env); err != nil {
+ return Envelope{}, fmt.Errorf("解析信封: %w", err)
+ }
+ return env, nil
+}
+
+func (c *conn) sendCtx(ctx context.Context, typ, requestID string, payload any) error {
+ env, err := newEnvelope(typ, requestID, payload)
+ if err != nil {
+ return err
+ }
+ return c.writeEnvelope(ctx, env)
+}
+
+func (c *conn) send(typ, requestID string, payload any) error {
+ ctx, cancel := context.WithTimeout(c.ctx, writeTimeout)
+ defer cancel()
+ return c.sendCtx(ctx, typ, requestID, payload)
+}
+
+// writeEnvelope 串行化写出(coder/websocket 同一时刻只允许一个写者)。
+func (c *conn) writeEnvelope(ctx context.Context, env Envelope) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return wsjson.Write(ctx, c.ws, env)
+}
+
+// close 幂等关闭:取消连接 ctx(解阻塞读)、关闭底层 WS、触发 closed 通道。
+func (c *conn) close(code websocket.StatusCode, reason string) {
+ c.closeOnce.Do(func() {
+ c.cancel()
+ _ = c.ws.Close(code, reason)
+ close(c.closed)
+ })
+}
diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go
new file mode 100644
index 0000000..52f906e
--- /dev/null
+++ b/internal/daemon/bridge/envelope.go
@@ -0,0 +1,115 @@
+package bridge
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// Envelope 是所有 WS 消息的统一信封(docs/protocol.md §2)。
+type Envelope struct {
+ V int `json:"v"`
+ Type string `json:"type"`
+ RequestID string `json:"requestId,omitempty"`
+ Payload json.RawMessage `json:"payload,omitempty"`
+}
+
+// 协议版本常量。
+const protocolV = 1
+
+// envelope 类型。扁平信任把 pair.*/client.* 从 protocol.json envelopeTypes 中移除后,下列即
+// 全集:envelopeTypes.session(握手/存活/生命周期)+ envelopeTypes.bridge(能力 RPC)。未知
+// 类型按前向兼容忽略(§2)。
+const (
+ typeAuthChallenge = "auth.challenge"
+ typeAuthResponse = "auth.response"
+ typeAuthOK = "auth.ok"
+ typeHello = "hello"
+ typeBridgeRequest = "bridge.request"
+ typeBridgeResponse = "bridge.response"
+ typeBridgeCancel = "bridge.cancel"
+ typePing = "ping"
+ typePong = "pong"
+ typeBridgeShutdown = "bridge.shutdown"
+)
+
+// 握手模式标识(auth.response.mode)。
+const (
+ modeSession = "session"
+ modePairing = "pairing"
+)
+
+// 常用错误码(全集见 protocol.json errorCodes;此处仅列 daemon 侧会主动产生的)。
+const (
+ CodeInvalidRequest = "INVALID_REQUEST"
+ CodeInternal = "INTERNAL_ERROR"
+ CodeRateLimited = "RATE_LIMITED"
+ CodeOperationExpired = "OPERATION_EXPIRED"
+)
+
+// --- payload 结构 ---
+
+type authChallengePayload struct {
+ NonceD string `json:"nonceD"`
+}
+
+type authResponsePayload struct {
+ Mode string `json:"mode"`
+ NonceE string `json:"nonceE"`
+ HMAC string `json:"hmac"`
+}
+
+type keyDelivery struct {
+ Ciphertext string `json:"ciphertext"`
+ IV string `json:"iv"`
+}
+
+type authOKPayload struct {
+ HMAC string `json:"hmac"`
+ Key *keyDelivery `json:"key,omitempty"`
+}
+
+type helloPayload struct {
+ DaemonVersion string `json:"daemonVersion"`
+ ProtocolVersion int `json:"protocolVersion"`
+}
+
+// Request 是转发给扩展执行的一次 action 调用(docs/protocol.md §4)。
+type Request struct {
+ ProtocolVersion int `json:"protocolVersion"`
+ ClientID string `json:"clientId"`
+ Action string `json:"action"`
+ Input json.RawMessage `json:"input"`
+}
+
+// Response 是扩展对 bridge.request 的应答(ok 二选一)。
+type Response struct {
+ OK bool `json:"ok"`
+ Result json.RawMessage `json:"result,omitempty"`
+ Error *Error `json:"error,omitempty"`
+}
+
+// Error 是失败应答里的结构化错误。
+type Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *Error) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Code + ": " + e.Message
+}
+
+// newEnvelope 组装一条信封,payload 为 nil 时省略该字段。
+func newEnvelope(typ, requestID string, payload any) (Envelope, error) {
+ env := Envelope{V: protocolV, Type: typ, RequestID: requestID}
+ if payload != nil {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return Envelope{}, fmt.Errorf("序列化 %s payload: %w", typ, err)
+ }
+ env.Payload = raw
+ }
+ return env, nil
+}
diff --git a/internal/daemon/bridge/pairing.go b/internal/daemon/bridge/pairing.go
new file mode 100644
index 0000000..a7aefc7
--- /dev/null
+++ b/internal/daemon/bridge/pairing.go
@@ -0,0 +1,65 @@
+package bridge
+
+import (
+ "errors"
+ "time"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+)
+
+// pendingEnrollment 是一次进行中的扩展接入窗口(由 sctl connect 打开)。接入是唯一需要带外
+// 配对码的环节:sctl 生成一次性码只在终端显示,用户输入扩展页面证明掌握,据此建立长期密钥 K。
+type pendingEnrollment struct {
+ code string
+ expiresAt time.Time
+ attemptsLeft int
+}
+
+// BeginEnrollment 打开一次接入窗口,返回展示形配对码(XXXX-XXXX)。供 sctl connect 使用;
+// 码只在终端显示、绝不经 WS 下发给任何连接(docs/threat-model.md)。
+func (s *Server) BeginEnrollment() (display string, err error) {
+ canonical, display, err := auth.NewPairingCode()
+ if err != nil {
+ return "", err
+ }
+ s.mu.Lock()
+ s.enrollment = &pendingEnrollment{
+ code: canonical,
+ expiresAt: time.Now().Add(s.enrollTTL),
+ attemptsLeft: 3,
+ }
+ s.mu.Unlock()
+ return display, nil
+}
+
+func (s *Server) activeEnrollmentCode() (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.enrollment == nil {
+ return "", errors.New("无进行中的接入窗口")
+ }
+ if time.Now().After(s.enrollment.expiresAt) {
+ s.enrollment = nil
+ return "", errors.New("接入窗口已过期")
+ }
+ return s.enrollment.code, nil
+}
+
+// failEnrollmentAttempt 记一次接入失败,累计 3 次即作废接入窗口(docs/protocol.md §3.2)。
+func (s *Server) failEnrollmentAttempt() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.enrollment == nil {
+ return
+ }
+ s.enrollment.attemptsLeft--
+ if s.enrollment.attemptsLeft <= 0 {
+ s.enrollment = nil
+ }
+}
+
+func (s *Server) clearEnrollment() {
+ s.mu.Lock()
+ s.enrollment = nil
+ s.mu.Unlock()
+}
diff --git a/internal/daemon/bridge/server.go b/internal/daemon/bridge/server.go
new file mode 100644
index 0000000..ecbfb74
--- /dev/null
+++ b/internal/daemon/bridge/server.go
@@ -0,0 +1,256 @@
+package bridge
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+ "github.com/scriptscat/sctl/internal/daemon/ratelimit"
+ "github.com/scriptscat/sctl/internal/daemon/store"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// auditCapacity 是守卫侧安全事件环形缓冲的容量,对齐扩展侧 MCP_AUDIT_RING_BUFFER_SIZE。
+const auditCapacity = 500
+
+var (
+ // ErrNotConnected 表示当前没有已配对的扩展连接。
+ ErrNotConnected = errors.New("扩展未连接")
+ // ErrDisconnected 表示在途请求因扩展连接断开而作废。
+ ErrDisconnected = errors.New("扩展连接已断开")
+)
+
+// Server 是桥接 daemon 的 WS 服务核心:accept、双向认证握手、envelope 路由与阻塞写模型。
+// v1 只允许一个扩展实例:新完成握手的连接替换旧连接。
+//
+// 信任模型是扁平的:接入(enrollment)建立唯一长期密钥 K 即确立信任,CLI 与所有 MCP agent 都
+// 经这条可信通道继承信任,不再逐客户端配对/铸令牌/撤销(docs/threat-model.md)。
+type Server struct {
+ version string
+ proto *protocol.Protocol
+ crypto *auth.Crypto
+ keys *store.KeyStore
+ log *zap.Logger
+
+ audit *audit.Recorder
+
+ enrollAttempts *ratelimit.Limiter
+ readLimit *ratelimit.Limiter
+ writeLimit *ratelimit.Limiter
+
+ authTimeout time.Duration
+ writeDecisionTTL time.Duration
+ enrollTTL time.Duration
+ maxFrameBytes int64
+
+ mu sync.Mutex
+ conns map[*conn]struct{}
+ active *conn
+ pending map[string]*pendingCall
+ enrollment *pendingEnrollment
+
+ httpServer *http.Server
+ baseCtx context.Context
+ baseCancel context.CancelFunc
+ shutdownOnce sync.Once
+}
+
+// NewServer 用协议常量与持久化后端构造服务。限流默认值取 docs/protocol.md §7(实现可调)。
+func NewServer(version string, p *protocol.Protocol, keys *store.KeyStore, log *zap.Logger) *Server {
+ if log == nil {
+ log = zap.NewNop()
+ }
+ return &Server{
+ version: version,
+ proto: p,
+ crypto: auth.NewCrypto(p),
+ keys: keys,
+ log: log,
+ audit: audit.NewRecorder(auditCapacity, log),
+ enrollAttempts: ratelimit.NewLimiter(5, time.Minute),
+ readLimit: ratelimit.NewLimiter(60, time.Minute),
+ writeLimit: ratelimit.NewLimiter(10, time.Minute),
+ authTimeout: time.Duration(p.Limits.AuthTimeoutMs) * time.Millisecond,
+ writeDecisionTTL: time.Duration(p.Limits.WriteDecisionTtlMs) * time.Millisecond,
+ enrollTTL: time.Duration(p.Limits.ExtPairingCodeTtlMs) * time.Millisecond,
+ maxFrameBytes: int64(p.Limits.MaxFrameBytes),
+ conns: make(map[*conn]struct{}),
+ pending: make(map[string]*pendingCall),
+ }
+}
+
+// Version 返回注入的 daemon 版本(hello.daemonVersion 与控制 API 都读它)。
+func (s *Server) Version() string { return s.version }
+
+// Action 按名字查协议动作定义(scope 与读写属性),未知 action 返回 ok=false。
+func (s *Server) Action(name string) (protocol.Action, bool) {
+ a, ok := s.proto.Actions[name]
+ return a, ok
+}
+
+// ExtConnected 报告当前是否有完成握手的扩展连接。
+func (s *Server) ExtConnected() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.active != nil
+}
+
+// AuditSnapshot 返回守卫侧安全事件的快照。
+func (s *Server) AuditSnapshot() []audit.Event { return s.audit.Snapshot() }
+
+// Serve 在给定 listener 上运行 WS 服务,阻塞至 ctx 取消后优雅停机。
+// mux 由调用方提供并可预先挂载其他路径(组装层在此挂上 /control/*,见 internal/daemon);
+// WS 面注册在根路径 "/"。
+func (s *Server) Serve(ctx context.Context, ln net.Listener, mux *http.ServeMux) error {
+ s.baseCtx, s.baseCancel = context.WithCancel(context.Background())
+ if mux == nil {
+ mux = http.NewServeMux()
+ }
+ mux.HandleFunc("/", s.handleWS)
+ s.httpServer = &http.Server{Handler: mux}
+
+ shutdownDone := make(chan struct{})
+ go func() {
+ <-ctx.Done()
+ s.shutdown()
+ close(shutdownDone)
+ }()
+
+ err := s.httpServer.Serve(ln)
+ <-shutdownDone
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+}
+
+// shutdown 通知扩展、断开所有连接并关闭 HTTP server。幂等。
+func (s *Server) shutdown() {
+ s.shutdownOnce.Do(func() {
+ s.mu.Lock()
+ active := s.active
+ conns := make([]*conn, 0, len(s.conns))
+ for c := range s.conns {
+ conns = append(conns, c)
+ }
+ s.mu.Unlock()
+
+ if active != nil {
+ // 计划退出前推送 bridge.shutdown(§3.4),best-effort。
+ _ = active.send(typeBridgeShutdown, uuid.NewString(), struct{}{})
+ }
+ for _, c := range conns {
+ c.close(websocket.StatusGoingAway, "server shutdown")
+ }
+ s.baseCancel()
+ _ = s.httpServer.Close()
+ })
+}
+
+// extensionOriginSchemes 是浏览器扩展页面(含 offscreen 文档)发起 WS 时 Origin 的合法前缀。
+// 浏览器盖章 Origin、页面 JS 无法伪造,据此廉价挡掉普通网页直连(docs/threat-model.md)。
+var extensionOriginSchemes = []string{"chrome-extension://", "moz-extension://", "safari-web-extension://"}
+
+// originAllowed 判定 WS 请求的 Origin 是否放行。空 Origin 放行:只有扩展经 WS 面接入,非浏览器
+// 进程(可伪造任意 Origin)由握手兜底;带 http(s):// 等网页 Origin 的连接直接拒。这是廉价前置
+// 过滤,不是唯一闸门。
+func originAllowed(origin string) bool {
+ if origin == "" {
+ return true
+ }
+ for _, scheme := range extensionOriginSchemes {
+ if strings.HasPrefix(origin, scheme) {
+ return true
+ }
+ }
+ return false
+}
+
+// handleWS 接受一条 WS 连接并驱动其握手与消息循环。Origin 白名单是廉价前置(挡网页),握手仍是
+// 真正的闸门(docs/protocol.md §8:非浏览器进程可伪造任意 Origin)。
+func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
+ if origin := r.Header.Get("Origin"); !originAllowed(origin) {
+ s.log.Debug("拒绝非扩展 Origin 的 WS 连接", zap.String("origin", origin))
+ s.audit.Record(audit.Event{Type: audit.TypeOriginRejected})
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ s.log.Warn("websocket accept 失败", zap.Error(err))
+ return
+ }
+ ws.SetReadLimit(s.maxFrameBytes)
+
+ c := s.newConn(ws)
+ defer s.removeConn(c)
+
+ if err := c.handshake(); err != nil {
+ // 认证失败统一以 1008 关闭,不回显原因(不给探测者信息,§3)。
+ s.log.Debug("握手失败,断开连接", zap.Error(err))
+ // 只有被守卫判定为安全信号的失败才进审计;本地故障(密钥读写失败等)不混入。
+ var ae *authError
+ if errors.As(err, &ae) {
+ s.audit.Record(audit.Event{Type: ae.evType, Reason: ae.reason})
+ }
+ c.close(websocket.StatusPolicyViolation, "")
+ return
+ }
+
+ s.audit.Record(audit.Event{Type: audit.TypeHandshakeOK})
+ s.setActive(c)
+ if err := c.send(typeHello, uuid.NewString(), helloPayload{DaemonVersion: s.version, ProtocolVersion: protocolV}); err != nil {
+ s.log.Debug("发送 hello 失败", zap.Error(err))
+ c.close(websocket.StatusInternalError, "")
+ return
+ }
+ s.log.Info("扩展已完成握手并连接")
+ c.readLoop()
+}
+
+func (s *Server) newConn(ws *websocket.Conn) *conn {
+ ctx, cancel := context.WithCancel(s.baseCtx)
+ c := &conn{
+ ws: ws,
+ srv: s,
+ log: s.log,
+ closed: make(chan struct{}),
+ ctx: ctx,
+ cancel: cancel,
+ }
+ s.mu.Lock()
+ s.conns[c] = struct{}{}
+ s.mu.Unlock()
+ return c
+}
+
+func (s *Server) removeConn(c *conn) {
+ s.mu.Lock()
+ delete(s.conns, c)
+ if s.active == c {
+ s.active = nil
+ }
+ s.mu.Unlock()
+ c.close(websocket.StatusNormalClosure, "")
+}
+
+// setActive 将 c 设为唯一活动连接,替换并断开旧连接(v1 单实例)。
+func (s *Server) setActive(c *conn) {
+ s.mu.Lock()
+ old := s.active
+ s.active = c
+ s.mu.Unlock()
+ if old != nil && old != c {
+ old.close(websocket.StatusNormalClosure, "replaced by new connection")
+ }
+}
diff --git a/internal/daemon/bridge/server_test.go b/internal/daemon/bridge/server_test.go
new file mode 100644
index 0000000..661fb89
--- /dev/null
+++ b/internal/daemon/bridge/server_test.go
@@ -0,0 +1,331 @@
+package bridge
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/google/uuid"
+ . "github.com/smartystreets/goconvey/convey"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+ "github.com/scriptscat/sctl/internal/daemon/store"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// testHarness 承载一个运行中的 daemon 与用于对拍的密码学助手/存储。
+type testHarness struct {
+ srv *Server
+ crypto *auth.Crypto
+ keys *store.KeyStore
+ url string
+ proto *protocol.Protocol
+}
+
+func startTestServer(t *testing.T) *testHarness {
+ t.Helper()
+ p, err := protocol.Load()
+ So(err, ShouldBeNil)
+ dir := t.TempDir()
+ keys := store.NewKeyStore(filepath.Join(dir, "pairing.key"))
+ srv := NewServer("0.1.0", p, keys, zap.NewNop())
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ So(err, ShouldBeNil)
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ go func() { _ = srv.Serve(ctx, ln, nil) }()
+
+ return &testHarness{
+ srv: srv,
+ crypto: auth.NewCrypto(p),
+ keys: keys,
+ url: "ws://" + ln.Addr().String() + "/",
+ proto: p,
+ }
+}
+
+// extClient 模拟扩展侧的 WS client。
+type extClient struct {
+ ws *websocket.Conn
+}
+
+func dial(url string) *extClient {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ ws, _, err := websocket.Dial(ctx, url, nil)
+ So(err, ShouldBeNil)
+ return &extClient{ws: ws}
+}
+
+func (e *extClient) read() Envelope {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, data, err := e.ws.Read(ctx)
+ So(err, ShouldBeNil)
+ var env Envelope
+ So(json.Unmarshal(data, &env), ShouldBeNil)
+ return env
+}
+
+func (e *extClient) write(typ, requestID string, payload any) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ env, err := newEnvelope(typ, requestID, payload)
+ So(err, ShouldBeNil)
+ So(wsjson.Write(ctx, e.ws, env), ShouldBeNil)
+}
+
+// doSessionHandshake 以给定长期密钥完成会话握手,并消费 hello。
+func (h *testHarness) doSessionHandshake(key []byte) *extClient {
+ e := dial(h.url)
+ challenge := e.read()
+ So(challenge.Type, ShouldEqual, typeAuthChallenge)
+ var cp authChallengePayload
+ So(json.Unmarshal(challenge.Payload, &cp), ShouldBeNil)
+
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModeSession, key, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac})
+
+ ok := e.read()
+ So(ok.Type, ShouldEqual, typeAuthOK)
+ var okp authOKPayload
+ So(json.Unmarshal(ok.Payload, &okp), ShouldBeNil)
+ So(h.crypto.VerifyDaemonHMAC(auth.ModeSession, key, cp.NonceD, nonceE, okp.HMAC), ShouldBeTrue)
+
+ hello := e.read()
+ So(hello.Type, ShouldEqual, typeHello)
+ return e
+}
+
+func TestSessionHandshakeFlow(t *testing.T) {
+ Convey("会话模式握手(已配对)", t, func() {
+ h := startTestServer(t)
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+
+ Convey("正确密钥可完成握手并收到 hello", func() {
+ e := h.doSessionHandshake(key)
+ So(e, ShouldNotBeNil)
+ })
+
+ Convey("错误密钥握手失败,连接被断开", func() {
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ _ = json.Unmarshal(challenge.Payload, &cp)
+ wrong, _ := auth.NewLongTermKey()
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModeSession, wrong, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac})
+ // 认证失败:后续读应报错(连接以 1008 关闭,不回显原因)。
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, _, err := e.ws.Read(ctx)
+ So(err, ShouldNotBeNil)
+ })
+ })
+}
+
+func TestPairingHandshakeFlow(t *testing.T) {
+ Convey("配对模式握手(首次配对)", t, func() {
+ h := startTestServer(t)
+
+ Convey("配对码正确:下发的 K 可解出并已落盘 0600", func() {
+ display, err := h.srv.BeginEnrollment()
+ So(err, ShouldBeNil)
+ So(display, ShouldContainSubstring, "-")
+
+ kpMac, kpEnc, err := h.crypto.DerivePairingKeys(display)
+ So(err, ShouldBeNil)
+
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ _ = json.Unmarshal(challenge.Payload, &cp)
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModePairing, kpMac, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modePairing, NonceE: nonceE, HMAC: mac})
+
+ ok := e.read()
+ So(ok.Type, ShouldEqual, typeAuthOK)
+ var okp authOKPayload
+ So(json.Unmarshal(ok.Payload, &okp), ShouldBeNil)
+ So(okp.Key, ShouldNotBeNil)
+ So(h.crypto.VerifyDaemonHMAC(auth.ModePairing, kpMac, cp.NonceD, nonceE, okp.HMAC), ShouldBeTrue)
+
+ k, err := h.crypto.OpenKey(kpEnc, okp.Key.Ciphertext, okp.Key.IV)
+ So(err, ShouldBeNil)
+
+ hello := e.read()
+ So(hello.Type, ShouldEqual, typeHello)
+
+ // 落盘的长期密钥与扩展解出的一致。
+ saved, ok2, err := h.keys.Load()
+ So(err, ShouldBeNil)
+ So(ok2, ShouldBeTrue)
+ So(saved, ShouldResemble, k)
+ })
+
+ Convey("配对码错误:握手失败且不下发密钥", func() {
+ _, err := h.srv.BeginEnrollment()
+ So(err, ShouldBeNil)
+ _, wrongEnc, _ := h.crypto.DerivePairingKeys("WRONGWRONG")
+ _ = wrongEnc
+ wrongMac, _, _ := h.crypto.DerivePairingKeys("WRONGCODE")
+
+ e := dial(h.url)
+ challenge := e.read()
+ var cp authChallengePayload
+ _ = json.Unmarshal(challenge.Payload, &cp)
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModePairing, wrongMac, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modePairing, NonceE: nonceE, HMAC: mac})
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, _, readErr := e.ws.Read(ctx)
+ So(readErr, ShouldNotBeNil)
+ _, saved, _ := h.keys.Load()
+ So(saved, ShouldBeFalse)
+ })
+ })
+}
+
+func TestBlockingCallAndCancel(t *testing.T) {
+ Convey("阻塞写调用与断开即作废", t, func() {
+ h := startTestServer(t)
+ key, _ := auth.NewLongTermKey()
+ So(h.keys.Save(key), ShouldBeNil)
+ e := h.doSessionHandshake(key)
+
+ type callResult struct {
+ resp Response
+ err error
+ }
+
+ Convey("批准前请求方取消 → 扩展收到同 requestId 的 bridge.cancel", func() {
+ callCtx, cancelCall := context.WithCancel(context.Background())
+ resCh := make(chan callResult, 1)
+ go func() {
+ resp, err := h.srv.Call(callCtx, Request{
+ ClientID: "sctl-cli",
+ Action: "scripts.install.request",
+ Input: json.RawMessage(`{"url":"x"}`),
+ }, true)
+ resCh <- callResult{resp, err}
+ }()
+
+ req := e.read()
+ So(req.Type, ShouldEqual, typeBridgeRequest)
+ So(req.RequestID, ShouldNotBeBlank)
+
+ cancelCall()
+
+ cancelMsg := e.read()
+ So(cancelMsg.Type, ShouldEqual, typeBridgeCancel)
+ So(cancelMsg.RequestID, ShouldEqual, req.RequestID)
+
+ r := <-resCh
+ So(r.err, ShouldNotBeNil)
+ })
+
+ Convey("扩展如期应答 → Call 返回结果", func() {
+ resCh := make(chan callResult, 1)
+ go func() {
+ resp, err := h.srv.Call(context.Background(), Request{
+ ClientID: "sctl-cli",
+ Action: "scripts.list",
+ Input: json.RawMessage(`{}`),
+ }, false)
+ resCh <- callResult{resp, err}
+ }()
+
+ req := e.read()
+ So(req.Type, ShouldEqual, typeBridgeRequest)
+ e.write(typeBridgeResponse, req.RequestID, Response{OK: true, Result: json.RawMessage(`{"scripts":[]}`)})
+
+ r := <-resCh
+ So(r.err, ShouldBeNil)
+ So(r.resp.OK, ShouldBeTrue)
+ So(string(r.resp.Result), ShouldEqual, `{"scripts":[]}`)
+ })
+
+ Convey("扩展连接断开 → 在途请求返回 ErrDisconnected", func() {
+ resCh := make(chan callResult, 1)
+ go func() {
+ resp, err := h.srv.Call(context.Background(), Request{
+ ClientID: "sctl-cli",
+ Action: "scripts.list",
+ Input: json.RawMessage(`{}`),
+ }, false)
+ resCh <- callResult{resp, err}
+ }()
+
+ req := e.read()
+ So(req.Type, ShouldEqual, typeBridgeRequest)
+ So(e.ws.Close(websocket.StatusNormalClosure, "bye"), ShouldBeNil)
+
+ r := <-resCh
+ So(r.err, ShouldEqual, ErrDisconnected)
+ })
+ })
+}
+
+func TestOriginWhitelist(t *testing.T) {
+ Convey("Origin 白名单廉价挡掉普通网页直连(握手仍是真正闸门)", t, func() {
+ h := startTestServer(t)
+
+ dialOrigin := func(origin string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ ws, _, err := websocket.Dial(ctx, h.url, &websocket.DialOptions{
+ HTTPHeader: http.Header{"Origin": []string{origin}},
+ })
+ if ws != nil {
+ _ = ws.Close(websocket.StatusNormalClosure, "")
+ }
+ return err
+ }
+
+ Convey("http(s):// 网页 Origin 被拒(WS 升级失败)", func() {
+ So(dialOrigin("http://evil.example"), ShouldNotBeNil)
+ })
+
+ Convey("扩展 Origin(chrome-extension://)放行", func() {
+ So(dialOrigin("chrome-extension://abcdefghijklmnop"), ShouldBeNil)
+ })
+
+ Convey("无 Origin(非浏览器进程)放行,由握手兜底", func() {
+ e := dial(h.url)
+ So(e.read().Type, ShouldEqual, typeAuthChallenge)
+ })
+
+ Convey("被拒的网页连接记录为 origin.rejected 审计事件", func() {
+ _ = dialOrigin("http://evil.example")
+ deadline := time.Now().Add(3 * time.Second)
+ var found bool
+ for time.Now().Before(deadline) {
+ for _, ev := range h.srv.audit.Snapshot() {
+ if ev.Type == audit.TypeOriginRejected {
+ found = true
+ }
+ }
+ if found {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ So(found, ShouldBeTrue)
+ })
+ })
+}
diff --git a/internal/daemon/component.go b/internal/daemon/component.go
new file mode 100644
index 0000000..03604e2
--- /dev/null
+++ b/internal/daemon/component.go
@@ -0,0 +1,142 @@
+// Package daemon 是 sctl serve 的组装层:一个 cago Component,把仅监听 loopback 的
+// 桥接 WS server(internal/daemon/bridge)、本机控制 API(internal/daemon/controlapi)
+// 与持久化存储(internal/daemon/store)接到同一个 listener 上。
+// 扩展 ↔ daemon 协议见 docs/protocol.md。
+package daemon
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/cago-frame/cago/configs"
+ "github.com/cago-frame/cago/pkg/gogo"
+ "github.com/cago-frame/cago/pkg/logger"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/daemon/bridge"
+ "github.com/scriptscat/sctl/internal/daemon/controlapi"
+ "github.com/scriptscat/sctl/internal/daemon/store"
+ "github.com/scriptscat/sctl/internal/pkg/paths"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// Config 是 config.yaml 中 `bridge` 段的映射。地址默认仅 loopback。
+type Config struct {
+ Address string `yaml:"address"`
+}
+
+// daemonComponent 实现 cago 的 ComponentCancel:Start 拉起 WS server,
+// server 意外退出时通过 cancel 终止整个应用。
+type daemonComponent struct {
+ version string
+ cfg Config
+ srv *bridge.Server
+ listener net.Listener
+}
+
+// Component 返回桥接 daemon 组件,注册到 cago 应用(用 RegistryCancel)。
+// version 注入 hello 消息的 daemonVersion。
+func Component(version string) *daemonComponent {
+ return &daemonComponent{version: version}
+}
+
+func (b *daemonComponent) Start(ctx context.Context, cfg *configs.Config) error {
+ return b.StartCancel(ctx, func() {}, cfg)
+}
+
+func (b *daemonComponent) StartCancel(ctx context.Context, cancel context.CancelFunc, cfg *configs.Config) error {
+ p, err := protocol.Load()
+ if err != nil {
+ logger.Ctx(ctx).Error("加载内嵌协议失败", zap.Error(err))
+ return err
+ }
+ if err := cfg.Scan(ctx, "bridge", &b.cfg); err != nil {
+ // 缺 bridge 段时退回协议默认端口,而不是直接失败。
+ logger.Ctx(ctx).Warn("读取 bridge 配置失败,回退协议默认地址", zap.Error(err))
+ }
+ // SCTL_BRIDGE_ADDR 覆盖配置/默认:daemon 与前端(control.resolveBaseURL)读同一环境变量,
+ // 保证自动拉起时 serve 绑定的地址正是前端要连的地址(自定义端口 / 多实例场景)。
+ if envAddr := os.Getenv("SCTL_BRIDGE_ADDR"); envAddr != "" {
+ b.cfg.Address = envAddr
+ }
+ if b.cfg.Address == "" {
+ b.cfg.Address = defaultAddress(p.Transport.DefaultPort)
+ }
+ // 仅允许绑定 loopback:非本机地址一律拒绝(docs/protocol.md §8 明确不做 Origin 判别,监听面必须收窄)。
+ if err := validateLoopback(b.cfg.Address); err != nil {
+ logger.Ctx(ctx).Error("拒绝在非 loopback 地址上监听", zap.String("address", b.cfg.Address), zap.Error(err))
+ return err
+ }
+
+ keys := store.NewKeyStore(paths.KeyFile())
+ b.srv = bridge.NewServer(b.version, p, keys, logger.Ctx(ctx))
+
+ // 同步 net.Listen 使绑定失败在启动阶段即暴露(cago 会 panic,符合 fail-fast 约定)。
+ ln, err := net.Listen("tcp", b.cfg.Address)
+ if err != nil {
+ logger.Ctx(ctx).Error("绑定 WS 监听端口失败", zap.String("address", b.cfg.Address), zap.Error(err))
+ return err
+ }
+ b.listener = ln
+ logger.Ctx(ctx).Info("桥接 daemon 开始监听", zap.String("address", ln.Addr().String()), zap.Int("protocolVersion", p.ProtocolVersion))
+
+ // 绑定成功后(端口竞态胜出者才走到这里)再生成并落盘控制令牌,避免失败方覆盖胜出者的令牌。
+ // 令牌先于 server goroutine 就绪:前端一旦看到 /control/health 200,令牌文件必已写好。
+ token, err := control.NewControlToken()
+ if err != nil {
+ logger.Ctx(ctx).Error("生成控制令牌失败", zap.Error(err))
+ return err
+ }
+ if err := control.WriteControlToken(token); err != nil {
+ logger.Ctx(ctx).Error("写入控制令牌失败", zap.Error(err))
+ return err
+ }
+
+ // 控制 API 与扩展 WS 面同 listener、独立路径:mux 在此组装,bridge 只认根路径。
+ mux := http.NewServeMux()
+ controlapi.New(b.srv, token, logger.Ctx(ctx)).Register(mux)
+
+ // cago 同步调用 StartCancel,server 类组件须起 goroutine 后立即返回,否则 Start()
+ // 的信号注册跑不到、SIGINT 会死锁(对齐 cago 的 mux.HTTP 写法)。
+ gogo.Go(func() error {
+ // server 意外退出(非优雅停机)即终止整个应用。
+ if err := b.srv.Serve(ctx, ln, mux); err != nil {
+ logger.Ctx(ctx).Error("WS server 异常退出", zap.Error(err))
+ cancel()
+ return err
+ }
+ return nil
+ })
+ return nil
+}
+
+func (b *daemonComponent) CloseHandle() {
+ logger.Default().Info("桥接 daemon 已停止")
+}
+
+func defaultAddress(port int) string {
+ return fmt.Sprintf("127.0.0.1:%d", port)
+}
+
+// validateLoopback 校验监听地址的主机部分是 loopback(127.0.0.0/8、::1 或 localhost)。
+func validateLoopback(address string) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return fmt.Errorf("解析监听地址 %q: %w", address, err)
+ }
+ if host == "localhost" {
+ return nil
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return fmt.Errorf("监听地址主机 %q 非法或非 loopback", host)
+ }
+ if !ip.IsLoopback() {
+ return fmt.Errorf("拒绝非 loopback 监听地址 %q", host)
+ }
+ return nil
+}
diff --git a/internal/daemon/component_test.go b/internal/daemon/component_test.go
new file mode 100644
index 0000000..4b11dda
--- /dev/null
+++ b/internal/daemon/component_test.go
@@ -0,0 +1,18 @@
+package daemon
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestLoopbackOnly(t *testing.T) {
+ Convey("仅允许绑定 loopback 地址", t, func() {
+ So(validateLoopback("127.0.0.1:8643"), ShouldBeNil)
+ So(validateLoopback("[::1]:8643"), ShouldBeNil)
+ So(validateLoopback("localhost:8643"), ShouldBeNil)
+ So(validateLoopback("0.0.0.0:8643"), ShouldNotBeNil)
+ So(validateLoopback("192.168.1.10:8643"), ShouldNotBeNil)
+ So(validateLoopback("garbage"), ShouldNotBeNil)
+ })
+}
diff --git a/internal/daemon/controlapi/controlapi.go b/internal/daemon/controlapi/controlapi.go
new file mode 100644
index 0000000..c11ee0d
--- /dev/null
+++ b/internal/daemon/controlapi/controlapi.go
@@ -0,0 +1,156 @@
+// Package controlapi 实现 daemon listener 上的本机内部控制 API(/control/*)。
+//
+// 它是守卫侧的 controller 层:只做协议转换(HTTP/JSON ↔ 桥接调用)与控制令牌鉴权,一切有状态
+// 逻辑交给 Bridge。扁平信任下控制令牌是唯一传输闸门:带令牌即拥有全部能力,不再逐客户端令牌 /
+// scope 判定;调用方自报的客户端标签只用于审计归因。与扩展 WS 面共用同一 listener、走独立路径;
+// 除 /control/health 外均要求控制令牌。请求/响应 DTO 与前端共享,见 internal/client/control。
+package controlapi
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/daemon/bridge"
+ "github.com/scriptscat/sctl/internal/pkg/audit"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// Bridge 是控制 API 依赖的守卫侧能力面,由 *bridge.Server 实现。
+// 收窄到这几个方法既是为了让本包能独立测试,也是为了标明控制 API 不该碰 WS 连接状态。
+type Bridge interface {
+ Version() string
+ Action(name string) (protocol.Action, bool)
+ ExtConnected() bool
+ AuditSnapshot() []audit.Event
+ Call(ctx context.Context, req bridge.Request, write bool) (bridge.Response, error)
+ BeginEnrollment() (string, error)
+}
+
+// Handler 是控制 API 的处理器集合。
+type Handler struct {
+ bridge Bridge
+ token string
+ log *zap.Logger
+}
+
+// New 构造控制 API。token 是 daemon 绑定端口后落盘的 0600 控制令牌;空令牌下除健康检查外全拒。
+func New(b Bridge, token string, log *zap.Logger) *Handler {
+ if log == nil {
+ log = zap.NewNop()
+ }
+ return &Handler{bridge: b, token: token, log: log}
+}
+
+// Register 把控制 API 挂到 daemon listener 的 mux 上。
+func (h *Handler) Register(mux *http.ServeMux) {
+ mux.HandleFunc(control.PathHealth, h.health)
+ mux.HandleFunc(control.PathCall, h.guard(h.call))
+ mux.HandleFunc(control.PathStatus, h.guard(h.status))
+ mux.HandleFunc(control.PathEnroll, h.guard(h.enroll))
+}
+
+// guard 包装需要控制令牌的处理器:恒定时间校验 X-Sctl-Control-Token,不符即 401(无细节)。
+func (h *Handler) guard(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.authenticated(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (h *Handler) authenticated(r *http.Request) bool {
+ got := r.Header.Get(control.HeaderControlToken)
+ if h.token == "" || got == "" {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(h.token), []byte(got)) == 1
+}
+
+func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, control.HealthResult{OK: true, Version: h.bridge.Version()})
+}
+
+func (h *Handler) status(w http.ResponseWriter, _ *http.Request) {
+ events := h.bridge.AuditSnapshot()
+ writeJSON(w, control.StatusResult{
+ DaemonVersion: h.bridge.Version(),
+ ExtConnected: h.bridge.ExtConnected(),
+ SecurityCount: len(events),
+ Security: events,
+ })
+}
+
+// call 转发一次 bridge action:解析调用方自报标签(仅审计),再驱动阻塞的 Bridge.Call。
+// 请求方(CLI/mcp)断开会取消 r.Context() → Call 向扩展发 bridge.cancel 作废操作。
+func (h *Handler) call(w http.ResponseWriter, r *http.Request) {
+ var req control.CallRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeControlError(w, bridge.CodeInvalidRequest, "请求体非法")
+ return
+ }
+ action, ok := h.bridge.Action(req.Action)
+ if !ok {
+ writeControlError(w, bridge.CodeInvalidRequest, "未知 action")
+ return
+ }
+
+ clientID := control.CLIClientID
+ if label := r.Header.Get(control.HeaderClientLabel); label != "" {
+ clientID = label
+ }
+
+ resp, err := h.bridge.Call(r.Context(), bridge.Request{
+ ClientID: clientID,
+ Action: req.Action,
+ Input: req.Input,
+ }, action.Write)
+ switch {
+ case err == nil:
+ res := control.CallResult{OK: resp.OK, Result: resp.Result}
+ if resp.Error != nil {
+ res.Error = &control.CallError{Code: resp.Error.Code, Message: resp.Error.Message}
+ }
+ writeJSON(w, res)
+ case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
+ // 请求方已断开(Ctrl-C / 超时),连接已消失,无需再写响应。
+ return
+ case errors.Is(err, bridge.ErrNotConnected):
+ writeControlError(w, bridge.CodeInternal, "扩展未连接")
+ case errors.Is(err, bridge.ErrDisconnected):
+ writeControlError(w, bridge.CodeOperationExpired, "扩展连接已断开,操作作废")
+ default:
+ var be *bridge.Error
+ if errors.As(err, &be) {
+ writeControlError(w, be.Code, be.Message)
+ return
+ }
+ writeControlError(w, bridge.CodeInternal, "内部错误")
+ }
+}
+
+// enroll 打开一次接入窗口并返回展示形配对码(供 sctl connect 在终端展示)。
+func (h *Handler) enroll(w http.ResponseWriter, _ *http.Request) {
+ display, err := h.bridge.BeginEnrollment()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, control.EnrollResult{Code: display})
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeControlError(w http.ResponseWriter, code, message string) {
+ writeJSON(w, control.CallResult{OK: false, Error: &control.CallError{Code: code, Message: message}})
+}
diff --git a/internal/daemon/controlapi/controlapi_test.go b/internal/daemon/controlapi/controlapi_test.go
new file mode 100644
index 0000000..2985817
--- /dev/null
+++ b/internal/daemon/controlapi/controlapi_test.go
@@ -0,0 +1,162 @@
+package controlapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/daemon/bridge"
+)
+
+func TestControlHealthAndAuth(t *testing.T) {
+ Convey("控制 API 鉴权与健康检查", t, func() {
+ h := startTestServer(t)
+ base := h.httpBase()
+ ctx := context.Background()
+
+ Convey("健康检查不需令牌,返回 ok 与版本", func() {
+ resp, err := postControl(ctx, base, control.PathHealth, "", "", nil)
+ So(err, ShouldBeNil)
+ defer resp.Body.Close()
+ So(resp.StatusCode, ShouldEqual, http.StatusOK)
+ var hr control.HealthResult
+ So(json.NewDecoder(resp.Body).Decode(&hr), ShouldBeNil)
+ So(hr.OK, ShouldBeTrue)
+ So(hr.Version, ShouldEqual, "0.1.0")
+ })
+
+ Convey("缺少控制令牌的 call 被 401 拒绝", func() {
+ resp, err := postControl(ctx, base, control.PathCall, "", "", control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)})
+ So(err, ShouldBeNil)
+ resp.Body.Close()
+ So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
+ })
+
+ Convey("控制令牌错误的 call 被 401 拒绝", func() {
+ resp, err := postControl(ctx, base, control.PathCall, "wrong-token", "", control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)})
+ So(err, ShouldBeNil)
+ resp.Body.Close()
+ So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
+ })
+ })
+}
+
+func TestControlCallForwarding(t *testing.T) {
+ Convey("控制 call 转发到扩展并回传应答", t, func() {
+ h := startTestServer(t)
+ key, err := newKeyAndSave(h)
+ So(err, ShouldBeNil)
+ e := h.doSessionHandshake(key)
+ base := h.httpBase()
+
+ Convey("sctl-cli 身份的读调用:扩展应答后 CallResult.ok=true", func() {
+ ch := goPostControl(context.Background(), base, control.PathCall, testControlToken, "", control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)})
+
+ req := e.read()
+ So(req.Type, ShouldEqual, typeBridgeRequest)
+ var br bridge.Request
+ So(json.Unmarshal(req.Payload, &br), ShouldBeNil)
+ So(br.ClientID, ShouldEqual, control.CLIClientID)
+ So(br.Action, ShouldEqual, "scripts.list")
+ e.write(typeBridgeResponse, req.RequestID, bridge.Response{OK: true, Result: json.RawMessage(`{"scripts":[]}`)})
+
+ out := <-ch
+ So(out.err, ShouldBeNil)
+ res := decodeCall(out.resp)
+ So(res.OK, ShouldBeTrue)
+ So(string(res.Result), ShouldEqual, `{"scripts":[]}`)
+ })
+
+ Convey("未知 action → INVALID_REQUEST", func() {
+ resp, err := postControl(context.Background(), base, control.PathCall, testControlToken, "", control.CallRequest{Action: "scripts.nope", Input: json.RawMessage(`{}`)})
+ So(err, ShouldBeNil)
+ res := decodeCall(resp)
+ So(res.OK, ShouldBeFalse)
+ So(res.Error.Code, ShouldEqual, "INVALID_REQUEST")
+ })
+ })
+}
+
+func TestControlWriteCancelPropagation(t *testing.T) {
+ Convey("写调用阻塞期间请求方取消 → 扩展收到同 requestId 的 bridge.cancel", t, func() {
+ h := startTestServer(t)
+ key, err := newKeyAndSave(h)
+ So(err, ShouldBeNil)
+ e := h.doSessionHandshake(key)
+ base := h.httpBase()
+
+ callCtx, cancelCall := context.WithCancel(context.Background())
+ ch := goPostControl(callCtx, base, control.PathCall, testControlToken, "", control.CallRequest{Action: "scripts.install.request", Input: json.RawMessage(`{"url":"https://x/y.user.js"}`)})
+
+ // 扩展收到 bridge.request(阻塞审批中,不应答)。
+ req := e.read()
+ So(req.Type, ShouldEqual, typeBridgeRequest)
+ So(req.RequestID, ShouldNotBeBlank)
+
+ // 请求方 Ctrl-C:取消 HTTP 请求。
+ cancelCall()
+
+ // 扩展应收到同 requestId 的 bridge.cancel(daemon 作废该操作)。
+ cancelMsg := e.read()
+ So(cancelMsg.Type, ShouldEqual, typeBridgeCancel)
+ So(cancelMsg.RequestID, ShouldEqual, req.RequestID)
+
+ // 客户端侧 Do 返回被取消错误。
+ out := <-ch
+ So(out.err, ShouldNotBeNil)
+ })
+}
+
+func TestControlClientLabelForwarding(t *testing.T) {
+ Convey("扁平信任:控制令牌即全部能力,客户端标签只作审计归因", t, func() {
+ h := startTestServer(t)
+ key, err := newKeyAndSave(h)
+ So(err, ShouldBeNil)
+ e := h.doSessionHandshake(key)
+ base := h.httpBase()
+
+ Convey("带客户端标签的调用被放行,并以该标签作为 clientId 转发", func() {
+ ch := goPostControl(context.Background(), base, control.PathCall, testControlToken, "scriptcat-claude", control.CallRequest{Action: "scripts.list", Input: json.RawMessage(`{}`)})
+ req := e.read()
+ var br bridge.Request
+ So(json.Unmarshal(req.Payload, &br), ShouldBeNil)
+ So(br.ClientID, ShouldEqual, "scriptcat-claude")
+ e.write(typeBridgeResponse, req.RequestID, bridge.Response{OK: true, Result: json.RawMessage(`{"scripts":[]}`)})
+ out := <-ch
+ So(out.err, ShouldBeNil)
+ So(decodeCall(out.resp).OK, ShouldBeTrue)
+ })
+
+ Convey("无标签的写调用被放行,并以内建 sctl-cli 标签转发(授权在扩展审批闸门)", func() {
+ ch := goPostControl(context.Background(), base, control.PathCall, testControlToken, "", control.CallRequest{Action: "scripts.delete.request", Input: json.RawMessage(`{"uuid":"x"}`)})
+ req := e.read()
+ var br bridge.Request
+ So(json.Unmarshal(req.Payload, &br), ShouldBeNil)
+ So(br.ClientID, ShouldEqual, control.CLIClientID)
+ So(br.Action, ShouldEqual, "scripts.delete.request")
+ e.write(typeBridgeResponse, req.RequestID, bridge.Response{OK: true, Result: json.RawMessage(`{"uuid":"x","deleted":true}`)})
+ out := <-ch
+ So(out.err, ShouldBeNil)
+ So(decodeCall(out.resp).OK, ShouldBeTrue)
+ })
+ })
+}
+
+func TestControlEnroll(t *testing.T) {
+ Convey("控制 enroll:打开接入窗口并返回展示形配对码", t, func() {
+ h := startTestServer(t)
+ base := h.httpBase()
+
+ resp, err := postControl(context.Background(), base, control.PathEnroll, testControlToken, "", struct{}{})
+ So(err, ShouldBeNil)
+ defer resp.Body.Close()
+ So(resp.StatusCode, ShouldEqual, http.StatusOK)
+ var res control.EnrollResult
+ So(json.NewDecoder(resp.Body).Decode(&res), ShouldBeNil)
+ So(res.Code, ShouldContainSubstring, "-")
+ })
+}
diff --git a/internal/daemon/controlapi/harness_test.go b/internal/daemon/controlapi/harness_test.go
new file mode 100644
index 0000000..69fba48
--- /dev/null
+++ b/internal/daemon/controlapi/harness_test.go
@@ -0,0 +1,209 @@
+package controlapi
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/google/uuid"
+ . "github.com/smartystreets/goconvey/convey"
+ "go.uber.org/zap"
+
+ "github.com/scriptscat/sctl/internal/client/control"
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+ "github.com/scriptscat/sctl/internal/daemon/bridge"
+ "github.com/scriptscat/sctl/internal/daemon/store"
+ "github.com/scriptscat/sctl/internal/pkg/protocol"
+)
+
+// 本包的测试跑真实全栈:bridge.Server + Handler 挂同一 listener,由 extClient 从 WS 面对拍。
+// 扩展侧一律按线上协议(docs/protocol.md)自己拼信封,不借 bridge 的未导出符号 —— 这样这些
+// 测试同时也在守护「daemon 对扩展呈现的样子」。
+const (
+ testControlToken = "test-control-token"
+ testVersion = "0.1.0"
+
+ typeAuthChallenge = "auth.challenge"
+ typeAuthResponse = "auth.response"
+ typeAuthOK = "auth.ok"
+ typeHello = "hello"
+ typeBridgeRequest = "bridge.request"
+ typeBridgeResponse = "bridge.response"
+ typeBridgeCancel = "bridge.cancel"
+
+ modeSession = "session"
+)
+
+type authChallengePayload struct {
+ NonceD string `json:"nonceD"`
+}
+
+type authResponsePayload struct {
+ Mode string `json:"mode"`
+ NonceE string `json:"nonceE"`
+ HMAC string `json:"hmac"`
+}
+
+type authOKPayload struct {
+ HMAC string `json:"hmac"`
+}
+
+// testHarness 承载一个运行中的 daemon(WS 面 + 控制 API)与用于对拍的密码学助手/存储。
+type testHarness struct {
+ srv *bridge.Server
+ crypto *auth.Crypto
+ keys *store.KeyStore
+ url string
+ proto *protocol.Protocol
+}
+
+func startTestServer(t *testing.T) *testHarness {
+ t.Helper()
+ p, err := protocol.Load()
+ So(err, ShouldBeNil)
+ dir := t.TempDir()
+ keys := store.NewKeyStore(filepath.Join(dir, "pairing.key"))
+ srv := bridge.NewServer(testVersion, p, keys, zap.NewNop())
+
+ mux := http.NewServeMux()
+ New(srv, testControlToken, zap.NewNop()).Register(mux)
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ So(err, ShouldBeNil)
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ go func() { _ = srv.Serve(ctx, ln, mux) }()
+
+ return &testHarness{
+ srv: srv,
+ crypto: auth.NewCrypto(p),
+ keys: keys,
+ url: "ws://" + ln.Addr().String() + "/",
+ proto: p,
+ }
+}
+
+// httpBase 从握手用的 ws:// url 推出控制 API 的 http:// 基址(两者同 listener)。
+func (h *testHarness) httpBase() string {
+ return strings.TrimSuffix(strings.Replace(h.url, "ws://", "http://", 1), "/")
+}
+
+// newKeyAndSave 生成并落盘长期密钥,返回它(供会话握手用)。
+func newKeyAndSave(h *testHarness) ([]byte, error) {
+ key, err := auth.NewLongTermKey()
+ if err != nil {
+ return nil, err
+ }
+ if err := h.keys.Save(key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+// extClient 模拟扩展侧的 WS client。
+type extClient struct {
+ ws *websocket.Conn
+}
+
+func dial(url string) *extClient {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ ws, _, err := websocket.Dial(ctx, url, nil)
+ So(err, ShouldBeNil)
+ return &extClient{ws: ws}
+}
+
+func (e *extClient) read() bridge.Envelope {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, data, err := e.ws.Read(ctx)
+ So(err, ShouldBeNil)
+ var env bridge.Envelope
+ So(json.Unmarshal(data, &env), ShouldBeNil)
+ return env
+}
+
+func (e *extClient) write(typ, requestID string, payload any) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ raw, err := json.Marshal(payload)
+ So(err, ShouldBeNil)
+ env := bridge.Envelope{V: 1, Type: typ, RequestID: requestID, Payload: raw}
+ So(wsjson.Write(ctx, e.ws, env), ShouldBeNil)
+}
+
+// doSessionHandshake 以给定长期密钥完成会话握手,并消费 hello。
+func (h *testHarness) doSessionHandshake(key []byte) *extClient {
+ e := dial(h.url)
+ challenge := e.read()
+ So(challenge.Type, ShouldEqual, typeAuthChallenge)
+ var cp authChallengePayload
+ So(json.Unmarshal(challenge.Payload, &cp), ShouldBeNil)
+
+ nonceE, _ := auth.RandomNonceHex(h.proto.Crypto.NonceBytes)
+ mac := h.crypto.ExtHMAC(auth.ModeSession, key, cp.NonceD, nonceE)
+ e.write(typeAuthResponse, uuid.NewString(), authResponsePayload{Mode: modeSession, NonceE: nonceE, HMAC: mac})
+
+ ok := e.read()
+ So(ok.Type, ShouldEqual, typeAuthOK)
+ var okp authOKPayload
+ So(json.Unmarshal(ok.Payload, &okp), ShouldBeNil)
+ So(h.crypto.VerifyDaemonHMAC(auth.ModeSession, key, cp.NonceD, nonceE, okp.HMAC), ShouldBeTrue)
+
+ hello := e.read()
+ So(hello.Type, ShouldEqual, typeHello)
+ return e
+}
+
+// postControl 发起一次控制 API 请求(可选控制令牌 / 客户端标签)。body 为 nil 时用 GET。
+func postControl(ctx context.Context, base, path, controlToken, clientLabel string, body any) (*http.Response, error) {
+ var r io.Reader
+ method := http.MethodGet
+ if body != nil {
+ raw, _ := json.Marshal(body)
+ r = bytes.NewReader(raw)
+ method = http.MethodPost
+ }
+ req, err := http.NewRequestWithContext(ctx, method, base+path, r)
+ if err != nil {
+ return nil, err
+ }
+ if controlToken != "" {
+ req.Header.Set(control.HeaderControlToken, controlToken)
+ }
+ if clientLabel != "" {
+ req.Header.Set(control.HeaderClientLabel, clientLabel)
+ }
+ return http.DefaultClient.Do(req)
+}
+
+type callOut struct {
+ resp *http.Response
+ err error
+}
+
+// goPostControl 在 goroutine 里发起(可能阻塞的)控制请求,把结果回传通道;断言留给测试 goroutine。
+func goPostControl(ctx context.Context, base, path, controlToken, clientLabel string, body any) <-chan callOut {
+ ch := make(chan callOut, 1)
+ go func() {
+ resp, err := postControl(ctx, base, path, controlToken, clientLabel, body)
+ ch <- callOut{resp, err}
+ }()
+ return ch
+}
+
+func decodeCall(resp *http.Response) control.CallResult {
+ defer resp.Body.Close()
+ var res control.CallResult
+ So(json.NewDecoder(resp.Body).Decode(&res), ShouldBeNil)
+ return res
+}
diff --git a/internal/daemon/ratelimit/ratelimit.go b/internal/daemon/ratelimit/ratelimit.go
new file mode 100644
index 0000000..a6df136
--- /dev/null
+++ b/internal/daemon/ratelimit/ratelimit.go
@@ -0,0 +1,49 @@
+// Package ratelimit 提供按 key 的滑动窗口限流,用于桥接的配对尝试与每客户端读写节流
+// (默认值见 docs/protocol.md §7:配对 5/分、每客户端读 60/分、写 10/分,均为实现可调值)。
+package ratelimit
+
+import (
+ "sync"
+ "time"
+)
+
+// Limiter 是按 key 的滑动窗口计数器:每个 key 在 window 内最多放行 limit 次。
+type Limiter struct {
+ mu sync.Mutex
+ limit int
+ window time.Duration
+ events map[string][]time.Time
+ clock func() time.Time
+}
+
+// NewLimiter 构造窗口 window 内上限 limit 次的限流器。
+func NewLimiter(limit int, window time.Duration) *Limiter {
+ return &Limiter{
+ limit: limit,
+ window: window,
+ events: make(map[string][]time.Time),
+ clock: time.Now,
+ }
+}
+
+// Allow 记录一次 key 的尝试:窗口内未超上限则记账放行,否则拒绝且不记账。
+func (l *Limiter) Allow(key string) bool {
+ now := l.clock()
+ cutoff := now.Add(-l.window)
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ kept := l.events[key][:0]
+ for _, t := range l.events[key] {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= l.limit {
+ l.events[key] = kept
+ return false
+ }
+ l.events[key] = append(kept, now)
+ return true
+}
diff --git a/internal/daemon/ratelimit/ratelimit_test.go b/internal/daemon/ratelimit/ratelimit_test.go
new file mode 100644
index 0000000..6d65a4f
--- /dev/null
+++ b/internal/daemon/ratelimit/ratelimit_test.go
@@ -0,0 +1,40 @@
+package ratelimit
+
+import (
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestLimiter(t *testing.T) {
+ Convey("滑动窗口限流", t, func() {
+ now := time.Unix(0, 0)
+ l := NewLimiter(3, time.Minute)
+ l.clock = func() time.Time { return now }
+
+ Convey("窗口内放行至上限,超出即拒绝", func() {
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeFalse)
+ })
+
+ Convey("不同 key 各自独立计数", func() {
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeFalse)
+ So(l.Allow("c2"), ShouldBeTrue)
+ })
+
+ Convey("窗口滑过后旧事件过期,重新放行", func() {
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeTrue)
+ So(l.Allow("c1"), ShouldBeFalse)
+ now = now.Add(61 * time.Second)
+ So(l.Allow("c1"), ShouldBeTrue)
+ })
+ })
+}
diff --git a/internal/daemon/store/keystore.go b/internal/daemon/store/keystore.go
new file mode 100644
index 0000000..82d5bf2
--- /dev/null
+++ b/internal/daemon/store/keystore.go
@@ -0,0 +1,48 @@
+// Package store 是守卫侧的持久化层(cago 分层里的 repository 角色):把扩展配对的长期
+// 共享密钥与 MCP 客户端授权记录以 0600 权限落到数据目录,全部写入走 fsutil 的原子替换。
+package store
+
+import (
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "strings"
+
+ "github.com/scriptscat/sctl/internal/pkg/fsutil"
+)
+
+// KeyStore 以 0600 权限持久化扩展配对的长期共享密钥 K(hex 文本,便于人工核查)。
+type KeyStore struct {
+ path string
+}
+
+// NewKeyStore 返回落盘在 path 的密钥存储。
+func NewKeyStore(path string) *KeyStore {
+ return &KeyStore{path: path}
+}
+
+// Load 读取长期密钥;文件不存在时返回 ok=false 而非错误。
+func (s *KeyStore) Load() (key []byte, ok bool, err error) {
+ raw, err := os.ReadFile(s.path)
+ if os.IsNotExist(err) {
+ return nil, false, nil
+ }
+ if err != nil {
+ return nil, false, fmt.Errorf("读取密钥文件: %w", err)
+ }
+ k, err := hex.DecodeString(strings.TrimSpace(string(raw)))
+ if err != nil {
+ return nil, false, fmt.Errorf("解析密钥文件: %w", err)
+ }
+ return k, true, nil
+}
+
+// Save 原子写入长期密钥,文件与目录权限分别为 0600 / 0700。重新配对时覆盖旧密钥。
+func (s *KeyStore) Save(key []byte) error {
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
+ return fmt.Errorf("创建密钥目录: %w", err)
+ }
+ return fsutil.WriteFileAtomic(s.path, []byte(hex.EncodeToString(key)), 0o600)
+}
diff --git a/internal/daemon/store/store_test.go b/internal/daemon/store/store_test.go
new file mode 100644
index 0000000..03e9d6a
--- /dev/null
+++ b/internal/daemon/store/store_test.go
@@ -0,0 +1,47 @@
+package store
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/scriptscat/sctl/internal/daemon/auth"
+)
+
+func TestKeyStore(t *testing.T) {
+ Convey("长期密钥落盘", t, func() {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "pairing.key")
+ ks := NewKeyStore(path)
+
+ Convey("初始不存在", func() {
+ _, ok, err := ks.Load()
+ So(err, ShouldBeNil)
+ So(ok, ShouldBeFalse)
+ })
+
+ Convey("保存后可原样读回,文件权限 0600", func() {
+ k, _ := auth.NewLongTermKey()
+ So(ks.Save(k), ShouldBeNil)
+
+ got, ok, err := ks.Load()
+ So(err, ShouldBeNil)
+ So(ok, ShouldBeTrue)
+ So(got, ShouldResemble, k)
+
+ info, _ := os.Stat(path)
+ So(info.Mode().Perm(), ShouldEqual, os.FileMode(0o600))
+ })
+
+ Convey("重新接入即替换:再次保存覆盖旧密钥", func() {
+ k1, _ := auth.NewLongTermKey()
+ k2, _ := auth.NewLongTermKey()
+ So(ks.Save(k1), ShouldBeNil)
+ So(ks.Save(k2), ShouldBeNil)
+ got, _, _ := ks.Load()
+ So(got, ShouldResemble, k2)
+ })
+ })
+}
diff --git a/internal/pkg/audit/audit.go b/internal/pkg/audit/audit.go
new file mode 100644
index 0000000..52809bc
--- /dev/null
+++ b/internal/pkg/audit/audit.go
@@ -0,0 +1,129 @@
+// Package audit 记录守卫侧的安全事件。
+//
+// 审计的权威存储在扩展侧(McpAuditDAO 环形缓冲 + 设置页 UI),这里只补扩展**看不到**的那一段:
+// 握手失败、未授权连入、限流拦截、配对失败——这些事件在扩展建立会话之前就被挡掉,不会产生任何
+// 扩展侧记录,缺了它们无法对「网页直连 8643」这类尝试取证(docs/threat-model.md §2)。
+//
+// 事件只有固定字段(类型 / 客户端标识 / 原因分类),没有承载任意载荷的出口,以此保证
+// docs/threat-model.md §5 的铁律:token 原文、脚本源码、含凭据的 URL 永不进入审计与日志。
+package audit
+
+import (
+ "sort"
+ "sync"
+ "time"
+
+ "go.uber.org/zap"
+)
+
+// Type 是安全事件类型。
+type Type string
+
+const (
+ TypeHandshakeOK Type = "handshake.ok"
+ TypeHandshakeFailed Type = "handshake.failed"
+ TypeOriginRejected Type = "origin.rejected"
+ TypePairingRateLimited Type = "pairing.rate_limited"
+ TypePairingFailed Type = "pairing.failed"
+ TypeRequestRateLimited Type = "request.rate_limited"
+)
+
+// Reason 是失败原因分类。刻意用固定枚举而非原始错误串,避免错误信息把敏感内容带进审计。
+type Reason string
+
+const (
+ ReasonHMACMismatch Reason = "hmac_mismatch"
+ ReasonTimeout Reason = "timeout"
+ ReasonProtocol Reason = "protocol_violation"
+ ReasonPairExpired Reason = "pairing_expired"
+ ReasonPairExhausted Reason = "pairing_attempts_exhausted"
+)
+
+// Event 是一条安全事件。字段集合是封闭的(见包注释)。
+type Event struct {
+ At time.Time `json:"at"`
+ Type Type `json:"type"`
+ Client string `json:"client,omitempty"`
+ Reason Reason `json:"reason,omitempty"`
+}
+
+// TypeCount 是按类型聚合的事件计数。
+type TypeCount struct {
+ Type Type `json:"type"`
+ Count int `json:"count"`
+}
+
+// Recorder 是固定容量的事件环形缓冲,同时把每条事件结构化输出到日志。
+// 可查询的这份只驻内存(daemon 重启即清空);日志那份随 /logs 落盘,
+// 两个出口都靠上面封闭的字段集合保证不含敏感内容。
+type Recorder struct {
+ mu sync.Mutex
+ cap int
+ events []Event
+ log *zap.Logger
+ clock func() time.Time
+}
+
+// NewRecorder 构造容量为 capacity 的记录器。容量对齐扩展侧 MCP_AUDIT_RING_BUFFER_SIZE 的约定。
+func NewRecorder(capacity int, log *zap.Logger) *Recorder {
+ if log == nil {
+ log = zap.NewNop()
+ }
+ return &Recorder{
+ cap: capacity,
+ events: make([]Event, 0, capacity),
+ log: log,
+ clock: time.Now,
+ }
+}
+
+// Record 追加一条事件,超出容量时淘汰最旧的一条。At 为零值时由记录器打上时间戳。
+func (r *Recorder) Record(ev Event) {
+ if ev.At.IsZero() {
+ ev.At = r.clock()
+ }
+
+ r.mu.Lock()
+ if len(r.events) >= r.cap {
+ r.events = append(r.events[:0], r.events[len(r.events)-r.cap+1:]...)
+ }
+ r.events = append(r.events, ev)
+ r.mu.Unlock()
+
+ r.log.Warn("安全事件",
+ zap.String("event", string(ev.Type)),
+ zap.String("client", ev.Client),
+ zap.String("reason", string(ev.Reason)),
+ )
+}
+
+// Snapshot 返回按发生顺序排列的事件副本。
+func (r *Recorder) Snapshot() []Event {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]Event, len(r.events))
+ copy(out, r.events)
+ return out
+}
+
+// Summarize 按类型聚合计数,数量相同时按类型名排序保证输出稳定。
+func Summarize(events []Event) []TypeCount {
+ if len(events) == 0 {
+ return nil
+ }
+ counts := make(map[Type]int, len(events))
+ for _, ev := range events {
+ counts[ev.Type]++
+ }
+ out := make([]TypeCount, 0, len(counts))
+ for t, c := range counts {
+ out = append(out, TypeCount{Type: t, Count: c})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Count != out[j].Count {
+ return out[i].Count > out[j].Count
+ }
+ return out[i].Type < out[j].Type
+ })
+ return out
+}
diff --git a/internal/pkg/audit/audit_test.go b/internal/pkg/audit/audit_test.go
new file mode 100644
index 0000000..280c180
--- /dev/null
+++ b/internal/pkg/audit/audit_test.go
@@ -0,0 +1,87 @@
+package audit
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestRecorder(t *testing.T) {
+ Convey("守卫侧安全审计", t, func() {
+ now := time.Unix(0, 0)
+ r := NewRecorder(3, nil)
+ r.clock = func() time.Time { return now }
+
+ Convey("记录的事件按发生顺序快照返回", func() {
+ r.Record(Event{Type: TypeHandshakeFailed, Reason: ReasonHMACMismatch})
+ now = now.Add(time.Second)
+ r.Record(Event{Type: TypeHandshakeOK})
+
+ got := r.Snapshot()
+ So(got, ShouldHaveLength, 2)
+ So(got[0].Type, ShouldEqual, TypeHandshakeFailed)
+ So(got[1].Type, ShouldEqual, TypeHandshakeOK)
+ })
+
+ Convey("未带时间的事件由记录器打上时间戳", func() {
+ r.Record(Event{Type: TypeHandshakeOK})
+ So(r.Snapshot()[0].At, ShouldEqual, now)
+ })
+
+ Convey("超出容量时淘汰最旧事件", func() {
+ r.Record(Event{Type: TypeHandshakeFailed, Reason: ReasonHMACMismatch})
+ r.Record(Event{Type: TypePairingRateLimited})
+ r.Record(Event{Type: TypeRequestRateLimited, Client: "c1"})
+ r.Record(Event{Type: TypeHandshakeOK})
+
+ got := r.Snapshot()
+ So(got, ShouldHaveLength, 3)
+ So(got[0].Type, ShouldEqual, TypePairingRateLimited)
+ So(got[2].Type, ShouldEqual, TypeHandshakeOK)
+ })
+
+ Convey("快照是副本,调用方改动不影响记录器", func() {
+ r.Record(Event{Type: TypeHandshakeOK})
+ snap := r.Snapshot()
+ snap[0].Type = TypeHandshakeFailed
+ So(r.Snapshot()[0].Type, ShouldEqual, TypeHandshakeOK)
+ })
+
+ Convey("并发记录不丢事件且不竞争", func() {
+ r := NewRecorder(100, nil)
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ r.Record(Event{Type: TypeRequestRateLimited, Client: "c1"})
+ }()
+ }
+ wg.Wait()
+ So(r.Snapshot(), ShouldHaveLength, 50)
+ })
+ })
+}
+
+func TestSummarize(t *testing.T) {
+ Convey("事件摘要按类型计数", t, func() {
+ Convey("空事件集摘要为空", func() {
+ So(Summarize(nil), ShouldBeEmpty)
+ })
+
+ Convey("同类型合并计数,按数量降序", func() {
+ got := Summarize([]Event{
+ {Type: TypeHandshakeFailed},
+ {Type: TypeRequestRateLimited},
+ {Type: TypeHandshakeFailed},
+ })
+ So(got, ShouldHaveLength, 2)
+ So(got[0].Type, ShouldEqual, TypeHandshakeFailed)
+ So(got[0].Count, ShouldEqual, 2)
+ So(got[1].Type, ShouldEqual, TypeRequestRateLimited)
+ So(got[1].Count, ShouldEqual, 1)
+ })
+ })
+}
diff --git a/internal/pkg/fsutil/atomic.go b/internal/pkg/fsutil/atomic.go
new file mode 100644
index 0000000..10fe3c6
--- /dev/null
+++ b/internal/pkg/fsutil/atomic.go
@@ -0,0 +1,36 @@
+// Package fsutil 提供跨包复用的文件系统小工具。
+package fsutil
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// WriteFileAtomic 以给定权限原子写入文件:先写同目录临时文件(0600 创建),再 rename 覆盖,
+// 避免半写状态或短暂的过宽权限窗口。调用方需先确保目标目录存在。
+func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
+ if err != nil {
+ return fmt.Errorf("创建临时文件: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+
+ if err := tmp.Chmod(perm); err != nil {
+ tmp.Close()
+ return fmt.Errorf("设置临时文件权限: %w", err)
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return fmt.Errorf("写入临时文件: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("关闭临时文件: %w", err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ return fmt.Errorf("替换目标文件: %w", err)
+ }
+ return nil
+}
diff --git a/internal/pkg/logging/logging.go b/internal/pkg/logging/logging.go
new file mode 100644
index 0000000..d2997d7
--- /dev/null
+++ b/internal/pkg/logging/logging.go
@@ -0,0 +1,54 @@
+// Package logging 为所有 sctl 子命令初始化统一的 zap 日志。
+//
+// 关键约束:日志绝不写 stdout —— `sctl mcp` 以 stdout 承载 MCP 协议帧,CLI 动词以
+// stdout 输出 `--json` 结果,任何日志混入都会破坏它们。cago 的 component.Core() 默认
+// 把日志写 stdout,因此 sctl 不用它(与 opskat/opsctl 一致),改由本包用 logger.New +
+// NewFileCore 构建 logger 并 logger.SetLogger 注入 cago,后续代码照常用 logger.Ctx(ctx)。
+//
+// 出口:持久文件日志(/logs/sctl.log 收 level+,sctl.err.log 只收 error+)
+// 外加 stderr console core —— sctl serve/pair 是前台命令,stderr 让用户实时看到日志,
+// 且 stderr 与 stdout 协议/JSON 输出互不干扰。
+package logging
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/cago-frame/cago/pkg/logger"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+
+ "github.com/scriptscat/sctl/internal/pkg/paths"
+)
+
+// Setup 构建并注入全局 logger。level 取 debug/info/warn/error。文件日志目录不可写时
+// 静默降级为仅 stderr(不因日志目录问题阻断命令)。返回构建出的 logger 供直接使用。
+func Setup(level string) *zap.Logger {
+ lvl := logger.ToLevel(level)
+
+ cores := []zapcore.Core{stderrCore(lvl)}
+ if fileCores, ok := fileCores(lvl); ok {
+ cores = append(cores, fileCores...)
+ }
+
+ l := zap.New(zapcore.NewTee(cores...), zap.AddCaller())
+ logger.SetLogger(l)
+ return l
+}
+
+func stderrCore(lvl zapcore.Level) zapcore.Core {
+ cfg := zap.NewDevelopmentEncoderConfig()
+ cfg.EncodeTime = zapcore.ISO8601TimeEncoder
+ return zapcore.NewCore(zapcore.NewConsoleEncoder(cfg), zapcore.Lock(os.Stderr), lvl)
+}
+
+func fileCores(lvl zapcore.Level) ([]zapcore.Core, bool) {
+ dir := paths.LogsDir()
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return nil, false
+ }
+ return []zapcore.Core{
+ logger.NewFileCore(lvl, filepath.Join(dir, "sctl.log")),
+ logger.NewFileCore(logger.ToLevel("error"), filepath.Join(dir, "sctl.err.log")),
+ }, true
+}
diff --git a/internal/pkg/paths/paths.go b/internal/pkg/paths/paths.go
new file mode 100644
index 0000000..35f961d
--- /dev/null
+++ b/internal/pkg/paths/paths.go
@@ -0,0 +1,47 @@
+// Package paths 解析 sctl 的数据目录与派生路径(日志、配对密钥等)。
+// 平台约定对齐 opskat/opsctl:各平台的用户级应用数据目录,可用 SCTL_DATA_DIR 覆盖。
+package paths
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+// DataDir 返回 sctl 的数据目录。SCTL_DATA_DIR 优先,否则用平台默认目录。
+func DataDir() string {
+ if dir := os.Getenv("SCTL_DATA_DIR"); dir != "" {
+ return dir
+ }
+ switch runtime.GOOS {
+ case "darwin":
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, "Library", "Application Support", "sctl")
+ case "windows":
+ localAppData := os.Getenv("LOCALAPPDATA")
+ if localAppData == "" {
+ home, _ := os.UserHomeDir()
+ localAppData = filepath.Join(home, "AppData", "Local")
+ }
+ return filepath.Join(localAppData, "sctl")
+ default:
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".config", "sctl")
+ }
+}
+
+// LogsDir 返回日志目录 /logs。
+func LogsDir() string {
+ return filepath.Join(DataDir(), "logs")
+}
+
+// KeyFile 返回扩展配对长期密钥的落盘路径(0600)。
+func KeyFile() string {
+ return filepath.Join(DataDir(), "pairing.key")
+}
+
+// ControlTokenFile 返回本机内部控制通道凭据的落盘路径(0600)。daemon 绑定端口后写入,
+// 本机同用户的前端(sctl mcp / CLI 动词)读取后作为 loopback 控制 API 的鉴权凭据。
+func ControlTokenFile() string {
+ return filepath.Join(DataDir(), "control.token")
+}
diff --git a/internal/pkg/protocol/protocol.go b/internal/pkg/protocol/protocol.go
new file mode 100644
index 0000000..e72b848
--- /dev/null
+++ b/internal/pkg/protocol/protocol.go
@@ -0,0 +1,97 @@
+// Package protocol 解析内嵌的 protocol.json —— 与 ScriptCat 扩展共用的桥接常量单一事实源。
+// 协议语义见 docs/protocol.md,两者冲突以 json 为准。
+package protocol
+
+import (
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "sync"
+)
+
+// protocolJSON 是桥接协议常量文件。权威副本在扩展仓库,CI 的 protocol-drift job
+// 逐字节比对本镜像。
+//
+//go:embed protocol.json
+var protocolJSON []byte
+
+type Protocol struct {
+ ProtocolVersion int `json:"protocolVersion"`
+ Transport Transport `json:"transport"`
+ Versions Versions `json:"versions"`
+ EnvelopeTypes EnvelopeTypes `json:"envelopeTypes"`
+ Scopes []string `json:"scopes"`
+ Actions map[string]Action `json:"actions"`
+ ErrorCodes []string `json:"errorCodes"`
+ Crypto Crypto `json:"crypto"`
+ Limits Limits `json:"limits"`
+ PairingCode PairingCode `json:"pairingCode"`
+}
+
+type Transport struct {
+ DefaultURL string `json:"defaultUrl"`
+ DefaultPort int `json:"defaultPort"`
+ Frame string `json:"frame"`
+}
+
+// EnvelopeTypes groups the wire envelope vocabulary into the protocol's two layers: session frames
+// carry the crypto handshake, version/liveness and session lifecycle (Layer 1); bridge frames are
+// the capability RPC tunnelled over the channel (Layer 2). See protocol.json "$comment.layering".
+type EnvelopeTypes struct {
+ Session []string `json:"session"`
+ Bridge []string `json:"bridge"`
+}
+
+type Versions struct {
+ MinDaemonVersion string `json:"minDaemonVersion"`
+}
+
+type Action struct {
+ Scope string `json:"scope"`
+ Write bool `json:"write"`
+ Blocking string `json:"blocking,omitempty"`
+}
+
+type Crypto struct {
+ MAC string `json:"mac"`
+ KDF string `json:"kdf"`
+ AEAD string `json:"aead"`
+ NonceBytes int `json:"nonceBytes"`
+ Encoding string `json:"encoding"`
+ Context map[string]string `json:"context"`
+}
+
+type Limits struct {
+ AuthTimeoutMs int `json:"authTimeoutMs"`
+ WriteDecisionTtlMs int `json:"writeDecisionTtlMs"`
+ ExtPairingCodeTtlMs int `json:"extPairingCodeTtlMs"`
+ McpPairingTtlMs int `json:"mcpPairingTtlMs"`
+ MaxSourceBytes int `json:"maxSourceBytes"`
+ MaxFrameBytes int `json:"maxFrameBytes"`
+ PingIntervalMs int `json:"pingIntervalMs"`
+}
+
+type PairingCode struct {
+ Alphabet string `json:"alphabet"`
+ Length int `json:"length"`
+ Display string `json:"display"`
+}
+
+var (
+ once sync.Once
+ parsed *Protocol
+ parseErr error
+)
+
+// Load 解析内嵌的 protocol.json,只解析一次。
+func Load() (*Protocol, error) {
+ once.Do(func() {
+ p := &Protocol{}
+ if err := json.Unmarshal(protocolJSON, p); err != nil {
+ parseErr = fmt.Errorf("parse embedded protocol.json: %w", err)
+ return
+ }
+ parsed = p
+ })
+ return parsed, parseErr
+}
diff --git a/internal/pkg/protocol/protocol.json b/internal/pkg/protocol/protocol.json
new file mode 100644
index 0000000..47fb5fa
--- /dev/null
+++ b/internal/pkg/protocol/protocol.json
@@ -0,0 +1,77 @@
+{
+ "$comment": "ScriptCat bridge protocol constants — single source of truth. The ScriptCat extension repo holds the authoritative copy; scriptscat/sctl mirrors it byte-for-byte. Both sides' conformance tests diff against this file.",
+ "protocolVersion": 1,
+ "transport": {
+ "defaultUrl": "ws://127.0.0.1:8643",
+ "defaultPort": 8643,
+ "frame": "json-text"
+ },
+ "versions": {
+ "minDaemonVersion": "0.1.0"
+ },
+ "$comment.layering": "Two-layer protocol. session = Layer 1: the crypto handshake, version/liveness and session lifecycle — security-critical, stays custom, the half a standard RPC (JSON-RPC) cannot express. bridge = Layer 2: the capability RPC tunnelled over the channel; bridge.request.payload is opaque to the session layer, which lets the two layers evolve independently. Grouped by function, not name prefix — bridge.shutdown ends the whole session, so it lives under session.",
+ "envelopeTypes": {
+ "session": ["auth.challenge", "auth.response", "auth.ok", "hello", "ping", "pong", "bridge.shutdown"],
+ "bridge": ["bridge.request", "bridge.response", "bridge.cancel"]
+ },
+ "scopes": [
+ "scripts:list",
+ "scripts:metadata:read",
+ "scripts:source:read",
+ "scripts:install:request",
+ "scripts:toggle:request",
+ "scripts:delete:request"
+ ],
+ "actions": {
+ "scripts.list": { "scope": "scripts:list", "write": false },
+ "scripts.metadata.get": { "scope": "scripts:metadata:read", "write": false },
+ "scripts.source.get": { "scope": "scripts:source:read", "write": false, "blocking": "disclosure" },
+ "scripts.install.request": { "scope": "scripts:install:request", "write": true, "blocking": "approval" },
+ "scripts.toggle.request": { "scope": "scripts:toggle:request", "write": true, "blocking": "approval" },
+ "scripts.delete.request": { "scope": "scripts:delete:request", "write": true, "blocking": "approval" }
+ },
+ "errorCodes": [
+ "INVALID_REQUEST",
+ "UNAUTHENTICATED",
+ "INSUFFICIENT_SCOPE",
+ "WRITE_MODE_DISABLED",
+ "USER_APPROVAL_REQUIRED",
+ "USER_REJECTED",
+ "OPERATION_EXPIRED",
+ "CONFLICT",
+ "NOT_FOUND",
+ "RATE_LIMITED",
+ "PAYLOAD_TOO_LARGE",
+ "INTERNAL_ERROR"
+ ],
+ "crypto": {
+ "mac": "HMAC-SHA-256",
+ "kdf": "HKDF-SHA-256",
+ "aead": "AES-256-GCM",
+ "nonceBytes": 32,
+ "encoding": "lowercase-hex",
+ "context": {
+ "sessionExt": "scriptcat-bridge-v1/ext",
+ "sessionDaemon": "scriptcat-bridge-v1/daemon",
+ "pairExt": "scriptcat-bridge-v1/pair-ext",
+ "pairDaemon": "scriptcat-bridge-v1/pair-daemon",
+ "pairKdfSalt": "scriptcat-bridge-v1/pair-salt",
+ "pairKdfInfoMac": "scriptcat-bridge-v1/pair-mac",
+ "pairKdfInfoEnc": "scriptcat-bridge-v1/pair-enc"
+ }
+ },
+ "limits": {
+ "authTimeoutMs": 5000,
+ "writeDecisionTtlMs": 300000,
+ "extPairingCodeTtlMs": 120000,
+ "mcpPairingTtlMs": 120000,
+ "maxSourceBytes": 2097152,
+ "maxFrameBytes": 4194304,
+ "pingIntervalMs": 30000
+ },
+ "pairingCode": {
+ "alphabet": "crockford-base32",
+ "length": 8,
+ "display": "XXXX-XXXX"
+ }
+}
diff --git a/internal/pkg/protocol/protocol_test.go b/internal/pkg/protocol/protocol_test.go
new file mode 100644
index 0000000..7c7a69b
--- /dev/null
+++ b/internal/pkg/protocol/protocol_test.go
@@ -0,0 +1,34 @@
+package protocol
+
+import "testing"
+
+func TestLoad(t *testing.T) {
+ p, err := Load()
+ if err != nil {
+ t.Fatalf("解析内嵌 protocol.json 失败: %v", err)
+ }
+ t.Run("协议版本为 1", func(t *testing.T) {
+ if p.ProtocolVersion != 1 {
+ t.Fatalf("protocolVersion = %d, 期望 1", p.ProtocolVersion)
+ }
+ })
+ t.Run("所有 action 映射到合法 scope", func(t *testing.T) {
+ scopes := map[string]bool{}
+ for _, s := range p.Scopes {
+ scopes[s] = true
+ }
+ for name, a := range p.Actions {
+ if !scopes[a.Scope] {
+ t.Fatalf("action %s 映射到未知 scope %s", name, a.Scope)
+ }
+ }
+ })
+ t.Run("默认端口 8643 且仅 loopback URL", func(t *testing.T) {
+ if p.Transport.DefaultPort != 8643 {
+ t.Fatalf("defaultPort = %d, 期望 8643", p.Transport.DefaultPort)
+ }
+ if p.Transport.DefaultURL != "ws://127.0.0.1:8643" {
+ t.Fatalf("defaultUrl = %q", p.Transport.DefaultURL)
+ }
+ })
+}