From 5a8db69665065648fbb63b70c418b1aeb750f7de Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 23 Oct 2026 19:07:05 +0000 Subject: [PATCH 1/2] Remove Harness-level workspace reads --- .../internal/agent/claudesdk/bridge_output.go | 32 --- .../internal/agent/claudesdk/contracts.go | 14 +- .../internal/agent/claudesdk/executor.go | 15 +- .../claudesdk/preparation_fixture_test.go | 10 +- .../internal/agent/claudesdk/session.go | 14 +- .../agent/claudesdk/workspace_directory.go | 237 ------------------ .../claudesdk/workspace_directory_test.go | 214 ---------------- .../agent/claudesdk/workspace_read.go | 196 --------------- .../agent/claudesdk/workspace_read_test.go | 208 --------------- apps/daemon/internal/agent/codex/contracts.go | 14 +- .../internal/agent/codex/unsupported.go | 22 -- .../internal/agent/codex/unsupported_test.go | 41 --- .../agent/contract_declarations_test.go | 12 +- apps/daemon/internal/agent/harness.go | 24 -- apps/daemon/internal/agent/mcode/contracts.go | 14 +- .../internal/agent/mcode/unsupported.go | 22 -- .../internal/agent/mcode/unsupported_test.go | 41 --- apps/daemon/internal/agent/workspace_read.go | 6 +- .../preparation_executor_fixture_test.go | 6 - .../prepared_handoff_mutation_test.go | 6 - .../dispatch/prepared_handoff_test.go | 2 +- .../internal/dispatch/workspace_export.go | 2 +- .../internal/dispatch/workspace_read.go | 45 ++-- contracts/agents-api/harness-onboarding.md | 5 +- contracts/agents-api/zh/harness-onboarding.md | 7 +- packages/claude-sdk-adapter/README.md | 8 - packages/claude-sdk-adapter/src/adapter.ts | 12 +- packages/claude-sdk-adapter/src/main.ts | 12 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- .../src/workspace_directories.ts | 80 ------ .../claude-sdk-adapter/src/workspace_reads.ts | 57 ----- .../tests/preparation.test.mjs | 19 -- .../tests/workspace_directories.test.mjs | 56 ----- .../tests/workspace_reads.test.mjs | 34 --- 34 files changed, 57 insertions(+), 1432 deletions(-) delete mode 100644 apps/daemon/internal/agent/claudesdk/bridge_output.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_directory.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_directory_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_read.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_read_test.go delete mode 100644 apps/daemon/internal/agent/codex/unsupported.go delete mode 100644 apps/daemon/internal/agent/codex/unsupported_test.go delete mode 100644 apps/daemon/internal/agent/mcode/unsupported.go delete mode 100644 apps/daemon/internal/agent/mcode/unsupported_test.go delete mode 100644 packages/claude-sdk-adapter/src/workspace_directories.ts delete mode 100644 packages/claude-sdk-adapter/src/workspace_reads.ts delete mode 100644 packages/claude-sdk-adapter/tests/workspace_directories.test.mjs delete mode 100644 packages/claude-sdk-adapter/tests/workspace_reads.test.mjs diff --git a/apps/daemon/internal/agent/claudesdk/bridge_output.go b/apps/daemon/internal/agent/claudesdk/bridge_output.go deleted file mode 100644 index 6101240af..000000000 --- a/apps/daemon/internal/agent/claudesdk/bridge_output.go +++ /dev/null @@ -1,32 +0,0 @@ -package claudesdk - -import "bufio" - -type bridgeOutput struct { - frames chan []byte - current []byte - err error -} - -func (s *session) bridgeOutput() *bridgeOutput { - output := &bridgeOutput{frames: make(chan []byte, 1)} - go func() { - defer close(output.frames) - scanner := bufio.NewScanner(s.process.Stdout) - scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) - for scanner.Scan() { - raw := append([]byte(nil), scanner.Bytes()...) - if !s.receiveWorkspaceRead(raw) && !s.receiveWorkspaceDirectory(raw) { - output.frames <- raw - } - } - output.err = scanner.Err() - if output.err != nil { - s.process.Cancel() - } - }() - return output -} -func (o *bridgeOutput) Scan() bool { var ok bool; o.current, ok = <-o.frames; return ok } -func (o *bridgeOutput) Bytes() []byte { return o.current } -func (o *bridgeOutput) Err() error { return o.err } diff --git a/apps/daemon/internal/agent/claudesdk/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go index 8027eb5cf..e4689ae4a 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -5,13 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*executor)(nil) - _ agent.Turn = (*session)(nil) - _ agent.Session = (*session)(nil) - _ agent.DurableSteerer = (*session)(nil) - _ agent.FunctionResultSubmitter = (*session)(nil) - _ agent.WorkspaceReader = (*session)(nil) - _ agent.WorkspaceDirectoryLister = (*session)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) + _ agent.Executor = (*executor)(nil) + _ agent.Turn = (*session)(nil) + _ agent.Session = (*session)(nil) + _ agent.DurableSteerer = (*session)(nil) + _ agent.FunctionResultSubmitter = (*session)(nil) ) diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 5b99aed4f..6d3826895 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -1,6 +1,7 @@ package claudesdk import ( + "bufio" "context" "encoding/json" "errors" @@ -55,8 +56,6 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { if err != nil { return nil, err } - base.reads.supported = slices.Contains(info.Features, "workspace_read") - base.directories.supported = slices.Contains(info.Features, "workspace_directory") e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume} go e.read() if err = e.write(start); err == nil { @@ -124,7 +123,8 @@ func (e *executor) write(value any) error { func (e *executor) read() { stderrDone := make(chan struct{}) go func() { _, _ = io.Copy(io.Discard, e.base.process.Stderr); close(stderrDone) }() - scanner := e.base.bridgeOutput() + scanner := bufio.NewScanner(e.base.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) ready := false readyFailure := errors.New("claudesdk: Executor readiness failed") for scanner.Scan() { @@ -167,8 +167,6 @@ func (e *executor) read() { } <-stderrDone _ = e.base.process.Wait() - e.base.stopWorkspaceReads() - e.base.stopWorkspaceDirectories() e.mu.Lock() e.invalid = true if e.active != nil { @@ -306,10 +304,3 @@ func (s *session) invalidate() { s.process.Cancel() } } - -func (e *executor) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - return e.base.ReadWorkspaceFile(ctx, path, maxBytes) -} -func (e *executor) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - return e.base.ListWorkspaceDirectory(ctx, path, maxEntries) -} diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 67e60abc9..58805600b 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -38,7 +38,7 @@ func preparationRequest() proto.PromptRequestPayload { func runPreparationHelper() { mode := os.Getenv("SDK_HELPER_MODE") if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { - features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "workspace_read", "workspace_directory"} + features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations"} if mode == "old-runtime" { features = []string{"workspace_tools"} } else if mode == "old-command-runtime" { @@ -85,14 +85,6 @@ func runPreparationHelper() { } } emit(bridgeEvent{Type: "executor_ready", Protocol: 3}) - if strings.HasPrefix(mode, "directory-") { - runWorkspaceDirectoryHelper(scanner, state, mode) - return - } - if strings.HasPrefix(mode, "read-") { - runWorkspaceReadHelper(scanner, state, mode) - return - } if !scanner.Scan() { return } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index b5e55ee34..216cbd522 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -23,14 +23,12 @@ type session struct { cancelOutput chan struct{} nativeEnded bool - reads workspaceReadState - directories workspaceDirectoryState - process *clirunner.Process - writeMu *sync.Mutex - functions functionState - steering steeringState - settled chan struct{} - outcome proto.DonePayload + process *clirunner.Process + writeMu *sync.Mutex + functions functionState + steering steeringState + settled chan struct{} + outcome proto.DonePayload } type bridgeEvent struct { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go deleted file mode 100644 index 1bde6cbe5..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory.go +++ /dev/null @@ -1,237 +0,0 @@ -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "io" - "io/fs" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/google/uuid" -) - -const workspaceDirectoryMaxEntries = 1000 -const workspaceDirectoryTimeout = 12 * time.Second - -type workspaceDirectoryState struct { - mu sync.Mutex - supported bool - closed bool - uncertain bool - pending *workspaceDirectory -} -type workspaceDirectory struct { - id string - maxEntries int - done chan struct{} - result agent.WorkspaceDirectoryResult - err error -} -type workspaceDirectoryEvent struct { - Type string `json:"type"` - ID string `json:"id"` - Entries *[]workspaceDirectoryEntry `json:"entries"` - Truncated *bool `json:"truncated"` - Error string `json:"error"` -} - -var _ agent.WorkspaceDirectoryLister = (*session)(nil) - -func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - if s.owner != nil { - return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) - } - read, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return s.awaitWorkspaceDirectory(ctx, read) -} - -func (s *session) admitWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (*workspaceDirectory, error) { - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - if !w.supported { - return nil, agent.ErrWorkspaceReadUnsupported - } - if w.uncertain { - return nil, agent.ErrWorkspaceReadUncertain - } - if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { - return nil, agent.ErrWorkspaceReadUnavailable - } - if maxEntries < 1 || maxEntries > workspaceDirectoryMaxEntries || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { - return nil, agent.ErrWorkspaceReadInvalid - } - for _, part := range strings.Split(path, "/") { - if path == "" { - break - } - if part == "" || part == "." || part == ".." { - return nil, agent.ErrWorkspaceReadInvalid - } - } - if w.pending != nil { - return nil, agent.ErrWorkspaceReadBusy - } - read := &workspaceDirectory{id: uuid.NewString(), maxEntries: maxEntries, done: make(chan struct{})} - frame, err := json.Marshal(struct { - Type string `json:"type"` - ID string `json:"id"` - Path string `json:"directory"` - MaxEntries int `json:"max_entries"` - }{"workspace_directory", read.id, path, maxEntries}) - if err != nil || len(frame)+1 > 8192 { - return nil, agent.ErrWorkspaceReadInvalid - } - w.pending = read - deadline := time.Now().Add(workspaceDirectoryTimeout) - if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { - deadline = requested - } - go func() { - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case <-read.done: - return - case <-timer.C: - } - s.failWorkspaceDirectory(read) - }() - go func() { - s.writeMu.Lock() - _, err := s.process.Stdin.Write(append(frame, '\n')) - s.writeMu.Unlock() - if err != nil { - s.failWorkspaceDirectory(read) - } - }() - return read, nil -} - -func (s *session) failWorkspaceDirectory(read *workspaceDirectory) { - s.directories.mu.Lock() - pending := s.directories.pending == read - if pending { - s.directories.uncertain = true - s.directories.pending = nil - read.err = agent.ErrWorkspaceReadUncertain - s.process.Cancel() - close(read.done) - } - s.directories.mu.Unlock() -} - -func (s *session) awaitWorkspaceDirectory(_ context.Context, read *workspaceDirectory) (agent.WorkspaceDirectoryResult, error) { - // Caller cancellation cannot discard an already admitted native wait. - <-read.done - return read.result, read.err -} - -func (s *session) receiveWorkspaceDirectory(raw []byte) bool { - var event workspaceDirectoryEvent - if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_directory" { - return false - } - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - read := w.pending - if read == nil || event.ID != read.id { - w.uncertain = true - s.process.Cancel() - return true - } - read.err = agent.ErrWorkspaceReadUncertain - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF - if !valid { - w.uncertain = true - w.pending = nil - close(read.done) - s.process.Cancel() - return true - } - if event.Error != "" && event.Entries == nil && event.Truncated == nil { - switch event.Error { - case "not_found": - read.err = fs.ErrNotExist - case "permission": - read.err = fs.ErrPermission - case "invalid": - read.err = agent.ErrWorkspaceReadInvalid - case "busy": - read.err = agent.ErrWorkspaceReadBusy - case "unavailable": - read.err = agent.ErrWorkspaceReadUnavailable - } - } else if event.Error == "" && event.Entries != nil && event.Truncated != nil { - entries, valid := validWorkspaceDirectoryEntries(*event.Entries, read.maxEntries) - if valid && (!*event.Truncated || len(entries) == read.maxEntries) { - read.result = agent.WorkspaceDirectoryResult{Entries: entries, Truncated: *event.Truncated} - read.err = nil - } - } - if read.err == agent.ErrWorkspaceReadUncertain { - w.uncertain = true - s.process.Cancel() - } - w.pending = nil - close(read.done) - return true -} - -func (s *session) stopWorkspaceDirectories() { - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - w.closed = true - if w.pending != nil { - read := w.pending - w.pending = nil - w.uncertain = true - read.err = agent.ErrWorkspaceReadUncertain - close(read.done) - } -} - -type workspaceDirectoryEntry struct { - Name string `json:"name"` - Kind string `json:"kind"` - SizeBytes *int64 `json:"size_bytes,omitempty"` -} - -func validWorkspaceDirectoryEntries(raw []workspaceDirectoryEntry, maxEntries int) ([]agent.WorkspaceDirectoryEntry, bool) { - if raw == nil || len(raw) > maxEntries { - return nil, false - } - entries := make([]agent.WorkspaceDirectoryEntry, 0, len(raw)) - names := make(map[string]bool, len(raw)) - for _, entry := range raw { - if entry.Name == "" || entry.Name == "." || entry.Name == ".." || strings.ContainsAny(entry.Name, "/\x00") || names[entry.Name] { - return nil, false - } - names[entry.Name] = true - switch entry.Kind { - case "file": - if entry.SizeBytes == nil || *entry.SizeBytes < 0 { - return nil, false - } - case "directory", "symlink", "other": - if entry.SizeBytes != nil { - return nil, false - } - default: - return nil, false - } - entries = append(entries, agent.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) - } - return entries, true -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go deleted file mode 100644 index e073cffae..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go +++ /dev/null @@ -1,214 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - - "context" - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { - turnID := "" - for scanner.Scan() { - var req struct { - Type, ID string - TurnID string `json:"turn_id"` - Path string `json:"directory"` - Max int `json:"max_entries"` - } - _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "turn_cancel" { - reusable := false - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - confirmed := true - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) - return - } - if req.Type == "turn_start" { - turnID = req.TurnID - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) - _ = os.WriteFile(filepath.Join(state, "directory-started"), []byte("{}"), 0600) - continue - } - _ = os.WriteFile(filepath.Join(state, "directory-admitted"), []byte("{}"), 0600) - if mode == "directory-held" { - for { - if _, err := os.Stat(filepath.Join(state, "directory-release")); err == nil { - break - } - time.Sleep(time.Millisecond) - } - } - if mode == "directory-exit" { - return - } - entries := []map[string]any{{"name": "file", "kind": "file", "size_bytes": 3}} - if req.Path == "empty" { - entries = []map[string]any{} - } - response := map[string]any{"type": "workspace_directory", "id": req.ID, "entries": entries, "truncated": false} - switch req.Path { - case "uncertain", "invalid", "not_found", "permission": - response = map[string]any{"type": "workspace_directory", "id": req.ID, "error": req.Path} - case "bad-kind": - entries[0]["kind"] = "unknown" - case "wrong-id": - response["id"] = "other" - case "extra": - response["extra"] = true - case "bad-size": - entries[0]["size_bytes"] = -1 - case "missing-size": - delete(entries[0], "size_bytes") - case "duplicate": - response["entries"] = append(entries, entries[0]) - case "escape-name": - entries[0]["name"] = "../secret" - case "null": - response["entries"] = nil - } - - _ = json.NewEncoder(os.Stdout).Encode(response) - } -} - -func directoryExecutor(t *testing.T, mode string) (*executor, Config) { - t.Helper() - config := preparationFixture(t, mode) - resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - e := resource.(*executor) - t.Cleanup(func() { _ = e.Close(context.Background()) }) - return e, config -} - -func TestWorkspaceDirectoryBoundsAndMetadata(t *testing.T) { - e, _ := directoryExecutor(t, "directory-normal") - for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("accepted %q: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := e.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(limit, err) - } - } - result, err := e.ListWorkspaceDirectory(t.Context(), "", 4) - if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { - t.Fatal(result, err) - } - empty, err := e.ListWorkspaceDirectory(t.Context(), "empty", 4) - if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { - t.Fatal(empty, err) - } - for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { - t.Fatal(path, err) - } - } - if _, err := e.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceDirectoryDetachAndTurnStart(t *testing.T) { - e, config := directoryExecutor(t, "directory-held") - ctx, detach := context.WithCancel(t.Context()) - defer detach() - result := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - detach() - if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { - t.Fatal(err) - } - out := make(chan proto.Envelope, 16) - running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - select { - case err := <-result: - t.Fatal("caller detach discarded native wait", err) - default: - } - if err := os.WriteFile(filepath.Join(config.StateDir, "directory-release"), nil, 0600); err != nil { - t.Fatal(err) - } - if err := <-result; err != nil { - t.Fatal(err) - } - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-started")) - if _, err := running.(agent.WorkspaceDirectoryLister).ListWorkspaceDirectory(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { - for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { - t.Run(path, func(t *testing.T) { - e, _ := directoryExecutor(t, "directory-normal") - result, err := e.ListWorkspaceDirectory(t.Context(), path, 4) - if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { - t.Fatal(result, err) - } - if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - }) - } - for _, mode := range []string{"directory-held", "directory-exit"} { - t.Run(mode, func(t *testing.T) { - e, config := directoryExecutor(t, mode) - done := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if mode == "directory-held" { - if err := e.Close(t.Context()); err != nil { - t.Fatal(err) - } - } - select { - case err := <-done: - if err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("native waiter lost on release") - } - }) - } -} - -func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - e, config := directoryExecutor(t, "directory-held") - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - done := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if err := <-done; err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - if e.base.process.Context().Err() == nil { - t.Fatal("uncertain deadline returned before owner cancellation") - } - if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Turn") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go deleted file mode 100644 index ec5ea4a56..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_read.go +++ /dev/null @@ -1,196 +0,0 @@ -package claudesdk - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "io" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/google/uuid" -) - -const workspaceReadMaxBytes = 1 << 20 -const workspaceReadTimeout = 12 * time.Second - -type workspaceReadState struct { - mu sync.Mutex - supported bool - closed bool - uncertain bool - pending *workspaceRead -} -type workspaceRead struct { - id string - maxBytes int - done chan struct{} - result agent.WorkspaceReadResult - err error -} -type workspaceReadEvent struct { - Type string `json:"type"` - ID string `json:"id"` - Data *string `json:"data_base64"` - Truncated *bool `json:"truncated"` - Error string `json:"error"` -} - -var _ agent.WorkspaceReader = (*session)(nil) - -func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - if s.owner != nil { - return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) - } - read, err := s.admitWorkspaceRead(ctx, path, maxBytes) - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return s.awaitWorkspaceRead(ctx, read) -} - -func (s *session) admitWorkspaceRead(ctx context.Context, path string, maxBytes int) (*workspaceRead, error) { - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - if !w.supported { - return nil, agent.ErrWorkspaceReadUnsupported - } - if w.uncertain { - return nil, agent.ErrWorkspaceReadUncertain - } - if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { - return nil, agent.ErrWorkspaceReadUnavailable - } - if maxBytes < 1 || maxBytes > workspaceReadMaxBytes || path == "" || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { - return nil, agent.ErrWorkspaceReadInvalid - } - for _, part := range strings.Split(path, "/") { - if part == "" || part == "." || part == ".." { - return nil, agent.ErrWorkspaceReadInvalid - } - } - if w.pending != nil { - return nil, agent.ErrWorkspaceReadBusy - } - read := &workspaceRead{id: uuid.NewString(), maxBytes: maxBytes, done: make(chan struct{})} - frame, err := json.Marshal(struct { - Type string `json:"type"` - ID string `json:"id"` - Path string `json:"path"` - MaxBytes int `json:"max_bytes"` - }{"workspace_read", read.id, path, maxBytes}) - if err != nil || len(frame)+1 > 8192 { - return nil, agent.ErrWorkspaceReadInvalid - } - w.pending = read - deadline := time.Now().Add(workspaceReadTimeout) - if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { - deadline = requested - } - go func() { - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case <-read.done: - return - case <-timer.C: - } - s.failWorkspaceRead(read) - }() - go func() { - s.writeMu.Lock() - _, err := s.process.Stdin.Write(append(frame, '\n')) - s.writeMu.Unlock() - if err != nil { - s.failWorkspaceRead(read) - } - }() - return read, nil -} - -func (s *session) failWorkspaceRead(read *workspaceRead) { - s.reads.mu.Lock() - pending := s.reads.pending == read - if pending { - s.reads.uncertain = true - s.reads.pending = nil - read.err = agent.ErrWorkspaceReadUncertain - s.process.Cancel() - close(read.done) - } - s.reads.mu.Unlock() -} - -func (s *session) awaitWorkspaceRead(_ context.Context, read *workspaceRead) (agent.WorkspaceReadResult, error) { - // Caller cancellation cannot discard an already admitted native wait. - <-read.done - return read.result, read.err -} - -func (s *session) receiveWorkspaceRead(raw []byte) bool { - var event workspaceReadEvent - if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_read" { - return false - } - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - read := w.pending - if read == nil || event.ID != read.id { - w.uncertain = true - s.process.Cancel() - return true - } - read.err = agent.ErrWorkspaceReadUncertain - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF - if !valid { - w.uncertain = true - w.pending = nil - close(read.done) - s.process.Cancel() - return true - } - if event.Error != "" && event.Data == nil && event.Truncated == nil { - switch event.Error { - case "invalid": - read.err = agent.ErrWorkspaceReadInvalid - case "busy": - read.err = agent.ErrWorkspaceReadBusy - case "unavailable": - read.err = agent.ErrWorkspaceReadUnavailable - } - } else if event.Error == "" && event.Data != nil && event.Truncated != nil { - data, err := base64.StdEncoding.Strict().DecodeString(*event.Data) - if err == nil && base64.StdEncoding.EncodeToString(data) == *event.Data && len(data) <= read.maxBytes && (!*event.Truncated || len(data) == read.maxBytes) { - read.result = agent.WorkspaceReadResult{Data: data, Truncated: *event.Truncated} - read.err = nil - } - } - if read.err == agent.ErrWorkspaceReadUncertain { - w.uncertain = true - s.process.Cancel() - } - w.pending = nil - close(read.done) - return true -} - -func (s *session) stopWorkspaceReads() { - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - w.closed = true - if w.pending != nil { - read := w.pending - w.pending = nil - w.uncertain = true - read.err = agent.ErrWorkspaceReadUncertain - close(read.done) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go deleted file mode 100644 index 72d32a943..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go +++ /dev/null @@ -1,208 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { - turnID := "" - for scanner.Scan() { - var req struct { - Type, ID, Path string - TurnID string `json:"turn_id"` - Max int `json:"max_bytes"` - } - _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "turn_cancel" { - reusable := false - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - confirmed := true - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) - return - } - if req.Type == "turn_start" { - turnID = req.TurnID - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) - _ = os.WriteFile(filepath.Join(state, "read-started"), []byte("{}"), 0600) - continue - } - _ = os.WriteFile(filepath.Join(state, "read-admitted"), []byte("{}"), 0600) - if mode == "read-held" { - for { - if _, err := os.Stat(filepath.Join(state, "read-release")); err == nil { - break - } - time.Sleep(time.Millisecond) - } - } - if mode == "read-exit" { - return - } - data := bytes.Repeat([]byte{0, 255, 1, 128}, req.Max/4) - if req.Path == "empty" { - data = nil - } - encoded := base64.StdEncoding.EncodeToString(data) - truncated := req.Path == "prefix" - response := map[string]any{"type": "workspace_read", "id": req.ID, "data_base64": encoded, "truncated": truncated} - switch req.Path { - case "uncertain": - response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "uncertain"} - case "bad-base64": - response["data_base64"] = "!" - case "wrong-id": - response["id"] = "other" - case "extra": - response["extra"] = true - case "oversize": - response["data_base64"] = base64.StdEncoding.EncodeToString(make([]byte, req.Max+1)) - case "invalid": - response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "invalid"} - } - _ = json.NewEncoder(os.Stdout).Encode(response) - } -} - -func readExecutor(t *testing.T, mode string) (*executor, Config) { - t.Helper() - config := preparationFixture(t, mode) - resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - e := resource.(*executor) - t.Cleanup(func() { _ = e.Close(context.Background()) }) - return e, config -} - -func TestWorkspaceReadBoundsAndBinary(t *testing.T) { - e, _ := readExecutor(t, "read-normal") - for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := e.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("accepted %q: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := e.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(limit, err) - } - } - for _, path := range []string{"file", "prefix", "empty"} { - result, err := e.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) - expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) - if path == "empty" { - expected = nil - } - if err != nil || !bytes.Equal(result.Data, expected) || result.Truncated != (path == "prefix") { - t.Fatal(path, err, len(result.Data), result.Truncated) - } - } - if _, err := e.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(err) - } - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceReadDetachAndTurnStart(t *testing.T) { - e, config := readExecutor(t, "read-held") - ctx, detach := context.WithCancel(t.Context()) - defer detach() - result := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); result <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - detach() - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { - t.Fatal(err) - } - out := make(chan proto.Envelope, 16) - running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - select { - case err := <-result: - t.Fatal("caller detach discarded native wait", err) - default: - } - if err := os.WriteFile(filepath.Join(config.StateDir, "read-release"), nil, 0600); err != nil { - t.Fatal(err) - } - if err := <-result; err != nil { - t.Fatal(err) - } - waitPreparationFile(t, filepath.Join(config.StateDir, "read-started")) - if _, err := running.(agent.WorkspaceReader).ReadWorkspaceFile(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceReadUnknownAndRelease(t *testing.T) { - for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { - t.Run(path, func(t *testing.T) { - e, _ := readExecutor(t, "read-normal") - result, err := e.ReadWorkspaceFile(t.Context(), path, 4) - if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { - t.Fatal(result, err) - } - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - }) - } - for _, mode := range []string{"read-held", "read-exit"} { - t.Run(mode, func(t *testing.T) { - e, config := readExecutor(t, mode) - done := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - if mode == "read-held" { - if err := e.Close(t.Context()); err != nil { - t.Fatal(err) - } - } - select { - case err := <-done: - if err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("native waiter lost on release") - } - }) - } -} - -func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - e, config := readExecutor(t, "read-held") - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - done := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - if err := <-done; err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - if e.base.process.Context().Err() == nil { - t.Fatal("uncertain deadline returned before owner cancellation") - } - if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Turn") - } -} diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index 90763a671..dde65b9f3 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -5,13 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*Executor)(nil) - _ agent.Turn = (*Session)(nil) - _ agent.Session = (*Session)(nil) - _ agent.DurableSteerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceReader = (*Executor)(nil) - _ agent.WorkspaceDirectoryLister = (*Executor)(nil) + _ agent.Executor = (*Executor)(nil) + _ agent.Turn = (*Session)(nil) + _ agent.Session = (*Session)(nil) + _ agent.DurableSteerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) ) diff --git a/apps/daemon/internal/agent/codex/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go deleted file mode 100644 index e9f02276a..000000000 --- a/apps/daemon/internal/agent/codex/unsupported.go +++ /dev/null @@ -1,22 +0,0 @@ -package codex - -import ( - "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func (s *Executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Executor) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go deleted file mode 100644 index 903f87422..000000000 --- a/apps/daemon/internal/agent/codex/unsupported_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package codex - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// Nil concrete receivers prove Unsupported needs no native owner, transport, -// workspace access or input retention. Callers still preserve the separate -// nil-Turn ownership rule on required lifecycle methods. -func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { - ctx := context.Background() - const secret = "private-fixture-value" - check := func(err error) { - t.Helper() - if !errors.Is(err, agent.ErrUnsupportedOperation) || err.Error() == agent.ErrUnsupportedOperation.Error() { - t.Fatalf("expected explicit unsupported result with reason, got %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unsupported error disclosed input") - } - } - for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } -} diff --git a/apps/daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go index b3d6dbe39..ef12a649e 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -14,13 +14,11 @@ import ( // decision. Each adapter's compile assertions then enforce its actual methods. func TestPublicHarnessContractDeclarations(t *testing.T) { roles := map[string][]string{ - "Executor": {"executor"}, - "Turn": {"session"}, - "Session": {"session"}, - "DurableSteerer": {"session"}, - "FunctionResultSubmitter": {"session"}, - "WorkspaceReader": {"executor", "session"}, - "WorkspaceDirectoryLister": {"executor", "session"}, + "Executor": {"executor"}, + "Turn": {"session"}, + "Session": {"session"}, + "DurableSteerer": {"session"}, + "FunctionResultSubmitter": {"session"}, } files, err := filepath.Glob("*.go") if err != nil { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 3db9bede8..53aaeb02a 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -188,30 +188,6 @@ type FunctionResultSubmitter interface { SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error } -// Workspace extensions, implemented by each owner explicitly. The common -// Runtime may supply an authorized workspace owner independently of the adapter. -// A resource without native access returns the corresponding Unsupported error; -// this does not disable capabilities provided by the common workspace owner. - -// WorkspaceReader returns success only after acknowledged native close on an existing owner. -type WorkspaceReader interface { - ReadWorkspaceFile(context.Context, string, int) (WorkspaceReadResult, error) -} - -// WorkspaceDirectoryLister reads one directory through an existing workspace owner. -// An empty directory selects the root; other paths contain only relative components. -// Entry names are single components. Kind is file, directory, symlink or other; -// SizeBytes is present and nonnegative only for regular files. Results have no -// prescribed order, and Truncated must not be presented as a complete inventory. -// maxEntries is positive; adapters may reject limits above their private bound. -// Successful return requires settled directory/metadata access and handle cleanup. -// Implementations retain workspace authorization and isolation and use the existing -// WorkspaceRead errors for unsupported, unavailable, busy, invalid or uncertain reads. -// This interface does not establish public Files pagination or feature admission. -type WorkspaceDirectoryLister interface { - ListWorkspaceDirectory(context.Context, string, int) (WorkspaceDirectoryResult, error) -} - // Kind registration. // RegisterKind installs the heartbeat descriptor and model configuration for an diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index 9c8525d16..c59b7eb70 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -5,13 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*executor)(nil) - _ agent.Turn = (*Session)(nil) - _ agent.Session = (*Session)(nil) - _ agent.DurableSteerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) + _ agent.Executor = (*executor)(nil) + _ agent.Turn = (*Session)(nil) + _ agent.Session = (*Session)(nil) + _ agent.DurableSteerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) ) diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go deleted file mode 100644 index 040dcf9b9..000000000 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ /dev/null @@ -1,22 +0,0 @@ -package mcode - -import ( - "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *executor) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go deleted file mode 100644 index 3cc8bb206..000000000 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package mcode - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// Nil concrete receivers prove Unsupported needs no native owner, transport, -// workspace access or input retention. Callers still preserve the separate -// nil-Turn ownership rule on required lifecycle methods. -func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { - ctx := context.Background() - const secret = "private-fixture-value" - check := func(err error) { - t.Helper() - if !errors.Is(err, agent.ErrUnsupportedOperation) || err.Error() == agent.ErrUnsupportedOperation.Error() { - t.Fatalf("expected explicit unsupported result with reason, got %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unsupported error disclosed input") - } - } - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } -} diff --git a/apps/daemon/internal/agent/workspace_read.go b/apps/daemon/internal/agent/workspace_read.go index 71f1db3e5..c69f31321 100644 --- a/apps/daemon/internal/agent/workspace_read.go +++ b/apps/daemon/internal/agent/workspace_read.go @@ -1,9 +1,6 @@ package agent -import ( - "errors" - "fmt" -) +import "errors" type WorkspaceReadResult struct { Data []byte @@ -11,7 +8,6 @@ type WorkspaceReadResult struct { } var ( - ErrWorkspaceReadUnsupported = fmt.Errorf("%w: workspace read", ErrUnsupportedOperation) ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") ErrWorkspaceReadBusy = errors.New("workspace read busy") ErrWorkspaceReadInvalid = errors.New("workspace read invalid") diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index 73206099f..c3e8c9ebf 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -113,9 +113,3 @@ func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSt } return agent.ErrSteeringInactive } -func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { - if target, ok := t.Session.(agent.WorkspaceReader); ok { - return target.ReadWorkspaceFile(ctx, path, limit) - } - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 39c557936..a7615cf35 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -17,7 +17,6 @@ type preparedMutationSession struct { cancelOnce sync.Once functions atomic.Int32 steers atomic.Int32 - reads atomic.Int32 } func (s *preparedMutationSession) Cancel(ctx context.Context) error { @@ -35,11 +34,6 @@ func (s *preparedMutationSession) SteerWithReceipt(context.Context, proto.Prompt return nil } -func (s *preparedMutationSession) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - s.reads.Add(1) - return agent.WorkspaceReadResult{Data: []byte("x")}, nil -} - type blockingPreparedReceiptSender struct { *recSender deliveryID string diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index f366b649f..2d6e0ba0e 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -190,7 +190,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeWorkspaceReadResult || frames[len(frames)-1].DecodePayload(&readResult) != nil || readResult.ErrorCode != "resource_unavailable" { t.Fatalf("pre-publication workspace read = %+v", frames) } - if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 { + if session.functions.Load() != 0 || session.steers.Load() != 0 { t.Fatal("private Session accepted work before started publication") } duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")}) diff --git a/apps/daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go index 490cbd463..7a2aa846e 100644 --- a/apps/daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -46,7 +46,7 @@ func (r *Router) handleWorkspaceExport(ctx context.Context, env proto.Envelope) return errors.New("dispatch: workspace export request already pending") } } - _, code := r.workspaceResourceLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) + code := r.workspaceReadAdmissionLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) p := r.preparations[request.Handle] if u != nil || r.workspaceWrite != nil || !r.localWorkspace.CanExport() || code != "" || p == nil || p.executor != nil { r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index 7511a84c5..ead6b1896 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -36,7 +37,7 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er r.mu.Unlock() return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("read_capacity")) } - resource, code := r.workspaceResourceLocked(request) + code := r.workspaceReadAdmissionLocked(request) if code != "" { r.mu.Unlock() return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) @@ -45,6 +46,7 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er r.workspaceReads = make(map[string]struct{}) } r.workspaceReads[env.ID] = struct{}{} + binding := r.localWorkspace r.shutdownWG.Add(1) r.mu.Unlock() go func() { @@ -53,44 +55,32 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er // Observer loss does not discard an admitted native wait or replay it. operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 12*time.Second) defer cancel() - result := executeWorkspaceRead(operation, resource, request) + result := executeWorkspaceRead(operation, binding, request) _ = r.sendWorkspaceRead(context.WithoutCancel(ctx), env, result) }() return nil } -func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (any, string) { - var resource any +func (r *Router) workspaceReadAdmissionLocked(request proto.WorkspaceReadPayload) string { if request.Handle != "" { p := r.preparations[request.Handle] if p == nil || p.environmentID != request.EnvironmentID || p.status.State != "ready" || !p.owns || p.busy || p.ctx.Err() != nil || !time.Now().Before(p.deadline) { - return nil, "resource_unavailable" + return "resource_unavailable" } - if p.executor != nil { - resource = p.executor.native - } - } else { - s := r.sessions[request.RunID] - if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || - !r.interactionRouteOpenLocked(s) { - return nil, "resource_unavailable" - } - resource = s.session + return "" } - if r.localWorkspace != nil { - resource = r.localWorkspace + s := r.sessions[request.RunID] + if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || + !r.interactionRouteOpenLocked(s) { + return "resource_unavailable" } - return resource, "" + return "" } -func executeWorkspaceRead(ctx context.Context, resource any, request proto.WorkspaceReadPayload) proto.WorkspaceReadResultPayload { +func executeWorkspaceRead(ctx context.Context, binding *localworkspace.Binding, request proto.WorkspaceReadPayload) proto.WorkspaceReadResultPayload { if request.Operation == "directory" { - reader, ok := resource.(agent.WorkspaceDirectoryLister) - if !ok { - return rejectedWorkspaceRead("read_unsupported") - } - read, err := reader.ListWorkspaceDirectory(ctx, request.Path, request.MaxEntries) + read, err := binding.ListWorkspaceDirectory(ctx, request.Path, request.MaxEntries) if err != nil { return workspaceReadResult(agent.WorkspaceReadResult{}, err, 0) } @@ -106,11 +96,7 @@ func executeWorkspaceRead(ctx context.Context, resource any, request proto.Works } return proto.WorkspaceReadResultPayload{Outcome: "completed", Directory: directory, CloseAcknowledged: true} } - reader, ok := resource.(agent.WorkspaceReader) - if !ok { - return rejectedWorkspaceRead("read_unsupported") - } - read, err := reader.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) + read, err := binding.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) return workspaceReadResult(read, err, request.MaxBytes) } @@ -126,7 +112,6 @@ func workspaceReadResult(read agent.WorkspaceReadResult, err error, limit int) p err error code string }{ - {agent.ErrWorkspaceReadUnsupported, "read_unsupported"}, {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, {agent.ErrWorkspaceReadBusy, "read_capacity"}, {agent.ErrWorkspaceReadInvalid, "invalid_request"}, diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index cd29f9002..ef49f3cb7 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -58,7 +58,7 @@ Implement the mandatory text lifecycle and handle every extension explicitly. Qu ## Required adapter interfaces -[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) is the interface entry point. The required lifecycle is `ExecutorFactory`, `Executor`, `Turn` (including `DurableSteerer`) and `TurnSettlement`. Required methods perform their native obligations; returning Unsupported is not an implementation of cancellation, receipts, settlement or cleanup. Turn and workspace extension interfaces stay small and separate, but every public adapter implements each one explicitly. All use the neutral protocol types. +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) is the interface entry point. The required lifecycle is `ExecutorFactory`, `Executor`, `Turn` (including `DurableSteerer`) and `TurnSettlement`. Required methods perform their native obligations; returning Unsupported is not an implementation of cancellation, receipts, settlement or cleanup. Turn extension interfaces stay small and separate, but every public adapter implements each one explicitly. All use the neutral protocol types. For example, the Codex adapter keeps its app-server and thread, the Claude adapter one streaming Query, and the MiniMax adapter its ACP connection and native session. All expose the same Executor and Turn contract. Native callbacks and resources stay inside the adapter; the Runtime owns admission, idle expiry and replacement. Cancellation targets the exact Turn through `agent.Session`, and the adapter supplies native completion evidence to the Runtime. @@ -68,7 +68,6 @@ For example, the Codex adapter keeps its app-server and thread, the Claude adapt | `Session`, `Turn`, `CancellationOutcome`, `AwaitSettlement` | Real implementation | Cancel the exact Turn, keep observed results and confirm settlement independently of cancellation requests | | `DurableSteerer` | Real implementation on every Turn | Distinguish a complete write from the native application receipt; keep retry identity | | `FunctionResultSubmitter` | Explicit implementation or Unsupported | Match native call and result identity and acknowledge application | -| `WorkspaceReader`, `WorkspaceDirectoryLister` | Explicit on Turn and Executor owners | Use the authorized workspace, confirm access, commit or close, or return the operation's Unsupported error | | Neutral messages, images, MCP, structured output and Subagent observations | Explicit capability decisions | Keep each operation's protocol semantics; reject unsupported input before submission | Each adapter's `contracts.go` holds an individual compile-time assertion for each small interface. Do not embed a default implementation that makes future interfaces appear implemented. Adding a contract also requires a classification in the common completeness check and an explicit assertion in every public adapter; the check follows the authored Harness catalog. @@ -173,7 +172,7 @@ The Runtime resolves the [state directory](../../docs/configuration.md#runtime-r Fill every field of the declaration's `proto.AgentKindCapabilities` explicitly; [Capability declarations](../../docs/runtime-protocol.md#capability-declarations) defines how declarations are encoded, validated and frozen. An installation probe may withdraw a declared field with `agent.Narrow`; it never grants one, and `Registry.Register` rejects a discovery that does. Availability stays separate in `SupportedAgentKind.Available`. Code that reads a declaration uses `IsSupported()`; an interface assertion verifies implementation, never support. Every declaration must match the behavior verified for that installation. -The admission mapping is explicit. `DurableInputReceipts` controls `DurableSteerer` input and also requires the Turn settlement contract. Workspace declarations describe the authorized resource owner, including the common Runtime workspace implementation. `WorkspaceReadPreparation` admits `execution_prepare` with `workspace_read_only`. The Runtime readies that preparation itself and serves its reads from the bound local workspace directory without calling the Executor factory, so an adapter declares it only when this kind's Environment workspace is that local directory. Registration does not derive it. Runtime registration does not grant Core admission; the declaration does. +The admission mapping is explicit. `DurableInputReceipts` controls `DurableSteerer` input and also requires the Turn settlement contract. Workspace reads and writes are served by the Runtime from its bound local workspace, never by the adapter. Workspace declarations describe that resource owner. `WorkspaceReadPreparation` admits `execution_prepare` with `workspace_read_only`. The Runtime readies that preparation itself and serves its reads from the bound local workspace directory without calling the Executor factory, so an adapter declares it only when this kind's Environment workspace is that local directory. Registration does not derive it. Runtime registration does not grant Core admission; the declaration does. The runnable test-only example [`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/testdata/onboarding/main.go) registers a text-only synthetic Harness. It shows a Session-owned Executor, fresh Turns, durable steering, cancellation and history binding, and is never shipped. diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index bc6dda172..f9b3a18ed 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: bf3a297779def9925b30e6a77c3e5f28a1349964922242219bcf1fcaad9ef2af +source_hash: 35704f5aa719f0f151c6dde461ce5cf49863a517ef433b06829fd6fe6f9fe2bd --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -60,7 +60,7 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 ## 必需的适配器接口 {#required-adapter-interfaces} -[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) 是接口入口。必需的生命周期包括 `ExecutorFactory`、`Executor`、`Turn`(包括 `DurableSteerer`)和 `TurnSettlement`。必需方法必须履行其原生义务;返回 Unsupported 并不构成对取消、回执、结算或清理的实现。Turn 和工作区扩展接口应保持小而独立,但每个公共适配器都必须明确实现每一个接口。所有接口都使用中立协议类型。 +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) 是接口入口。必需的生命周期包括 `ExecutorFactory`、`Executor`、`Turn`(包括 `DurableSteerer`)和 `TurnSettlement`。必需方法必须履行其原生义务;返回 Unsupported 并不构成对取消、回执、结算或清理的实现。Turn 扩展接口应保持小而独立,但每个公共适配器都必须明确实现每一个接口。所有接口都使用中立协议类型。 例如,Codex 适配器保留其 app-server 和 thread,Claude 适配器保留一个流式 Query,MiniMax 适配器保留其 ACP 连接和原生 session。它们都公开相同的 Executor 和 Turn 契约。原生回调和资源保留在适配器内部;Runtime 负责准入、空闲过期和替换。取消通过 `agent.Session` 精确定位到目标 Turn,适配器则向 Runtime 提供原生完成证据。 @@ -70,7 +70,6 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 | `Session`、`Turn`、`CancellationOutcome`、`AwaitSettlement` | 真实实现 | 取消精确的 Turn,保留已观察结果,并独立于取消请求确认结算 | | `DurableSteerer` | 每个 Turn 上真实实现 | 区分完整写入与原生应用回执;保留重试身份 | | `FunctionResultSubmitter` | 明确实现或 Unsupported | 匹配原生调用和结果身份,并确认应用 | -| `WorkspaceReader`、`WorkspaceDirectoryLister` | 在 Turn 和 Executor 所有者上明确实现 | 使用授权工作区,确认访问,提交或关闭,或者返回该操作的 Unsupported 错误 | | 中立消息、图像、MCP、结构化输出和 Subagent 观察 | 明确作出能力决策 | 保持每项操作的协议语义;在提交前拒绝不受支持的输入 | 每个适配器的 `contracts.go` 都包含针对每个小型接口的单项编译时断言。不要嵌入会让未来接口看起来已经实现的默认实现。添加契约时,还必须在通用完整性检查中进行分类,并在每个公共适配器中添加明确断言;该检查遵循已编写的 Harness 目录。 @@ -175,7 +174,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res 显式填写声明中 `proto.AgentKindCapabilities` 的每个字段;[能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)定义声明的编码、验证和冻结方式。安装探测可以使用 `agent.Narrow` 撤回已声明的字段,但绝不授予新字段;`Registry.Register` 会拒绝授予了新字段的探测结果。可用性通过 `SupportedAgentKind.Available` 单独表示。读取声明的代码应调用 `IsSupported()`;接口断言用于验证实现,绝不表示支持。每个声明都必须与针对该安装验证的行为一致。 -准入映射是显式的。`DurableInputReceipts` 控制 `DurableSteerer` 输入,并且还要求 Turn 结算契约。工作区声明描述授权资源所有者,包括通用 Runtime 工作区实现。`WorkspaceReadPreparation` 准入带 `workspace_read_only` 的 `execution_prepare`。Runtime 自行就绪该 preparation,并从绑定的本地工作区目录提供读取,不调用 Executor 工厂;因此仅当该 kind 的 Environment 工作区就是该本地目录时,adapter 才声明它。注册过程不会派生它。Runtime 注册不会授予 Core 准入;声明才会授予。 +准入映射是显式的。`DurableInputReceipts` 控制 `DurableSteerer` 输入,并且还要求 Turn 结算契约。工作区读取和写入由 Runtime 从其绑定的本地工作区提供,而不是由适配器提供。工作区声明描述该资源所有者。`WorkspaceReadPreparation` 准入带 `workspace_read_only` 的 `execution_prepare`。Runtime 自行就绪该 preparation,并从绑定的本地工作区目录提供读取,不调用 Executor 工厂;因此仅当该 kind 的 Environment 工作区就是该本地目录时,adapter 才声明它。注册过程不会派生它。Runtime 注册不会授予 Core 准入;声明才会授予。 可运行的仅测试示例 [`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/testdata/onboarding/main.go) 会注册一个仅支持文本的合成 Harness。它展示 Session 所有的 Executor、全新的 Turn、持久化引导、取消和历史绑定,并且绝不会发布。 diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 2e5917ea4..f4c55e8e8 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -38,14 +38,6 @@ Each Turn ends with a result/error and `turn_settled`, independently of process `claudesdk.NewExecutorFactory` binds this bridge to `agent.Executor`. The read-only preparation wrapper delegates to the same implementation. Runtime execution uses the Executor registry for both none and workspace configurations. Its owner context spans all Turns; a Turn's caller cannot replace fixed resources. A failed preparation returns its Executor when cleanup remains unconfirmed. Installed runtime checks are cached by package/file identity, while capability and request validation still run for each Executor configuration. -### Workspace reads and directory listing - -The optional private `agent.WorkspaceReader` on this Executor and its delegated wrappers requires the packaged `workspace_read` feature. It sends bounded relative paths to that same SDK Query's native `readFile` control. Only the adapter combines the path with the frozen workspace root; callers cannot replace the placement. The pinned native read handler awaits file-handle close before its successful base64 response. The adapter validates bytes and truncation, bounds each result to 1 MiB and each request to 8 KiB, and admits one read at a time. Its continuous bridge output consumer retains read receipts during preparation and across Turns. Caller cancellation detaches observation without cancelling the Run or discarding an admitted waiter; its original deadline still applies. Owner closure stops admission. Native null, malformed receipts, timeout and interrupted delivery remain uncertain and stop the owner; local reap is not a successful read settlement. The SDK's nullable result catches all native/control errors, so it cannot distinguish missing files from denial or transport failure. The reader provides no public Files endpoint, snapshot consistency, placement registration or idle owner policy. The qualified live workspace fixture also checks binary, empty and bounded reads before input and during real execution, plus effects before cancellation and reads on fresh-process history continuation. - -The optional private `agent.WorkspaceDirectoryLister` uses the portable `workspace_directory` bridge feature. Node reads directory metadata under the selected workspace and never follows a link ([list files](../../contracts/agents-api/environment-files.md#list-files)); there is no Linux `/proc/self/fd` dependency. Public daemon Files operations share the Go Binding implementation on all platforms. Their relative path and result bounds define the Files API, not Harness permissions. - -Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit truncation, literal names, kinds, and sizes only for regular files. They do not promise ordering, snapshots, recursion, or public pagination. Missing and permission errors are returned only from distinguishable filesystem outcomes; unknown results stop the owner. Each operation closes its directory before a successful receipt. Caller cancellation, Turn transitions and owner shutdown follow the workspace read settlement rules. The lister alone does not enable public Claude Files; the dedicated Runtime integration supplies public placement and ownership. - ### Command observations Private workspace execution requires the packaged `workspace_command_observations` feature and emits the neutral command snapshots. Match root, current-query native Bash call/result identities after input; ignore historical replay, synthetic and child work. Preserve exact command text and the native per-call textual result, including native rendering or truncation. This is final native output, not incremental stdout/stderr or reconstructed interleaving. Native error results are failed; unambiguous structured interruption is incomplete. Missing results close as incomplete after the observation drain; query cancellation does not overwrite an already observed native failure. Do not infer an exit code from rendered text or supply cwd/duration without qualified native fields. Preparation alone emits no command. Cold continuation must not reissue historical observations. This private translation does not enable public workspace admission, Read/Edit Items or Files ownership. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index d96dc02b1..c11468734 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -3,9 +3,7 @@ import { ExecutorTurns, type ExecutorEvent } from "./executor_protocol.js"; import { StructuredOutput } from "./structured_output.js"; import { Subagents } from "./subagents.js"; import type { Fact } from "./subagent_history.js"; -import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_directories.js"; import { getSessionInfo, query, startup, type HookCallback, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; -import { WorkspaceReads, type WorkspaceReadEvent } from "./workspace_reads.js"; import { Inputs, type InputEvent } from "./inputs.js"; import { executorResultUsage, resultUsage, type NativeUsage } from "./usage.js"; import { spawnNative } from "./native.js"; @@ -24,8 +22,6 @@ export { parseStart, type Start } from "./request.js"; export type Event = | ExecutorEvent | Fact - | WorkspaceDirectoryEvent - | WorkspaceReadEvent | MessageEvent | InputEvent | FunctionEvent @@ -38,7 +34,7 @@ export type Event = | { type: "result"; session_id: string; text: string } | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled"; engine_error_code?: string; session_id?: string; result_id?: string }; -export async function execute(request: Start | Prepare | ExecutorPrepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort), turns?: ExecutorTurns): Promise { +export async function execute(request: Start | Prepare | ExecutorPrepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), turns?: ExecutorTurns): Promise { const ownerEmit=emit; if(turns) emit=event=>turns.emit(event); const nativeFailure = new NativeFailure(); @@ -161,10 +157,6 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if ((hooksRequested && initialized.hooks_applied !== true) || children.length !== 1 || !nativeAlive || abort.signal.aborted) { throw new Error("preparation unavailable"); } - if(workspace) { - reads.bind(stream, request.cwd); - if (process.platform === "linux") await directories.bind(request.cwd); - } if (declarations?.some(server => "required" in server && server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); if(turns) { turns.configure(stream,(input,output)=>{ @@ -262,8 +254,6 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: inputs.close(); turns?.close(); functions.close(); - try { await directories.close(); } catch { failed = true; classifiedFailure = undefined; } - await reads.close(); stream?.close(); warm?.close(); abort.signal.removeEventListener("abort", closeInputs); diff --git a/packages/claude-sdk-adapter/src/main.ts b/packages/claude-sdk-adapter/src/main.ts index 86b12aa09..891e77080 100644 --- a/packages/claude-sdk-adapter/src/main.ts +++ b/packages/claude-sdk-adapter/src/main.ts @@ -1,6 +1,4 @@ import { ExecutorTurns } from "./executor_protocol.js"; -import { WorkspaceDirectories } from "./workspace_directories.js"; -import { WorkspaceReads } from "./workspace_reads.js"; import { Inputs } from "./inputs.js"; import { FunctionBridge } from "./function_bridge.js"; import { createInterface } from "node:readline"; @@ -28,8 +26,6 @@ try { await emit(invalid && (event.type === "error" || event.type === "result") ? { type: "error", code: "invalid_request" } : event); }; const functions = new FunctionBridge(output); - const reads = new WorkspaceReads(output, abort); - const directories = new WorkspaceDirectories(output, abort); const prompts = new Inputs(immediateInput(request)); const turns=request.type==="executor_prepare" ? new ExecutorTurns(emit,abort) : undefined; const incoming = (async () => { @@ -37,11 +33,7 @@ try { for await (const line of { [Symbol.asyncIterator]: () => input }) { if (Buffer.byteLength(line) > 1024 * 1024) throw new Error("Invalid input."); const value: unknown = JSON.parse(line); - if (value && typeof value === "object" && "type" in value && value.type === "workspace_read") { - reads.submit(value as Record); - } else if (value && typeof value === "object" && "type" in value && value.type === "workspace_directory") { - directories.submit(value as Record); - } else if(turns) { + if(turns) { if(!value || typeof value!=="object" || Array.isArray(value)) throw new Error("invalid_request"); const control=value as Record; if(control.type==="turn_start") await turns.start(control); @@ -58,7 +50,7 @@ try { } catch { invalid = request.type === "prepare"; abort.abort(); } })(); - try { await execute(request, output, abort, functions, prompts, reads, directories, turns); } + try { await execute(request, output, abort, functions, prompts, turns); } catch { await output({ type: "error", code: "execution_failed" }); } finally { prompts.close(); diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index a9f47bdac..2eed2d302 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -47,7 +47,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/workspace_directories.ts b/packages/claude-sdk-adapter/src/workspace_directories.ts deleted file mode 100644 index a55c12486..000000000 --- a/packages/claude-sdk-adapter/src/workspace_directories.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { join, isAbsolute } from "node:path"; -import { lstat, opendir, stat } from "node:fs/promises"; - -export type WorkspaceDirectoryEntry = { name: string; kind: "file" | "directory" | "symlink" | "other"; size_bytes?: number }; -export type WorkspaceDirectoryEvent = { type: "workspace_directory"; id: string } & ( - { entries: WorkspaceDirectoryEntry[]; truncated: boolean } | - { error: "invalid" | "busy" | "unavailable" | "uncertain" | "not_found" | "permission" } -); -export class WorkspaceDirectories { - private root?: string; - private stopped = false; - private pending?: Promise; - - constructor(private readonly emit: (event: WorkspaceDirectoryEvent) => Promise, private readonly abort: AbortController) {} - - async bind(root: string): Promise { - if (this.root || this.stopped || !isAbsolute(root) || !(await stat(root)).isDirectory()) throw new Error("directory listing unavailable"); - this.root = root; - } - - submit(value: Record): void { - if (typeof value.id !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(value.id)) throw new Error("invalid_request"); - const id = value.id; - let error: "invalid" | "busy" | "unavailable" | undefined; - if (Buffer.byteLength(JSON.stringify(value)) > 8192 || Object.keys(value).some(key => !["type", "id", "directory", "max_entries"].includes(key)) || - typeof value.directory !== "string" || /[\x00\\\r\n]/.test(value.directory) || - (value.directory !== "" && value.directory.split("/").some(part => !part || part === "." || part === "..")) || - !Number.isInteger(value.max_entries) || (value.max_entries as number) < 1 || (value.max_entries as number) > 1000) error = "invalid"; - else if (this.stopped || this.abort.signal.aborted || !this.root) error = "unavailable"; - else if (this.pending) error = "busy"; - if (error) { void this.emit({ type: "workspace_directory", id, error }).catch(() => this.abort.abort()); return; } - this.pending = this.list(id, value.directory as string, value.max_entries as number).finally(() => { this.pending = undefined; }); - } - - private async list(id: string, directory: string, maxEntries: number): Promise { - try { - const result = await this.enumerate(directory, maxEntries); - await this.emit({ type: "workspace_directory", id, ...result }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - const classified = code === "ENOENT" ? "not_found" : code === "ENOTDIR" ? "invalid" : - ["EACCES", "EPERM", "ELOOP"].includes(code ?? "") ? "permission" : "uncertain"; - await this.emit({ type: "workspace_directory", id, error: classified }).catch(() => this.abort.abort()); - if (classified === "uncertain") { this.stopped = true; this.abort.abort(); } - } - } - - private async enumerate(directory: string, maxEntries: number): Promise<{ entries: WorkspaceDirectoryEntry[]; truncated: boolean }> { - let path = this.root!; - // Listings never follow a link: one anywhere in the path is rejected like a regular file. - for (const part of directory ? directory.split("/") : []) { - path = join(path, part); - if (!(await lstat(path)).isDirectory()) throw Object.assign(new Error("not a directory"), { code: "ENOTDIR" }); - } - const dir = await opendir(path, { encoding: "buffer" as BufferEncoding }); - try { - const entries: WorkspaceDirectoryEntry[] = []; - while (true) { - if (this.abort.signal.aborted) throw new Error("owner closed"); - const entry = await dir.read(); - if (!entry) return { entries, truncated: false }; - if (entries.length === maxEntries) return { entries, truncated: true }; - const rawName: unknown = entry.name; - if (!Buffer.isBuffer(rawName)) throw new Error("invalid entry name"); - const name = rawName.toString("utf8"); - if (!Buffer.from(name).equals(rawName) || !name || /[\x00/]/.test(name) || name === "." || name === "..") throw new Error("unsupported entry name"); - const stat = await lstat(join(path,name)); - const kind = stat.isFile() ? "file" : stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "other"; - if (kind === "file" && (!Number.isSafeInteger(stat.size) || stat.size < 0)) throw new Error("invalid file size"); - entries.push({ name, kind, ...(kind === "file" ? { size_bytes: stat.size } : {}) }); - } - } finally { await dir.close(); } - } - - async close(): Promise { - this.stopped = true; - await this.pending; - this.root = undefined; - } -} diff --git a/packages/claude-sdk-adapter/src/workspace_reads.ts b/packages/claude-sdk-adapter/src/workspace_reads.ts deleted file mode 100644 index fc9c6aa4e..000000000 --- a/packages/claude-sdk-adapter/src/workspace_reads.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Query } from "@anthropic-ai/claude-agent-sdk"; -import { join } from "node:path"; - -export type WorkspaceReadEvent = { type: "workspace_read"; id: string } & ( - { data_base64: string; truncated: boolean } | { error: "invalid" | "busy" | "unavailable" | "uncertain" } -); - -export class WorkspaceReads { - private query?: Pick; - private root = ""; - private stopped = false; - private pending?: Promise; - - constructor(private readonly emit: (event: WorkspaceReadEvent) => Promise, private readonly abort: AbortController) {} - - bind(query: Pick, root: string): void { this.query = query; this.root = root; } - - submit(value: Record): void { - if (typeof value.id !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(value.id)) throw new Error("invalid_request"); - const id = value.id; - let error: "invalid" | "busy" | "unavailable" | undefined; - if (Buffer.byteLength(JSON.stringify(value)) > 8192 || Object.keys(value).some(key => !["type", "id", "path", "max_bytes"].includes(key)) || - typeof value.path !== "string" || !value.path || /[\x00\\\r\n]/.test(value.path) || value.path.split("/").some(part => !part || part === "." || part === "..") || - !Number.isInteger(value.max_bytes) || (value.max_bytes as number) < 1 || (value.max_bytes as number) > 1048576) error = "invalid"; - else if (this.stopped || this.abort.signal.aborted || !this.query) error = "unavailable"; - else if (this.pending) error = "busy"; - if (error) { void this.emit({ type: "workspace_read", id, error }).catch(() => this.abort.abort()); return; } - const query = this.query!; - const path = join(this.root, value.path as string); - const maxBytes = value.max_bytes as number; - this.pending = this.read(query, id, path, maxBytes).finally(() => { this.pending = undefined; }); - } - - private async read(query: Pick, id: string, path: string, maxBytes: number): Promise { - let timer: ReturnType | undefined; - const native = query.readFile(path, { maxBytes, encoding: "base64" }); - try { - const result = await Promise.race([native, new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("native read timeout")), 10000); - })]); - if (!result || result.encoding !== "base64" || result.absPath !== path || typeof result.contents !== "string" || - (result.truncated !== undefined && typeof result.truncated !== "boolean") || result.contents.length > 4 * Math.ceil(maxBytes / 3)) throw new Error("uncertain"); - const bytes = Buffer.from(result.contents, "base64"); - if (bytes.toString("base64") !== result.contents || bytes.length > maxBytes || (result.truncated && bytes.length !== maxBytes)) throw new Error("uncertain"); - await this.emit({ type: "workspace_read", id, data_base64: result.contents, truncated: result.truncated === true }); - } catch { - this.stopped = true; - await this.emit({ type: "workspace_read", id, error: "uncertain" }).catch(() => {}); - this.abort.abort(); - // SDK close resolves its retained request waiter; timeout alone is not native settlement. - query.close(); - await native.catch(() => null); - } finally { clearTimeout(timer); } - } - - async close(): Promise { this.stopped = true; await this.pending; } -} diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index 6b8bf3b10..f5ba51ad3 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -40,13 +40,6 @@ globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { if(mode === "native-exit")setTimeout(()=>child.kill("SIGTERM"),100); return { close, - async readFile(path,{maxBytes,encoding}) { - assert.equal(encoding,"base64"); - assert.equal(path,options.cwd+"/binary"); - process.send({kind:"read",path,maxBytes}); - await new Promise(resolve=>setTimeout(resolve,80)); - return {absPath:path,encoding,contents:Buffer.from([0,255,128,1]).toString("base64")}; - }, async initializationResult(){return {hooks_applied:true};}, async *[Symbol.asyncIterator](){ const command={type:"assistant",session_id:"native",parent_tool_use_id:null, @@ -206,15 +199,3 @@ for (const mode of ["missing-history", "startup-error", "early-start", "cancel-s }); } - -test("prepared native reader stays on the same query across Start", { timeout: 10000 }, async t => { - const { request, events, observations, send, wait, finish } = await launch(t, "release"); - send(request); await wait(() => events.some(event => event.type === "prepared")); - send({type:"workspace_read",id:"before",path:"binary",max_bytes:4}); - await wait(()=>observations.some(value=>value.kind === "read")); - send({type:"start",input: [{ content: [{ type: "input_text", text: "first" }] }]}); - await wait(()=>events.some(event=>event.type === "workspace_read")); - assert.deepEqual(events.find(event=>event.type === "workspace_read"),{type:"workspace_read",id:"before",data_base64:"AP+AAQ==",truncated:false}); - await finish(); - assert.equal(observations.filter(value=>value.kind === "spawn").length,1); -}); diff --git a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs deleted file mode 100644 index dd3abf7a1..000000000 --- a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile, symlink, rm, rename, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { WorkspaceDirectories } from "../dist/workspace_directories.js"; - -async function fixture(t) { - const root = await mkdtemp(join(tmpdir(), "oac-directories-")); - const workspace = join(root, "workspace"); - await mkdir(workspace); - const abort = new AbortController(); - let receipt; - const directories = new WorkspaceDirectories(event => { receipt?.(event); return Promise.resolve(); }, abort); - await directories.bind(workspace); - t.after(async () => { await directories.close(); await rm(root, { recursive: true, force: true }); }); - let sequence = 0; - return { root, workspace, directories, abort, list: async (directory = "", max_entries = 1000) => { - const result = new Promise(resolve => { receipt = resolve; }); - directories.submit({ type: "workspace_directory", id: `list-${++sequence}`, directory, max_entries }); - const value = await result; - await new Promise(resolve => setImmediate(resolve)); - return value; - } }; -} - -test("bounded literal metadata, missing directory, and traversal denial", async t => { - const f = await fixture(t); - await mkdir(join(f.workspace, "nested")); - await writeFile(join(f.workspace, "binary"), Buffer.from([0, 255, 0])); - await writeFile(join(f.workspace, "empty"), ""); - await writeFile(join(f.root, "protected"), "secret"); - await symlink(f.root, join(f.workspace, "outside")); - await symlink("nested", join(f.workspace, "inside")); - const value = await f.list(); - assert.equal(value.truncated, false); - assert.deepEqual(value.entries.sort((a, b) => a.name.localeCompare(b.name)), [ - { name: "binary", kind: "file", size_bytes: 3 }, { name: "empty", kind: "file", size_bytes: 0 }, - { name: "inside", kind: "symlink" }, { name: "nested", kind: "directory" }, { name: "outside", kind: "symlink" }, - ]); - assert.deepEqual((await f.list("nested")).entries, []); - assert.equal((await f.list("", 1)).truncated, true); - assert.equal((await f.list("absent")).error, "not_found"); - for (const path of ["inside", "outside", "outside/workspace", "binary"]) assert.equal((await f.list(path)).error, "invalid"); - for (const path of ["/", "a//b", ".", "..", "a/../b", "a\\b", "a\n"]) assert.equal((await f.list(path)).error, "invalid"); - for (const limit of [0, -1, 1001, 1.1]) assert.equal((await f.list("", limit)).error, "invalid"); -}); - -test("literal Unicode names are preserved and undecodable names fail explicitly", {skip:process.platform === "win32"}, async t => { - const f = await fixture(t); - await writeFile(join(f.workspace, "字\ufffd"), "ok"); - assert.deepEqual((await f.list()).entries, [{ name: "字\ufffd", kind: "file", size_bytes: 2 }]); - await writeFile(Buffer.concat([Buffer.from(f.workspace + "/"), Buffer.from([255])]), "invalid"); - assert.equal((await f.list()).error, "uncertain"); - assert.equal(f.abort.signal.aborted, true); -}); diff --git a/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs b/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs deleted file mode 100644 index 5d8fe943c..000000000 --- a/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { WorkspaceReads } from "../dist/workspace_reads.js"; - -const request = { type: "workspace_read", id: "read", path: "file", max_bytes: 4 }; -function fixture(readFile) { - const events=[],abort=new AbortController();let closed=false; - const reads=new WorkspaceReads(async event=>{events.push(event);},abort); - reads.bind({readFile,close(){closed=true;}},"/workspace"); - return {reads,events,abort,get closed(){return closed;}}; -} -for (const [name,contents,truncated] of [["binary","AP+AAQ==",false],["empty","",false],["prefix","AP+AAQ==",true]]) { - test(`native binary response: ${name}`,async()=>{ - const f=fixture(async(path,options)=>{assert.equal(path,"/workspace/file");assert.deepEqual(options,{maxBytes:4,encoding:"base64"});return {absPath:path,encoding:"base64",contents,...truncated&&{truncated}};}); - f.reads.submit(request);await f.reads.close(); - assert.deepEqual(f.events,[{type:"workspace_read",id:"read",data_base64:contents,truncated}]);assert.equal(f.abort.signal.aborted,false); - }); -} -test("bounds, admission and release retain the original native wait",async()=>{ - let settle;const f=fixture(()=>new Promise(resolve=>{settle=resolve;})); - for(const fields of [{path:"../escape"},{path:"/absolute"},{path:"a//b"},{path:"a\\b"},{max_bytes:0},{max_bytes:1048577},{path:"a".repeat(8192)}]) f.reads.submit({...request,...fields}); - assert.ok(f.events.every(event=>event.error === "invalid"));f.events.length=0; - f.reads.submit(request);f.reads.submit({...request,id:"busy"});assert.equal(f.events[0].error,"busy"); - let released=false;const release=f.reads.close().then(()=>{released=true;});await Promise.resolve();assert.equal(released,false); - f.reads.submit({...request,id:"closed"});assert.equal(f.events[1].error,"unavailable"); - settle({absPath:"/workspace/file",encoding:"base64",contents:""});await release;assert.equal(f.events[2].data_base64,""); -}); -for(const result of [null,{absPath:"/workspace/file",contents:"",encoding:"utf-8"},{absPath:"/other",contents:"",encoding:"base64"}, - {absPath:"/workspace/file",contents:"!",encoding:"base64"},{absPath:"/workspace/file",contents:"",encoding:"base64",truncated:true}]) { - test("ambiguous native responses fence the owner",async()=>{ - const f=fixture(async()=>result);f.reads.submit(request);await f.reads.close(); - assert.deepEqual(f.events,[{type:"workspace_read",id:"read",error:"uncertain"}]);assert.equal(f.abort.signal.aborted,true);assert.equal(f.closed,true); - }); -} From efc05af06a72685022463d146cbc6757cdd69539 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 23 Oct 2026 19:10:00 +0000 Subject: [PATCH 2/2] Drop removed read features from the Claude runtime check --- scripts/check-claude-sdk-runtime.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 9675881e2..16e079858 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 3); -assert.deepEqual(report.features, ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`);