Files
multica/server/internal/handler/agent_work_dir_test.go
Bohan Jiang 1195255e43 MUL-2771: feat(transcript): server-derived relative work_dir chip (#3428)
* 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>
2026-05-28 15:53:16 +08:00

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)
}
}