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: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,11 @@ qualify this layout. The read-only deployment needs neither writer setting.

Transfer a complete bounded body in acknowledged 64 KiB frames before invoking
the existing installer, verify the declared digest, and run no model for upload.
Keep the existing private 50 MiB bound distinct from upstream protocol limits.
Keep the private 50 MiB transfer bound distinct from the official 5 MiB decoded
inline bound, which the API checks before any Runtime work. Files.create uses the
installer's explicit create mode: parents are created without following links and
an existing path is never replaced. Initial Session files and Skills keep the
installer's replace mode; do not change one caller's mode for another.
The dedicated Runtime excludes execution while receiving or applying a write;
malformed, incomplete or expired transfers cannot reach the installer. Exact
commit/rejection receipts release the mutation owner. Missing or ambiguous
Expand Down
8 changes: 8 additions & 0 deletions apps/parsar-daemon/internal/agent/workspace_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
"fmt"
)

type WorkspaceWriteResult struct {
Expand All @@ -22,3 +23,10 @@ var (
ErrWorkspaceWriteRejected = errors.New("workspace write rejected")
ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain")
)

// Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected:
// nothing was installed.
var (
ErrWorkspaceWriteDirectory = fmt.Errorf("%w: destination is a directory", ErrWorkspaceWriteRejected)
ErrWorkspaceWriteUnsafe = fmt.Errorf("%w: destination exists or its path is not a plain directory chain", ErrWorkspaceWriteRejected)
)
11 changes: 11 additions & 0 deletions apps/parsar-daemon/internal/dispatch/workspace_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int)
if err == nil && write.SizeBytes == int64(size) {
return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size}
}
for _, conflict := range []struct {
err error
reason string
}{
{agent.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory},
{agent.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe},
} {
if errors.Is(err, conflict.err) {
return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: conflict.reason}
}
}
for _, failure := range []struct {
err error
code string
Expand Down
26 changes: 26 additions & 0 deletions apps/parsar-daemon/internal/dispatch/workspace_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,29 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) {
t.Fatal("shutdown declared uncertain mutation settled")
}
}

