Skip to content

Commit 1d81046

Browse files
author
SqlRush
committed
Write gated bridge manifests
1 parent c21b125 commit 1d81046

7 files changed

Lines changed: 251 additions & 2 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1456,7 +1456,7 @@ M7 补充:terminal input parser 和 configurable keybinding name parser 现在
14561456
- Chrome/computer-use/voice/native integrations。
14571457
- enterprise/gated/platform-specific behavior。
14581458

1459-
当前状态:已新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced``/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤;未启用时仍不注册或泄露 gated 工具 schema。实际 repl bridge、session websocket/direct connect、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
1459+
当前状态:已新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced``/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据;未启用时仍不注册或泄露 gated 工具 schema。实际 repl bridge server、session websocket/direct connect、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
14601460

14611461
## Recommended Next Steps
14621462

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ test/parity/ # golden tests against TS/official behavior
745745
- 每个 gated feature 独立开关测试。
746746
- 不启用 feature 时二进制行为和可见 schema 不泄露 gated 工具/命令。
747747

748-
- 本轮补充:新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced``/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤;未启用时仍不注册或泄露 gated 工具 schema。实际 repl bridge、session websocket/direct connect、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
748+
- 本轮补充:新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced``/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据;未启用时仍不注册或泄露 gated 工具 schema。实际 repl bridge server、session websocket/direct connect、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
749749

750750
### M12: Parity hardening
751751

internal/bridge/manifest.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package bridge
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
"ccgo/internal/commands"
11+
"ccgo/internal/contracts"
12+
"ccgo/internal/platform"
13+
)
14+
15+
const manifestFileName = "bridge-manifest.json"
16+
17+
type Manifest struct {
18+
SessionID contracts.ID `json:"session_id,omitempty"`
19+
WorkingDirectory string `json:"working_directory,omitempty"`
20+
GeneratedAt string `json:"generated_at"`
21+
Commands []Command `json:"commands,omitempty"`
22+
}
23+
24+
type Command struct {
25+
Name string `json:"name"`
26+
DisplayName string `json:"display_name,omitempty"`
27+
Type contracts.CommandType `json:"type"`
28+
Source contracts.CommandSource `json:"source,omitempty"`
29+
LoadedFrom string `json:"loaded_from,omitempty"`
30+
Aliases []string `json:"aliases,omitempty"`
31+
ArgumentHint string `json:"argument_hint,omitempty"`
32+
SupportsNonInteractive bool `json:"supports_non_interactive,omitempty"`
33+
Immediate bool `json:"immediate,omitempty"`
34+
}
35+
36+
func SessionManifestPath(sessionPath string, sessionID contracts.ID) string {
37+
if sessionPath == "" || sessionID == "" {
38+
return ""
39+
}
40+
return filepath.Join(filepath.Dir(sessionPath), string(sessionID), manifestFileName)
41+
}
42+
43+
func BuildManifest(sessionID contracts.ID, cwd string, registry commands.Registry) Manifest {
44+
manifest := Manifest{
45+
SessionID: sessionID,
46+
WorkingDirectory: cwd,
47+
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
48+
}
49+
for _, command := range registry.Visible() {
50+
if !commands.IsBridgeSafeCommand(command) {
51+
continue
52+
}
53+
manifest.Commands = append(manifest.Commands, Command{
54+
Name: command.Name,
55+
DisplayName: command.DisplayName,
56+
Type: command.Type,
57+
Source: command.Source,
58+
LoadedFrom: command.LoadedFrom,
59+
Aliases: append([]string(nil), command.Aliases...),
60+
ArgumentHint: command.ArgumentHint,
61+
SupportsNonInteractive: command.SupportsNonInteractive,
62+
Immediate: command.Immediate,
63+
})
64+
}
65+
return manifest
66+
}
67+
68+
func BuildManifestFromSettings(sessionID contracts.ID, cwd string, settings contracts.Settings) Manifest {
69+
return BuildManifest(sessionID, cwd, commands.Load(commands.Options{CWD: cwd, Settings: settings}))
70+
}
71+
72+
func WriteManifest(path string, manifest Manifest) error {
73+
if path == "" {
74+
return os.ErrInvalid
75+
}
76+
if manifest.GeneratedAt == "" {
77+
manifest.GeneratedAt = time.Now().UTC().Format(time.RFC3339Nano)
78+
}
79+
data, err := json.MarshalIndent(manifest, "", " ")
80+
if err != nil {
81+
return err
82+
}
83+
data = append(data, '\n')
84+
return platform.AtomicWriteFile(path, data, 0o644)
85+
}
86+
87+
func LoadManifest(path string) (Manifest, error) {
88+
if path == "" {
89+
return Manifest{}, os.ErrInvalid
90+
}
91+
data, err := os.ReadFile(path)
92+
if errors.Is(err, os.ErrNotExist) {
93+
return Manifest{}, nil
94+
}
95+
if err != nil {
96+
return Manifest{}, err
97+
}
98+
var manifest Manifest
99+
if err := json.Unmarshal(data, &manifest); err != nil {
100+
return Manifest{}, err
101+
}
102+
return manifest, nil
103+
}

