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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ compatibility is not complete.
| Storage | SQLite; one process; at-least-once restart recovery |

Important gaps include interrupt propagation, durable per-step runtime output,
session-scoped workspaces, retries with side-effect idempotency, MCP execution,
files/skills/memory, resolved multiagent orchestration, and distributed workers.
durable sandbox checkpoint/restore across process restart, retries with
side-effect idempotency, MCP execution, files/skills/memory, resolved multiagent
orchestration, and distributed workers.
See the [roadmap](docs/roadmap.md).

## Quick start
Expand Down Expand Up @@ -119,8 +120,11 @@ go run ./cmd/managed-agent serve

Docker sandboxes use `--network none` by default, but containers still share
the host kernel and this path has not been audited for hostile multi-tenant
workloads. Sandboxes are currently provisioned per run, so filesystem state
does not persist across session turns.
workloads. Sandboxes are scoped to the session: the first run needing tools
provisions one and later runs in the same session reuse it, so filesystem state
persists across turns; the sandbox is released when the session is deleted. The
manager is in-memory, so a process restart does not restore an idle session's
sandbox.

## Documentation

Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ The strongest current risks are semantic rather than structural:
effect without a durable journal of the prior attempt.
3. Pending client actions are encoded in events and stop reasons rather than a
first-class durable `pending_actions` model.
4. Sandboxes are per run rather than per session, so workspace continuity is
not yet part of the session contract.
4. Sandboxes are session-scoped: a session's logical sandbox is provisioned on
first tool use, reused across its runs, and released on session deletion.
The manager is in-memory, so a process restart does not restore an idle
session's workspace, and there is no durable checkpoint, quota, or eviction
policy yet.
5. `SessionService` currently combines session CRUD, admission, dispatch, and
completion orchestration. These responsibilities should be separated before
introducing multiple workers or richer retry behavior.
Expand Down
38 changes: 32 additions & 6 deletions docs/architecture/runtime-and-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,38 @@ Containers share the host kernel. This provider has not been audited for
hostile multi-tenant use; stronger isolation such as gVisor or a remote sandbox
can be added behind the same provider interface.

## Current lifecycle limitation

Sandboxes are provisioned per run and destroyed when the run ends. Files
created by tools do not persist across session turns. Moving to a
session-scoped workspace requires an explicit ownership, checkpoint, quota, and
cleanup model rather than simply retaining temporary directories.
## Session-scoped ownership

A sandbox is scoped to the session, not to a single run. The first run in a
session that needs tools provisions a logical sandbox; every later run in the
same session reuses that same instance, so filesystem state a tool creates in
one run is visible to the next. Different sessions acquire under different keys
and never share a sandbox, so they stay isolated even when they use the same
agent and environment.

Ownership lives in a session-scoped manager that wraps the provider inside the
`internal/sandbox` package: acquisition provisions on first use and returns the
cached instance afterwards; release destroys it. The `AgentRuntime` is unaware
of this — the application resolves the sandbox and passes it in the run request.

Entering idle does not tear the sandbox down; it stays live between turns.
Deleting the session releases it, running the provider teardown exactly once. A
provisioning failure is not cached, so a later run may retry.

The manager holds sandboxes in memory. Restart does not restore an idle
session's sandbox: a process restart starts from an empty workspace, and the
first run after restart provisions a fresh one. Durable checkpoint/restore is
not implemented in this slice. Quotas and eviction are also out of scope here.

This is a process-boundary limitation, not just a persistence gap. Because
ownership lives only in the in-memory manager, a new process cannot reattach to
sandboxes an earlier process provisioned. A crash or an ungraceful restart
therefore leaves those provider resources — Docker containers or local temp
directories — orphaned, since the only code that would tear them down (`Release`
on session deletion) died with the process. Nothing reclaims them until an
external cleanup step or a reaper exists, and neither is built yet. Reclaiming
in-flight sandboxes on shutdown (a shutdown manager or reaper) is out of scope
for this slice.

## Streaming previews