func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) {
for helperError, reason := range map[string]string{
"destination_directory": proto.WorkspaceWriteReasonDirectory,
"unsafe_destination": proto.WorkspaceWriteReasonUnsafe,
"write_failed": "",
} {
t.Run(helperError, func(t *testing.T) {
r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`)
id := uuid.NewString()
for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} {
if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil {
t.Fatal(err)
}
}
if got := waitWorkspaceWrite(t, sender, id, "rejected"); got.ErrorCode != "write_rejected" || got.Reason != reason {
t.Fatal(got)
}
next := uuid.NewString()
if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)); err != nil {
t.Fatal(err)
}
waitWorkspaceWrite(t, sender, next, "ready")
})
}
}
15 changes: 12 additions & 3 deletions apps/parsar-daemon/internal/localworkspace/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by
operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second)
defer cancel()
digest := sha256.Sum256(data)
cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging)
// This writer serves only public Files.create, so it always selects the
// helper's create mode: create missing parents and never replace.
cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create")
cmd.Dir = "/"
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"}
cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:]))
Expand Down Expand Up @@ -96,8 +98,15 @@ func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error)
if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) {
return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil
}
if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil && (*wire.Error == "invalid_input" || *wire.Error == "write_failed") {
return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected
if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil {
switch *wire.Error {
case "invalid_input", "write_failed":
return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected
case "destination_directory":
return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteDirectory
case "unsafe_destination":
return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsafe
}
}
return agent.WorkspaceWriteResult{}, invalid
}
66 changes: 41 additions & 25 deletions apps/parsar-daemon/internal/localworkspace/write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func TestWriteRejectsUnsafeBindings(t *testing.T) {

func TestWriteReceiptAndCredentialBoundary(t *testing.T) {
t.Setenv("PARSAR_PRIVATE_CREDENTIAL", "synthetic-secret")
b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n")
// Files.create always selects the helper's explicit create mode.
b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n")
result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2})
if err != nil || result.SizeBytes != 3 {
t.Fatalf("write: %+v %v", result, err)
Expand Down Expand Up @@ -144,14 +145,22 @@ func TestWriteReceiptValidation(t *testing.T) {
`{"version":1,"outcome":"failed","error":"other"}`,
`{"version":1,"outcome":"completed","size_bytes":0} {}`,
`{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`,
`{"version":1,"outcome":"unknown","error":"unsafe_destination"}`,
`{"version":1,"outcome":"failed","size_bytes":0,"error":"destination_directory"}`,
} {
if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) {
t.Fatalf("unsafe receipt %s: %v", response, err)
}
}
for _, code := range []string{"invalid_input", "write_failed"} {
if _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0); !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
t.Fatal(err)
for code, want := range map[string]error{
"invalid_input": agent.ErrWorkspaceWriteRejected,
"write_failed": agent.ErrWorkspaceWriteRejected,
"destination_directory": agent.ErrWorkspaceWriteDirectory,
"unsafe_destination": agent.ErrWorkspaceWriteUnsafe,
} {
_, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0)
if err != want || !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
t.Fatal(code, err)
}
}
}
Expand All @@ -165,40 +174,47 @@ func TestLocalWriteNativeInstaller(t *testing.T) {
if err := b.bindWriter(helper, b.writer.staging); err != nil {
t.Fatal(err)
}
escape := filepath.Join(b.workspace, "escape")
if err := os.Symlink(b.writer.staging, escape); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil {
t.Fatal(err)
}
// Missing parents are created; an existing destination is never replaced.
for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} {
path := fmt.Sprintf("n1/n2/file-%d", size)
data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size]
got, err := b.WriteWorkspaceFile(t.Context(), "file", data)
got, err := b.WriteWorkspaceFile(t.Context(), path, data)
if err != nil || got.SizeBytes != int64(size) {
t.Fatalf("size %d: %+v %v", size, got, err)
}
actual, err := os.ReadFile(filepath.Join(b.workspace, "file"))
actual, err := os.ReadFile(filepath.Join(b.workspace, path))
if err != nil || !bytes.Equal(actual, data) {
t.Fatalf("size %d bytes differ: %v", size, err)
}
}
old := filepath.Join(b.workspace, "file")
if err := os.WriteFile(old, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
alias := filepath.Join(b.workspace, "alias")
if err := os.Link(old, alias); err != nil {
t.Fatal(err)
if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 {
t.Fatal("parent directory", info, err)
}
if _, err := b.WriteWorkspaceFile(t.Context(), "file", []byte("replacement")); err != nil {
t.Fatal(err)
}
if data, err := os.ReadFile(alias); err != nil || string(data) != "original" {
t.Fatal("hard-link contents changed", err)
}
escape := filepath.Join(b.workspace, "escape")
if err := os.Symlink(b.writer.staging, escape); err != nil {
t.Fatal(err)
}
for _, path := range []string{"escape/new", "escape", "missing/file"} {
if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
for path, want := range map[string]error{
"n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe,
"setup.txt": agent.ErrWorkspaceWriteUnsafe,
"escape": agent.ErrWorkspaceWriteUnsafe,
"escape/new": agent.ErrWorkspaceWriteUnsafe,
"n1/n2": agent.ErrWorkspaceWriteDirectory,
"n1": agent.ErrWorkspaceWriteDirectory,
} {
if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want {
t.Fatalf("%s: %v", path, err)
}
}
if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) {
t.Fatal("existing file changed", err)
}
if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" {
t.Fatal("untracked file changed", err)
}
if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil {
t.Fatal("known rejection blocked next write", err)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ describe("EnvironmentFileCreatePanel", () => {
expect(validInlineEnvironmentFilePath(`/workspace/${"界".repeat(1_363)}`)).toBe(false);
});

it("enforces the decoded 50 MiB boundary and encodes multibyte bytes as standard Base64", () => {
it("enforces the decoded 5 MiB boundary and encodes multibyte bytes as standard Base64", () => {
expect(maxInlineEnvironmentFileBytes).toBe(5 * 1024 * 1024);
expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes)).toBe(true);
expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes + 1)).toBe(false);
expect(validInlineEnvironmentFileByteLength(-1)).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function EnvironmentFileCreatePanel({
<FilePlus2 size={14} strokeWidth={1.5} aria-hidden="true" />
<div>
<strong id="environment-file-create-heading">Add inline Workspace file</strong>
<small>Explicit local selection · one write attempt · 50 MiB maximum</small>
<small>Explicit local selection · one write attempt · 5 MiB maximum</small>
</div>
</header>

Expand All @@ -121,7 +121,7 @@ export function EnvironmentFileCreatePanel({
</p>
) : null}
{!sizeValid ? (
<p className="environment-file-create-validation">This file exceeds the 50 MiB inline-content limit.</p>
<p className="environment-file-create-validation">This file exceeds the 5 MiB inline-content limit.</p>
) : null}

<label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type {
EnvironmentFile,
} from "@agents-core-web/agents-client";

export const maxInlineEnvironmentFileBytes = 50 * 1024 * 1024;
// Core applies the official 5 MiB decoded bound to inline data; Source File copies keep 50 MiB.
export const maxInlineEnvironmentFileBytes = 5 * 1024 * 1024;

export const inlineEnvironmentFileReadFailure =
"The selected local file could not be read. The selection and destination are unchanged; nothing was sent to Core.";
Expand Down Expand Up @@ -61,7 +62,7 @@ export function validInlineEnvironmentFileByteLength(bytes: number): boolean {
*/
export function arrayBufferToStandardBase64(buffer: ArrayBuffer): string {
if (!validInlineEnvironmentFileByteLength(buffer.byteLength)) {
throw new RangeError("Environment files must be at most 50 MiB.");
throw new RangeError("Inline Environment files must be at most 5 MiB.");
}

const bytes = new Uint8Array(buffer);
Expand Down Expand Up @@ -103,7 +104,7 @@ export async function submitInlineEnvironmentFile(
return {
kind: "validation_error",
draft,
message: "The selected file must contain at most 50 MiB.",
message: "The selected file must contain at most 5 MiB.",
};
}

Expand All @@ -120,7 +121,7 @@ export async function submitInlineEnvironmentFile(
return {
kind: "validation_error",
draft,
message: "The decoded file content must contain at most 50 MiB.",
message: "The decoded file content must contain at most 5 MiB.",
};
}

Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ paths start at `/vaults`, not `/agents/vaults`.
| sessions.subagents.turns | retrieve, list | Implemented; child Turns carry the Session's Agent ID and are not Session Turns |
| sessions.subagents.turns.items | list | Implemented; scoped persisted reads |
| environments | retrieve | Three-harness colocated self-hosted implementation and qualified Docker hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps |
| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. [Aligned](environment-files.md#wire-alignment--september-23-2026) the 201 status, page envelope, query keys, empty pages for non-directory paths on local workspace readers, sampled path/token errors and pending hosted rejection; recursion, parent creation, overwrite and other errors remain partial |
| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. [Aligned](environment-files.md#wire-alignment--september-23-2026) the 201 status, page envelope, query keys, empty pages for non-directory paths on local workspace readers, sampled path/token errors and pending hosted rejection; [aligned](environment-files.md#write-semantics--september-23-2026) parent creation, no-replacement and the 5 MiB inline bound; recursion and other errors remain partial |
| environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline/referenced Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps |
| vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing |
| vaults.credentials | create, retrieve, update, list, delete | Static-bearer and OAuth create/retrieve/list/replacement/deletion with scoped encrypted storage and dispatch-time refresh; Session attachment and exact-URL HTTPS MCP binding; archive semantics and full hosted lifecycle parity remain missing |
Expand Down
Loading
Loading