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
28 changes: 16 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ to recover a lost creation response. Session metadata updates require a supplied
metadata field, with null/empty clearing it. Validate an empty update before any
resource lookup, after authentication.

List order parsing distinguishes omission from an explicit empty value. Lists read by
the shared list parser and single-resource routes ignore unknown query keys; a
repeated supported list key still rejects. The Environment Files list keeps its own
strict key parser and still rejects unknown keys; that difference is deferred. Reuse the shared parser and error serializer, preserving the observed
Beta, Files and Skills error fields and per-family limit bounds rather than applying
one policy to every resource. Change page bounds, cursor ownership or parent lookup
order only with owned evidence for that family. Record uncertain range/lookup
behavior separately; do not reproduce observed upstream server failures as
compatibility behavior. See `contracts/agents-api/list-query-semantics.md` for the
bounded evidence.
List order parsing distinguishes omission from an explicit empty value. Lists and
single-resource routes ignore unknown query keys; a repeated supported list key
still rejects. The Environment Files list keeps its own path and cursor parsing but
uses the same unknown-key and duplicate-key rules, except that it still rejects
malformed query encoding (such as `%GG` or `;` separators) that the shared lists
drop. Reuse the shared parser and error serializer, preserving the observed Beta, Files and Skills error fields and
per-family limit bounds rather than applying one policy to every resource. Change
page bounds, cursor ownership or parent lookup order only with owned evidence for
that family. Record uncertain range/lookup behavior separately; do not reproduce
observed upstream server failures as compatibility behavior. See
`contracts/agents-api/list-query-semantics.md` for the bounded evidence.

Report validation failures with official evidence through the typed field error,
which emits `invalid_request_error` with the observed param and message; keep
Expand Down Expand Up @@ -824,8 +825,11 @@ Revoke the scoped read transport credential on
completion or failure. Runtime retains uncertain cleanup ownership and capacity;
this does not require a second durable Core owner registry or establish remote
write retirement. Public Files.list delegates workspace access to this reader;
the API owns tenant authorization, path validation and protocol pagination. Keep
partial directory coverage and unverified defaults explicit in the Files contract.
the API owns tenant authorization, path validation and protocol pagination. Only
the reader's distinct `not_directory` result (a missing path, a regular file or an
unfollowed symlink) becomes an empty page; root, permission, transport and
uncertain failures keep their errors. Keep partial directory coverage and
unverified defaults explicit in the Files contract.

Source Files belong to the execution project and have an independent lifecycle
from copied workspace files. Store immutable source metadata and PostgreSQL large
Expand Down
3 changes: 3 additions & 0 deletions apps/parsar-daemon/internal/agent/workspace_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ var (
ErrWorkspaceReadBusy = errors.New("workspace read busy")
ErrWorkspaceReadInvalid = errors.New("workspace read invalid")
ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain")
// ErrWorkspaceNotDirectory reports that a directory request's own path is
// missing, a regular file or a symbolic link; the link was not followed.
ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory")
)
52 changes: 52 additions & 0 deletions apps/parsar-daemon/internal/dispatch/local_directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,55 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa
t.Fatal("read result missing", id)
return proto.WorkspaceReadResultPayload{}
}

