mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* MUL-2771: feat(transcript): server-derived relative work_dir chip Adds a privacy-safe `relative_work_dir` field to the agent task wire shape so the transcript dialog can show where a task ran without leaking the user's home directory. Standard tasks strip the daemon's workspaces root to `<wsUUID>/<taskShort>/workdir`; local_directory tasks fall back to the trailing two path segments (`repos/foo`), which keeps enough context for the user to recognise the directory without exposing $HOME or the username. The derivation lives in `taskToResponse` so every endpoint that serves a task — list, snapshot, claim, rerun, cancel, complete, fail — fills the field consistently. taskToResponse now also populates `workspace_id`, which the prior shape declared but never set. shortTaskID mirrors execenv.shortID; a colocated test pins the two helpers together so future daemon-side layout changes don't silently degrade the chip into the local_directory fallback. Replaces the front-end stripping attempt in PR #3379, which passed issue_id where workspace_id was required and therefore rendered the full absolute path on every standard task. Co-authored-by: multica-agent <github@multica.ai> * MUL-2771: harden privacy guards on transcript work_dir chip Address second-round review feedback from PR #3428: 1. Drop the `title={task.work_dir}` tooltip in the transcript dialog. The visible chip was safe but native browser tooltips re-rendered the absolute `/Users/<name>/...` on hover, leaking into screen shares, screenshots, and recordings — defeating the stated goal of the chip. The absolute path now never reaches the DOM (no title, aria, or data attribute). 2. Replace the "tail two segments" fallback for local_directory paths with explicit home-prefix stripping plus a basename-only final fallback. The old behaviour leaked the username on shallow paths like `/Users/alice/foo`, `/home/alice/project`, and `C:\Users\alice\foo`. The new behaviour recognises common per-user home layouts on macOS, Linux, and Windows (case-insensitive), strips them down to the remainder, and falls back to the basename for any path under an unrecognised root — a single segment can never carry the home prefix. 3. Align the Go and TypeScript field comments with the real fallback policy so future readers see "strip home / basename" instead of the outdated "tail two segments" description. Tests: expanded `TestRelativeWorkDir` to cover shallow `/Users/...`, `/home/...`, and `C:\Users\...` paths, the exact-home edge cases, case-insensitive matching, and the non-home basename-only fallback. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
207 lines
6.2 KiB
Go
207 lines
6.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
// TestRelativeWorkDir covers the privacy-safe display derivation that
|
|
// agent-transcript dialogs render in the work_dir chip. Two regression
|
|
// concerns drive the table:
|
|
//
|
|
// 1. Standard tasks must strip the daemon's workspaces root so the chip
|
|
// doesn't expose the user's home directory or username (the bug in
|
|
// PR #3379 that this fix replaces).
|
|
// 2. local_directory tasks have a work_dir outside the envRoot layout —
|
|
// we must NOT leak `/Users/<name>/...`, `/home/<name>/...`, or
|
|
// `<drive>:/Users/<name>/...` even on shallow paths like
|
|
// `/Users/alice/foo`. The function strips recognised home prefixes
|
|
// and otherwise falls back to the basename, which can never carry a
|
|
// username segment.
|
|
func TestRelativeWorkDir(t *testing.T) {
|
|
const (
|
|
wsID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
|
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
workDir string
|
|
wsID string
|
|
taskID string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty work_dir returns empty",
|
|
workDir: "",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "standard envRoot path strips workspaces root",
|
|
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: wsID + "/5c57b65b/workdir",
|
|
},
|
|
{
|
|
name: "standard envRoot path without trailing workdir",
|
|
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: wsID + "/5c57b65b",
|
|
},
|
|
{
|
|
name: "local_directory path under /Users home is stripped",
|
|
workDir: "/Users/df007df/repos/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "repos/foo",
|
|
},
|
|
{
|
|
name: "local_directory deep path under home keeps full remainder",
|
|
workDir: "/Users/df007df/code/work/projects/multica/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "code/work/projects/multica/foo",
|
|
},
|
|
{
|
|
name: "shallow /Users home path strips username segment",
|
|
workDir: "/Users/alice/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "foo",
|
|
},
|
|
{
|
|
name: "shallow Linux /home path strips username segment",
|
|
workDir: "/home/alice/project",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "project",
|
|
},
|
|
{
|
|
name: "shallow Windows /Users path strips username segment",
|
|
workDir: `C:\Users\alice\foo`,
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "foo",
|
|
},
|
|
{
|
|
name: "exact home directory returns empty (would only render username)",
|
|
workDir: "/Users/alice",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "exact home directory with trailing slash returns empty",
|
|
workDir: "/Users/alice/",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Windows local_directory path under home strips username",
|
|
workDir: `C:\Users\alice\repos\foo`,
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "repos/foo",
|
|
},
|
|
{
|
|
name: "non-home local path falls back to basename only",
|
|
workDir: "/opt/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "foo",
|
|
},
|
|
{
|
|
name: "non-home deep local path falls back to basename only",
|
|
workDir: "/srv/git/repo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "repo",
|
|
},
|
|
{
|
|
name: "single-segment local path returns the segment",
|
|
workDir: "/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "foo",
|
|
},
|
|
{
|
|
name: "Windows backslash separators are normalized",
|
|
workDir: `C:\Users\alice\multica_workspaces\` + wsID + `\5c57b65b\workdir`,
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: wsID + "/5c57b65b/workdir",
|
|
},
|
|
{
|
|
name: "missing workspace_id under home strips home prefix instead of envRoot",
|
|
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
|
wsID: "",
|
|
taskID: taskID,
|
|
expected: "multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
|
},
|
|
{
|
|
name: "missing task_id under home strips home prefix instead of envRoot",
|
|
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
|
wsID: wsID,
|
|
taskID: "",
|
|
expected: "multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
|
},
|
|
{
|
|
name: "trailing slash on envRoot path is preserved in returned suffix",
|
|
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir/",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: wsID + "/5c57b65b/workdir/",
|
|
},
|
|
{
|
|
name: "wsID prefix appearing elsewhere falls back to basename when not under home",
|
|
workDir: "/var/" + wsID + "/something/else",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "else",
|
|
},
|
|
{
|
|
name: "case-insensitive /users matches the same as /Users",
|
|
workDir: "/users/alice/repos/foo",
|
|
wsID: wsID,
|
|
taskID: taskID,
|
|
expected: "repos/foo",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := relativeWorkDir(tc.workDir, tc.wsID, tc.taskID)
|
|
if got != tc.expected {
|
|
t.Fatalf("relativeWorkDir(%q, %q, %q) = %q, want %q",
|
|
tc.workDir, tc.wsID, tc.taskID, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestShortTaskIDMatchesDaemon pins shortTaskID() to execenv.PredictRootDir's
|
|
// path layout. Both helpers consume the same task UUID; if the daemon's
|
|
// shortID logic drifts, this test trips loudly instead of letting the UI
|
|
// silently fall back to the "tail two segments" branch. Without this guard,
|
|
// a daemon-side change to, say, a 12-char prefix would not break a build —
|
|
// it would just quietly degrade every standard-task work_dir chip into the
|
|
// local_directory fallback.
|
|
func TestShortTaskIDMatchesDaemon(t *testing.T) {
|
|
const (
|
|
workspacesRoot = "/tmp/workspaces"
|
|
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
|
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
|
|
)
|
|
daemonRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
|
|
expected := workspacesRoot + "/" + workspaceID + "/" + shortTaskID(taskID)
|
|
if daemonRoot != expected {
|
|
t.Fatalf("daemon PredictRootDir = %q, handler-side reconstruction = %q — shortTaskID is out of sync with execenv.shortID", daemonRoot, expected)
|
|
}
|
|
}
|