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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ on:
- "packages/cli/**"
- "packages/opencode-plugin/**"
- "packages/claude-sdk-adapter/**"
- "packages/mcode-harness/**"
- "packages/eslint-config/**"
- "packages/tsconfig/**"
- "scripts/**"
Expand All @@ -42,6 +43,7 @@ on:
- "packages/cli/**"
- "packages/opencode-plugin/**"
- "packages/claude-sdk-adapter/**"
- "packages/mcode-harness/**"
- "packages/eslint-config/**"
- "packages/tsconfig/**"
- "scripts/**"
Expand Down Expand Up @@ -116,7 +118,7 @@ jobs:
apps/web/*)
web=true
;;
packages/cli/*|packages/opencode-plugin/*|packages/claude-sdk-adapter/*)
packages/cli/*|packages/opencode-plugin/*|packages/claude-sdk-adapter/*|packages/mcode-harness/*)
cli=true
;;
packages/eslint-config/*)
Expand Down Expand Up @@ -237,7 +239,7 @@ jobs:

- run: pnpm install --frozen-lockfile

- run: make check-cli
- run: make check-cli check-mcode-harness

check-gate:
needs: [changes, check-go, check-store, check-web, check-cli]
Expand Down
25 changes: 22 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,17 @@ the same transaction as its Session and creation identity when this resource is
implemented. Keep mutable connection/registration state out of immutable
configuration; replacement ownership must fence stale observations.

The accepted delivery baseline is the shared Codex/Claude single-Agent path: Session
The current MVP covers Codex, Claude Code and MiniMax Code through the shared
single-Agent path: Session
creation, environment preparation, native execution, files/artifacts, cancellation,
reconnection/recovery queries, and standalone deployment acceptance. Select each
bounded task from the complete board; nonblocking local improvements stay queued.
Authentication, tenant/credential isolation, state consistency and data loss remain
material acceptance requirements. Additional harness implementations and protocol Subagent execution
remain queued without changing the complete pinned protocol target.
material acceptance requirements. Optional feature equality is not required. After
the three profiles pass merged-main validation, publish the results, limitations
and backlog, then stop development until new user direction. Additional harness
implementations and protocol Subagent execution remain queued without changing the
complete pinned protocol target.

The current hosted architecture is V1: Core runs independently; each Environment
sandbox contains its daemon, selected native harness, local tools and workspace.
Expand Down Expand Up @@ -1983,6 +1987,21 @@ settles the process and output, and continuation requires the exact owned native
history. Do not infer history IDs or qualify hosted execution from this text
profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md).

The MiniMax workspace profile retains the published CLI and isolates native
workspace tools behind its standard MCP client. The process and native Session
share one private control directory; public workspace files cannot configure that
process or become privileged project instructions. A trusted adapter-owned bridge
runs the original six tool implementations in the upstream Linux sandbox, with no
unsandboxed fallback. Keep native history bound to the control directory and Files/
Artifacts bound to the public workspace. Core and shared file helpers remain engine
neutral. This internal MCP transport does not admit public MCP configuration.
Record published CLI and worker-source provenance separately; complete
[workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) before enabling
hosted execution. The standalone companion uses its own npm lock; `make check`
runs its lifecycle tests and script checks, while its exact-source Linux build and
Docker qualification (including `native.test.mjs` under both network policies)
are required when the companion changes.

Claude hosted functions compose the existing SDK function bridge with the native
workspace sandbox. Only declared function tools and the verified native tool
inventory are available. The bundle advertises this combination separately from
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ dev: dev-db

# The full gate runs independent API tests once, after their isolated build.
check: GO_TEST_EXCLUDE = $(if $(strip $(GO_TEST_RUN) $(GO_TEST_ARGS)),,github.com/MiniMax-AI-Dev/parsar/services/agents-api/% github.com/MiniMax-AI-Dev/parsar/packages/agents-client/%)
check: check-go check-store check-web check-cli check-hygiene check-installer check-agents-api check-agents-executor check-agents-harness
check: check-go check-store check-web check-cli check-hygiene check-installer check-agents-api check-agents-executor check-agents-harness check-mcode-harness
@printf 'Parsar harness checks passed.\n'

check-setup:
Expand Down Expand Up @@ -132,6 +132,12 @@ check-cli: check-setup node-deps
check-installer:
bash scripts/check-installer.sh

.PHONY: check-mcode-harness
check-mcode-harness:
node --test packages/mcode-harness/*.test.mjs
@for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done
bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh

check-hygiene: check-setup
@for polluted in .parsar logs state cache config; do \
if [[ -e "$$polluted" ]]; then \
Expand Down
7 changes: 5 additions & 2 deletions apps/parsar-daemon/internal/agent/mcode/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,14 @@ func (s *Session) emitTool(update toolUpdate) {
if update.RawInput == nil {
update.RawInput = previous.RawInput
}
if s.req.ObserveToolObservations {
started = workspaceToolObservation(previous, "before") != nil
}
if !started {
s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: "before", Args: update.RawInput})
s.emitToolStage(update, "before")
}
if update.Status == "completed" || update.Status == "failed" {
s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: "after", Result: map[string]any{"output": update.RawOutput, "status": update.Status}})
s.emitToolStage(update, "after")
delete(s.tools, update.ID)
s.completedTools[update.ID] = true
} else {
Expand Down
131 changes: 131 additions & 0 deletions apps/parsar-daemon/internal/agent/mcode/preparation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package mcode

import (
"context"
"fmt"
"strings"
"sync"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
)

type prepared struct {
mu sync.Mutex
session *Session
ready chan struct{}
started chan struct{}
failure error
closed bool
binding *preparedStart
}

type preparedStart struct {
runID, prompt string
out chan<- proto.Envelope
}

func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory {
return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) {
if ctx == nil {
ctx = context.Background()
}
if req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.ObserveSubagentIdentities {
return nil, fmt.Errorf("mcode: preparation cannot contain input or product context")
}
opts, err := prepareWorkspaceOptions(ctx, config, req)
if err != nil {
return nil, err
}
s, err := launch(ctx, req, opts, config.Binary, nil)
if err != nil {
return nil, err
}
p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})}
go s.run(p)
<-p.ready
if p.failure != nil {
<-s.finished
return nil, p.failure
}
if ctx.Err() != nil {
_ = p.Close()
return nil, ctx.Err()
}
return p, nil
}
}

func (p *prepared) Start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (agent.Session, error) {
if ctx == nil {
ctx = context.Background()
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed || p.binding != nil {
return nil, fmt.Errorf("mcode: preparation is no longer available")
}
if strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" || out == nil || ctx.Err() != nil {
return nil, fmt.Errorf("mcode: start requires a live context, identity, prompt and output")
}
select {
case <-p.session.process.Context().Done():
return nil, fmt.Errorf("mcode: prepared process ended")
case <-p.session.exited:
return nil, fmt.Errorf("mcode: prepared process exited")
default:
}
p.binding = &preparedStart{runID: runID, prompt: prompt, out: out}
close(p.started)
return p.session, nil
}

func (p *prepared) Close() error {
p.mu.Lock()
if p.binding != nil {
p.mu.Unlock()
return nil
}
p.closed = true
p.mu.Unlock()
return p.session.Cancel(context.Background())
}

func (p *prepared) Cancel(ctx context.Context) error {
p.mu.Lock()
p.closed = true
p.mu.Unlock()
return p.session.Cancel(ctx)
}

func (p *prepared) CancellationOutcome() proto.DonePayload { return p.session.CancellationOutcome() }

// The Session goroutine owns preparation, input submission and terminal output.
func (p *prepared) awaitStart(err error) error {
p.failure = err
close(p.ready)
if err != nil {
return err
}
for {
var ended error
select {
case <-p.started:
case <-p.session.process.Context().Done():
ended = p.session.process.Context().Err()
case _, ok := <-p.session.frames:
if ok {
continue
}
ended = fmt.Errorf("mcode: prepared process exited")
}
p.mu.Lock()
if b := p.binding; b != nil {
p.session.req.RunID, p.session.req.Prompt, p.session.out = b.runID, b.prompt, b.out
} else {
p.closed = true
}
p.mu.Unlock()
return ended
}
}
123 changes: 123 additions & 0 deletions apps/parsar-daemon/internal/agent/mcode/preparation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package mcode

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
)

func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) {
t.Helper()
r := executionRequest(t)
r.RunID, r.Prompt, r.ConversationID = "", "", ""
r.DisableExecutionEnvironment = false
r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "disabled"}
r.WorkDir = t.TempDir()
record := filepath.Join(t.TempDir(), "calls")
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
binary := filepath.Join(t.TempDir(), "native")
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
script := "#!/bin/sh\nexport PARSAR_MCODE_TEST_HELPER=prepared\nexport PARSAR_MCODE_TEST_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "disabled", Scratch: t.TempDir(), ProtectedDirs: []string{os.Getenv("PARSAR_HOME")}}, r, record
}

func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) {
c, r, record := workspaceFixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
resource, err := NewPreparationFactory(c)(ctx, r)
if err != nil {
t.Fatal(err)
}
p := resource.(*prepared)
t.Cleanup(func() { _ = p.Cancel(context.Background()) })
raw, err := os.ReadFile(record)
if err != nil || strings.Contains(string(raw), "session/prompt") {
t.Fatalf("preparation consumed input: %q %v", raw, err)
}
if p.session.opts.Dir == r.WorkDir || !strings.HasPrefix(p.session.opts.Dir, p.session.opts.DataDir+string(filepath.Separator)) {
t.Fatal("native cwd is not private")
}
out := make(chan proto.Envelope)
var wg sync.WaitGroup
winners := make(chan bool, 8)
for range 8 {
wg.Add(1)
go func() { defer wg.Done(); _, err := p.Start(ctx, "run", "input", out); winners <- err == nil }()
}
wg.Wait()
close(winners)
n := 0
for winner := range winners {
if winner {
n++
}
}
if n != 1 {
t.Fatalf("owners=%d", n)
}
if err := p.Close(); err != nil {
t.Fatal(err)
}
deltas, done := 0, 0
for e := range out {
if e.Type == proto.TypeError {
t.Fatalf("execution: %s", e.Payload)
}
if e.Type == proto.TypeDelta {
deltas++
}
if e.Type == proto.TypeDone {
done++
select {
case <-p.session.exited:
default:
t.Fatal("Done before native settlement")
}
var d proto.DonePayload
_ = json.Unmarshal(e.Payload, &d)
if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" {
t.Fatal("native identity lost")
}
}
}
if deltas != 100 || done != 1 {
t.Fatalf("deltas=%d done=%d", deltas, done)
}
raw, _ = os.ReadFile(record)
if strings.Count(string(raw), "session/prompt") != 1 {
t.Fatalf("inputs=%s", raw)
}
}

func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) {
c, r, _ := workspaceFixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
resource, err := NewPreparationFactory(c)(ctx, r)
if err != nil {
t.Fatal(err)
}
if err = resource.Close(); err != nil {
t.Fatal(err)
}
if _, err = resource.Start(ctx, "run", "input", make(chan proto.Envelope)); err == nil {
t.Fatal("released preparation started")
}
if err = resource.Close(); err != nil {
t.Fatal(err)
}
}
Loading
Loading