func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) {
workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory")
script := `#!/bin/sh
case "$2" in
missing) printf '%s' '{"version":1,"error":"not_directory"}' ;;
invalid) printf '%s' '{"version":1,"error":"invalid_path"}' ;;
gone) printf '%s' '{"version":1,"error":"not_found"}' ;;
denied) printf '%s' '{"version":1,"error":"permission_denied"}' ;;
broken) printf '%s' '{"version":1,"error":"native_error"}' ;;
esac
`
if err := os.WriteFile(helper, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
environment, session := uuid.NewString(), uuid.NewString()
binding, err := localworkspace.New(environment, session, workspace, helper)
if err != nil {
t.Fatal(err)
}
reg := agent.NewRegistry()
reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) {
return nil, errors.New("must not start a model")
})
reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) {
return nil, errors.New("must not prepare a harness")
})
sender := &recSender{}
r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, LocalWorkspace: binding})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Shutdown(context.Background()) })
request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true}
if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil {
t.Fatal(err)
}
ready := waitPreparationStatus(t, sender, "idle", "ready", "")
for path, want := range map[string]proto.WorkspaceReadResultPayload{
"missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory},
"invalid": {Outcome: "rejected", ErrorCode: "invalid_request"},
"gone": {Outcome: "rejected", ErrorCode: "not_found"},
"denied": {Outcome: "rejected", ErrorCode: "permission_denied"},
"broken": {Outcome: "unknown", ErrorCode: "read_unconfirmed"},
} {
read := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Operation: "directory", Path: path, MaxEntries: 10}
_ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, path, read))
if got := waitWorkspaceRead(t, sender, path); got.Outcome != want.Outcome || got.ErrorCode != want.ErrorCode || got.Directory != nil || got.CloseAcknowledged {
t.Fatal("native directory result changed", path, got)
}
}
}
1 change: 1 addition & 0 deletions apps/parsar-daemon/internal/dispatch/workspace_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func workspaceReadResult(read agent.WorkspaceReadResult, err error, limit int) p
{agent.ErrWorkspaceReadUnavailable, "resource_unavailable"},
{agent.ErrWorkspaceReadBusy, "read_capacity"},
{agent.ErrWorkspaceReadInvalid, "invalid_request"},
{agent.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory},
{fs.ErrNotExist, "not_found"},
{fs.ErrPermission, "permission_denied"},
} {
Expand Down
17 changes: 17 additions & 0 deletions apps/parsar-daemon/internal/localworkspace/binding_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package localworkspace

import (
"errors"
"io/fs"
"os"
"path/filepath"
"testing"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/google/uuid"
)
Expand Down Expand Up @@ -76,6 +79,20 @@ func TestLocalHelperCannotInheritCredentials(t *testing.T) {
}
}

func TestDirectoryClassifiesNativeErrors(t *testing.T) {
for code, want := range map[string]error{
"not_directory": agent.ErrWorkspaceNotDirectory,
"not_found": fs.ErrNotExist,
"permission_denied": fs.ErrPermission,
"invalid_path": agent.ErrWorkspaceReadInvalid,
"native_error": agent.ErrWorkspaceReadUncertain,
} {
if _, err := decodeDirectory([]byte(`{"version":1,"error":"`+code+`"}`), 2); !errors.Is(err, want) {
t.Fatal("native error classification changed", code, err)
}
}
}