Expand Down
3 changes: 2 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ tests in `internal/httpapi/sdk_golden_test.go`.
| Custom-tool handoff (`agent.custom_tool_use` → park → `user.custom_tool_result` → resume) | partial | `TestWorkshop_CustomToolHandoff`, `TestSessionService_CustomToolParksAndResumes`, `TestAgentCore_CustomToolParksWithRequiresAction`, `TestFake_CustomToolResultEndsIdle`, `TestProjectMessages_CustomToolResultPairing` | A custom tool call parks the run at `session.status_idle` with `stop_reason.type == "requires_action"` and `event_ids` naming the committed `agent.custom_tool_use`; a `user.custom_tool_result` referencing that id resumes a fresh run to `end_turn`. End-to-end proven; the exact `requires_action` payload shape is unconfirmed against the official wire. |
| Built-in tool run end-to-end via the app layer | partial | `TestSessionService_BuiltinToolRunEndToEnd` | A session-driven run provisions a sandbox, executes a built-in tool, and reaches `end_turn` through the durable run path. |
| `always_ask` permission policy on built-in tools | partial | `TestAgentCore_AlwaysAskBuiltinParks` | An `always_ask` built-in call parks the run (`agent.tool_use{evaluated_permission:"ask"}` + `requires_action`); the **resume** path (`user.tool_confirmation` → projected `tool_result` + built-in execution) is not wired. `ProjectMessages` drops the dangling `tool_use` so the parked call never poisons a real request (`TestProjectMessages_DropsDanglingToolUse`, `TestProjectMessages_DropsDanglingCustomToolUse`). |
| Local sandbox (`Provider`/`Sandbox`, restricted local process) | partial | `TestLocal_ExecEcho`, `TestLocal_FileRoundTripAndConfinement`, `TestLocal_Timeout` | `internal/sandbox` provides a two-layer interface and a local-process default that confines paths to a work dir, clears the environment, applies a timeout, and caps output. **Dev-grade guardrail, not a security boundary — do not run untrusted code.** The sandbox is **per-run**: it is provisioned at the start of each run and destroyed when the run ends, so tool-produced file state does **not** persist across turns. Session-scoped persistence remains a later slice behind the same interface. |
| Session-scoped sandbox lifecycle (`internal/sandbox` `SessionManager`) | partial | `TestSessionManager_ReusesSandboxPerSession`, `TestSessionManager_IsolatesSessions`, `TestSessionManager_ReleaseDestroysExactlyOnce`, `TestSessionService_SandboxPersistsAcrossRuns`, `TestSessionService_SandboxIsolatedBetweenSessions`, `TestSessionService_SandboxProvisionedOncePerSession`, `TestSessionService_IdleDoesNotDestroySandbox`, `TestSessionService_DeleteReleasesSandboxExactlyOnce` | A sandbox is **scoped to the session**, not the run. The first run needing tools provisions one logical sandbox; later runs in the same session reuse it, so tool-produced file state **persists across turns**. Different sessions get distinct sandboxes and stay isolated. Entering idle does not destroy the sandbox; deleting the session releases it exactly once. Ownership lives in a session-scoped manager that wraps the provider inside the `sandbox` package; `AgentRuntime` receives a resolved sandbox and is unaware of the lifecycle. The manager is in-memory: a process restart does not restore an idle session's sandbox, and there is no durable checkpoint, quota, or eviction yet. |
| Local sandbox (`Provider`/`Sandbox`, restricted local process) | partial | `TestLocal_ExecEcho`, `TestLocal_FileRoundTripAndConfinement`, `TestLocal_Timeout` | `internal/sandbox` provides a two-layer interface and a local-process default that confines paths to a work dir, clears the environment, applies a timeout, and caps output. **Dev-grade guardrail, not a security boundary — do not run untrusted code.** Sandboxes are session-scoped (see the row above): provisioned on first tool use and reused across the session's runs. |
| Docker sandbox (real-isolation `Provider`, opt-in) | partial | `TestDocker_*` (skipped without a daemon), `TestResolveSandboxProvider_DefaultsToLocal` | The same `Provider`/`Sandbox` interface has a Docker-backed implementation (shells out to the `docker` CLI, no extra module dependency). It gives **real isolation**: each sandbox is a container with its own Linux namespaces/cgroups, a separate filesystem, and `--network none` by default. Selected at startup via `MANAGED_AGENT_SANDBOX=docker` (default is local); image via `MANAGED_AGENT_SANDBOX_IMAGE` (defaults `alpine:latest`). gVisor (`--runtime=runsc`) can layer under the same interface later with no interface change. Not audited for hostile multi-tenant use (shared host kernel). Docker tests are gated on a running daemon and skip in default offline CI. |
| MCP toolsets | unsupported | `TestParseTools_BuiltinCustomMCP` | Parsed into `domain.ToolSet` but never resolved or executed. |