internal/bridge/manifest_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package bridge
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"ccgo/internal/commands"
8+
"ccgo/internal/contracts"
9+
)
10+
11+
func TestSessionManifestPath(t *testing.T) {
12+
got := SessionManifestPath(filepath.Join("tmp", "sessions", "session.jsonl"), "sess_1")
13+
want := filepath.Join("tmp", "sessions", "sess_1", manifestFileName)
14+
if got != want {
15+
t.Fatalf("SessionManifestPath() = %q, want %q", got, want)
16+
}
17+
if got := SessionManifestPath("", "sess_1"); got != "" {
18+
t.Fatalf("empty transcript path = %q, want empty", got)
19+
}
20+
if got := SessionManifestPath("session.jsonl", ""); got != "" {
21+
t.Fatalf("empty session id = %q, want empty", got)
22+
}
23+
}
24+
25+
func TestBuildManifestIncludesOnlyBridgeSafeCommands(t *testing.T) {
26+
registry := commands.FromSources(commands.Sources{Builtins: []contracts.Command{
27+
{Name: "ask", Type: contracts.CommandPrompt, Source: contracts.CommandSourceSkills, LoadedFrom: "skills", Aliases: []string{"question"}},
28+
{Name: "compact", Type: contracts.CommandLocal, Source: contracts.CommandSourceBuiltin, SupportsNonInteractive: true},
29+
{Name: "status", Type: contracts.CommandLocalJSX, Source: contracts.CommandSourceBuiltin},
30+
{Name: "model", Type: contracts.CommandLocal, Source: contracts.CommandSourceBuiltin},
31+
}})
32+
manifest := BuildManifest("sess_bridge", "/work", registry)
33+
if manifest.SessionID != "sess_bridge" || manifest.WorkingDirectory != "/work" || manifest.GeneratedAt == "" {
34+
t.Fatalf("manifest metadata = %#v", manifest)
35+
}
36+
if len(manifest.Commands) != 2 {
37+
t.Fatalf("commands = %#v", manifest.Commands)
38+
}
39+
if manifest.Commands[0].Name != "ask" || manifest.Commands[1].Name != "compact" {
40+
t.Fatalf("commands = %#v", manifest.Commands)
41+
}
42+
if len(manifest.Commands[0].Aliases) != 1 || manifest.Commands[0].Aliases[0] != "question" {
43+
t.Fatalf("aliases = %#v", manifest.Commands[0].Aliases)
44+
}
45+
}
46+
47+
func TestWriteAndLoadManifest(t *testing.T) {
48+
path := filepath.Join(t.TempDir(), "sess_bridge", manifestFileName)
49+
input := Manifest{
50+
SessionID: "sess_bridge",
51+
WorkingDirectory: "/work",
52+
Commands: []Command{{Name: "compact", Type: contracts.CommandLocal}},
53+
}
54+
if err := WriteManifest(path, input); err != nil {
55+
t.Fatal(err)
56+
}
57+
loaded, err := LoadManifest(path)
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if loaded.SessionID != "sess_bridge" || loaded.GeneratedAt == "" || len(loaded.Commands) != 1 || loaded.Commands[0].Name != "compact" {
62+
t.Fatalf("loaded manifest = %#v", loaded)
63+
}
64+
}

internal/conversation/bridge.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package conversation
2+
3+
import bridgepkg "ccgo/internal/bridge"
4+
5+
func (r Runner) maybeWriteBridgeManifest() {
6+
settings := r.mergedSettings()
7+
if settings.Advanced == nil || !advancedBoolEnabled(settings.Advanced.Bridge) {
8+
return
9+
}
10+
path := bridgepkg.SessionManifestPath(r.SessionPath, r.SessionID)
11+
if path == "" {
12+
return
13+
}
14+
manifest := bridgepkg.BuildManifestFromSettings(r.SessionID, r.WorkingDirectory, settings)
15+
_ = bridgepkg.WriteManifest(path, manifest)
16+
}