func TestDirectoryRejectsMalformedOrIncompleteResponses(t *testing.T) {
for _, frame := range []string{
`{"version":2,"directory":{"entries":[],"truncated":false}}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/parsar-daemon/internal/localworkspace/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, er
err = fs.ErrPermission
case "invalid_path":
err = agent.ErrWorkspaceReadInvalid
case proto.WorkspaceReadNotDirectory:
err = agent.ErrWorkspaceNotDirectory
}
return agent.WorkspaceDirectoryResult{}, err
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package localworkspace

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent"
"github.com/google/uuid"
)

Expand Down Expand Up @@ -33,8 +35,15 @@ func TestNativeLocalDirectoryConfinement(t *testing.T) {
if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 {
t.Fatalf("native file metadata: %+v %v", got, err)
}
if _, err := b.ListWorkspaceDirectory(t.Context(), "escape", 10); err == nil {
t.Fatal("native helper followed an external symlink")
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
// A link, a regular file and a missing path are not listable directories;
// the external link target is never listed.
for _, path := range []string{"escape", "escape/secret", "file.txt", "missing", "missing/deeper"} {
if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) {
t.Fatal("native helper classified a non-directory path differently", path, err)
}
}
got, err = b.ListWorkspaceDirectory(t.Context(), "", 1)
if err != nil || !got.Truncated || len(got.Entries) != 1 {
Expand All @@ -47,7 +56,7 @@ func TestNativeLocalDirectoryConfinement(t *testing.T) {
if err := os.Symlink(outside, root); err != nil {
t.Fatal(err)
}
if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); err == nil {
t.Fatal("native helper followed a replaced root")
if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); !errors.Is(err, agent.ErrWorkspaceReadInvalid) {
t.Fatal("native helper followed a replaced root or hid it as an empty directory", err)
}
}
9 changes: 6 additions & 3 deletions apps/web/e2e/fixture-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1494,7 +1494,7 @@ const server = http.createServer(async (request, response) => {
response.destroy();
return;
}
return sendJson(response, result);
return sendJson(response, result, 201);
}
if (request.method === "GET" && environmentFilesMatch) {
trackAbort(response, "environmentFileReads");
Expand Down Expand Up @@ -1523,7 +1523,7 @@ const server = http.createServer(async (request, response) => {
.filter((file) => file.path.slice(0, file.path.lastIndexOf("/")) === directory)
.sort((left, right) => left.path.localeCompare(right.path));
if (order === "desc") files.reverse();
return sendJson(response, { data: files.slice(0, limit), next: null });
return sendJson(response, { object: "page", data: files.slice(0, limit), next: null, has_more: false });
}
const sessionEnvironment = state.sessions[0]?.environment;
const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null;
Expand All @@ -1547,9 +1547,12 @@ const server = http.createServer(async (request, response) => {
}));
if (order === "desc") allFiles.reverse();
const start = cursor === "fixture-page-2" ? 20 : 0;
const next = start + limit < allFiles.length ? "fixture-page-2" : null;
return sendJson(response, {
object: "page",
data: allFiles.slice(start, start + limit),
next: start + limit < allFiles.length ? "fixture-page-2" : null,
next,
has_more: next !== null,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AgentCoreError } from "@agents-core-web/agents-client";
import {
EnvironmentFilesPanel,
environmentFilesFailureMessage,
environmentFilesRequestDirectory,
formatFileSize,
validEnvironmentFilesDirectory,
} from "./EnvironmentFilesPanel";
Expand All @@ -27,6 +28,12 @@ describe("EnvironmentFilesPanel", () => {
expect(validEnvironmentFilesDirectory("/test/../secret", "/test")).toBe(false);
});

it("sends the cleaned directory form that Core requires", () => {
expect(environmentFilesRequestDirectory("/workspace/project/src/")).toBe("/workspace/project/src");
expect(environmentFilesRequestDirectory("/workspace//")).toBe("/workspace");
expect(environmentFilesRequestDirectory("/workspace/project")).toBe("/workspace/project");
});

it("reports an unsupported Core instead of a generic directory failure", () => {
expect(environmentFilesFailureMessage(
new AgentCoreError("This API operation is not supported.", 404, "unsupported_operation"),
Expand All @@ -50,7 +57,7 @@ describe("EnvironmentFilesPanel", () => {
<EnvironmentFilesPanel
environmentId="environment_01"
workspaceDirectory="/test"
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
/>,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export function validEnvironmentFilesDirectory(
return root === "/" || directory === root || directory.startsWith(`${root}/`);
}

/** Core accepts only the cleaned form, so a valid trailing separator is dropped before sending. */
export function environmentFilesRequestDirectory(value: string): string {
return canonicalDirectory(value) ?? value;
}

export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
Expand Down Expand Up @@ -119,7 +124,7 @@ export function EnvironmentFilesPanel({
abortRef.current = controller;
const request = requestRef.current + 1;
requestRef.current = request;
const requestedDirectory = append ? appliedDirectory : directory;
const requestedDirectory = append ? appliedDirectory : environmentFilesRequestDirectory(directory);
const requestedOrder = append ? appliedOrder : order;
setState(append ? "loading-more" : "loading");
setError(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe("EnvironmentPanel", () => {
}}
connectionActions={[{ type: "environment_connection", environment_id: hostedEnvironmentUuid }]}
environmentFilesEnabled
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
onCreateFile={async (_environmentId, input) => ({
environment_id: hostedEnvironmentUuid,
object: "agent.environment.file",
Expand Down Expand Up @@ -205,7 +205,7 @@ describe("EnvironmentPanel", () => {
}}
connectionActions={[]}
environmentFilesEnabled
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
onCreateFile={async () => ({
environment_id: hostedEnvironmentUuid,
object: "agent.environment.file",
Expand Down Expand Up @@ -270,7 +270,7 @@ describe("EnvironmentPanel", () => {
observation={null}
connectionActions={[]}
environmentFilesEnabled
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
/>,
);
expect(complete).toContain("Workspace files");
Expand All @@ -290,7 +290,7 @@ describe("EnvironmentPanel", () => {
observation={null}
connectionActions={[]}
environmentFilesEnabled={false}
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
/>,
);
expect(buildDisabled).not.toContain("Workspace files");
Expand All @@ -310,7 +310,7 @@ describe("EnvironmentPanel", () => {
observation={null}
connectionActions={[]}
environmentFilesEnabled
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
/>,
);
expect(incomplete).not.toContain("Workspace files");
Expand All @@ -327,7 +327,7 @@ describe("EnvironmentPanel", () => {
observation={null}
connectionActions={[]}
environmentFilesEnabled
onListFiles={async () => ({ data: [], next: null })}
onListFiles={async () => ({ object: "page", data: [], next: null, has_more: false })}
/>,
);
expect(unsupportedProfile).not.toContain("Workspace files");
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; shared Session/child IDs |
| 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. Full listing, overwrite and error semantics 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; recursion, parent creation, overwrite 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