Expand Down
7 changes: 5 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ surface-area compatibility.

## 3. Session continuity

- Move from per-run to session-scoped workspaces with explicit lifecycle and
cleanup policies.
- Add durable checkpoint/restore so an idle session's sandbox survives a process
restart, plus quota and eviction policies for session-scoped workspaces.
- Add context compaction, token usage accounting, and model-request spans.
- Persist resumable runtime checkpoints where the public contract requires
continuity.
Expand Down Expand Up @@ -68,6 +68,9 @@ When real deployment requirements demand it:
- Atomic input/run admission and single-node restart recovery.
- Multi-step model/tool loop.
- Local sandbox plus optional Docker provider.
- Session-scoped sandbox ownership: reused across a session's runs, isolated
between sessions, released on session deletion (in-memory manager; no durable
restore yet).
- `bash`, `read`, `write`, `edit`, `glob`, and `grep` execution.
- Custom-tool handoff with `requires_action`.
- Opt-in streaming preview of `agent.message`.
Expand Down
60 changes: 60 additions & 0 deletions internal/app/sandbox_test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package app

import (
"context"
"sync/atomic"

"github.com/yanpgwang/managed-agent-go/internal/sandbox"
)

// provisionCountingProvider wraps a Provider and counts Provision calls so a
// test can assert a session provisions its logical sandbox exactly once across
// repeated runs.
type provisionCountingProvider struct {
inner sandbox.Provider
provisions atomic.Int64
}

func (p *provisionCountingProvider) Provision(ctx context.Context, spec sandbox.Spec) (sandbox.Sandbox, error) {
p.provisions.Add(1)
return p.inner.Provision(ctx, spec)
}

func (p *provisionCountingProvider) count() int64 { return p.provisions.Load() }

// destroyCountingProvider hands out sandboxes that count their own Destroy calls
// so a test can assert session deletion tears the sandbox down exactly once.
type destroyCountingProvider struct {
inner sandbox.Provider
destroys atomic.Int64
}

func (p *destroyCountingProvider) Provision(ctx context.Context, spec sandbox.Spec) (sandbox.Sandbox, error) {
box, err := p.inner.Provision(ctx, spec)
if err != nil {
return nil, err
}
return &destroyCountingSandbox{inner: box, provider: p}, nil
}

func (p *destroyCountingProvider) destroyCount() int64 { return p.destroys.Load() }

type destroyCountingSandbox struct {
inner sandbox.Sandbox
provider *destroyCountingProvider
}

func (s *destroyCountingSandbox) Exec(ctx context.Context, cmd sandbox.Command) (*sandbox.Result, error) {
return s.inner.Exec(ctx, cmd)
}
func (s *destroyCountingSandbox) ReadFile(ctx context.Context, path string) ([]byte, error) {
return s.inner.ReadFile(ctx, path)
}
func (s *destroyCountingSandbox) WriteFile(ctx context.Context, path string, data []byte) error {
return s.inner.WriteFile(ctx, path, data)
}
func (s *destroyCountingSandbox) Root() string { return s.inner.Root() }
func (s *destroyCountingSandbox) Destroy(ctx context.Context) error {
s.provider.destroys.Add(1)
return s.inner.Destroy(ctx)
}
Loading