internal/conversation/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func (r *Runner) RunTurn(ctx context.Context, history []contracts.Message, user
3535
if r.Client == nil {
3636
return Result{}, fmt.Errorf("conversation runner missing client")
3737
}
38+
r.maybeWriteBridgeManifest()
3839
persistentModel := r.Model
3940
if user.Type == "" {
4041
user.Type = contracts.MessageUser

internal/conversation/run_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"ccgo/internal/api/anthropic"
16+
bridgepkg "ccgo/internal/bridge"
1617
"ccgo/internal/commands"
1718
compactpkg "ccgo/internal/compact"
1819
"ccgo/internal/contracts"
@@ -1742,6 +1743,61 @@ func TestRunnerRecordsGatedTelemetrySummaries(t *testing.T) {
17421743
}
17431744
}
17441745

1746+
func TestRunnerBridgeManifestDisabledByDefault(t *testing.T) {
1747+
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
1748+
client := &fakeClient{}
1749+
dir := t.TempDir()
1750+
transcriptPath := filepath.Join(dir, "session.jsonl")
1751+
runner := Runner{
1752+
Client: client,
1753+
Model: "sonnet",
1754+
SessionID: "sess_bridge_disabled",
1755+
SessionPath: transcriptPath,
1756+
WorkingDirectory: dir,
1757+
}
1758+
if _, err := runner.RunTurn(context.Background(), nil, messages.UserText("/status")); err != nil {
1759+
t.Fatal(err)
1760+
}
1761+
path := bridgepkg.SessionManifestPath(transcriptPath, "sess_bridge_disabled")
1762+
if _, err := os.Stat(path); !os.IsNotExist(err) {
1763+
t.Fatalf("bridge manifest exists with disabled bridge: %v", err)
1764+
}
1765+
}
1766+
1767+
func TestRunnerWritesGatedBridgeManifest(t *testing.T) {
1768+
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
1769+
client := &fakeClient{}
1770+
dir := t.TempDir()
1771+
transcriptPath := filepath.Join(dir, "session.jsonl")
1772+
bridgeEnabled := true
1773+
runner := Runner{
1774+
Client: client,
1775+
Model: "sonnet",
1776+
SessionID: "sess_bridge",
1777+
SessionPath: transcriptPath,
1778+
WorkingDirectory: dir,
1779+
MCP: &MCPConfig{UserSettings: contracts.Settings{
1780+
Advanced: &contracts.AdvancedSetting{Bridge: &bridgeEnabled},
1781+
}},
1782+
}
1783+
if _, err := runner.RunTurn(context.Background(), nil, messages.UserText("/status")); err != nil {
1784+
t.Fatal(err)
1785+
}
1786+
manifest, err := bridgepkg.LoadManifest(bridgepkg.SessionManifestPath(transcriptPath, "sess_bridge"))
1787+
if err != nil {
1788+
t.Fatal(err)
1789+
}
1790+
if manifest.SessionID != "sess_bridge" || manifest.WorkingDirectory != dir || manifest.GeneratedAt == "" {
1791+
t.Fatalf("manifest metadata = %#v", manifest)
1792+
}
1793+
if !bridgeManifestHasCommand(manifest, "compact") || !bridgeManifestHasCommand(manifest, "clear") {
1794+
t.Fatalf("manifest missing bridge-safe commands: %#v", manifest.Commands)
1795+
}
1796+
if bridgeManifestHasCommand(manifest, "status") || bridgeManifestHasCommand(manifest, "model") {
1797+
t.Fatalf("manifest leaked unsafe commands: %#v", manifest.Commands)
1798+
}
1799+
}
1800+
17451801
func TestRunnerExecutesStatusShowSectionsWithoutQuery(t *testing.T) {
17461802
client := &fakeClient{}
17471803
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
@@ -4884,6 +4940,15 @@ func requestHasTool(request anthropic.Request, name string) bool {
48844940
return false
48854941
}
48864942

4943+
func bridgeManifestHasCommand(manifest bridgepkg.Manifest, name string) bool {
4944+
for _, command := range manifest.Commands {
4945+
if command.Name == name {
4946+
return true
4947+
}
4948+
}
4949+
return false
4950+
}
4951+
48874952
func containsAnyString(values []any, want string) bool {
48884953
for _, value := range values {
48894954
if value == want {

0 commit comments

Comments
 (0)