Compare commits

...

2 Commits

Author SHA1 Message Date
J
e3cdb1759c 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>
2026-05-28 15:44:48 +08:00
J
15b430c8d5 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>
2026-05-28 14:07:35 +08:00
7 changed files with 401 additions and 26 deletions

View File

@@ -120,9 +120,24 @@ export interface AgentTask {
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
/**
* Local working directory pinned for this task by the daemon. Empty until
* the daemon reports a work_dir (typically once execution starts).
* the daemon reports a work_dir (typically once execution starts). This is
* the canonical absolute path the agent runs in; UI surfaces should prefer
* `relative_work_dir` to avoid leaking the user's home directory.
*/
work_dir?: string;
/**
* Privacy-safe display form of `work_dir`, derived on the server. For
* standard tasks the daemon's workspaces root has been stripped off
* (`<wsUUID>/<taskShort>/workdir`); for local_directory tasks where the
* path lives outside that layout, the server strips recognised home
* prefixes (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`)
* and otherwise falls back to the basename so neither the home directory
* nor the username leak into the UI. Older backends omit the field —
* render it conditionally and never render `work_dir` raw (not even in
* a tooltip / `title` / `aria-label`, since the goal is that screen
* shares and screenshots also stay safe).
*/
relative_work_dir?: string;
}
export interface Agent {

View File

@@ -17,6 +17,7 @@ import {
Cloud,
Cpu,
Filter,
FolderTree,
ArrowDownNarrowWide,
ArrowUpNarrowWide,
} from "lucide-react";
@@ -473,6 +474,19 @@ export function AgentTranscriptDialog({
: t(($) => $.transcript.events, { count: items.length })}
</MetadataChip>
{/* Working directory — server-derived display path. Falls back to
nothing when older backends omit the field rather than rendering
`work_dir` raw and leaking the user's home directory. The
absolute `task.work_dir` deliberately never reaches the DOM
(no title/aria/data attribute), since the goal of this chip is
that recordings, screen shares, and screenshots never expose
$HOME or the username. */}
{task.relative_work_dir && (
<MetadataChip icon={<FolderTree className="h-3 w-3" />}>
<span className="font-mono">{task.relative_work_dir}</span>
</MetadataChip>
)}
{/* Created time */}
{task.created_at && (
<MetadataChip>

View File

@@ -8,6 +8,8 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"strings"
"unicode/utf8"
"github.com/go-chi/chi/v5"
@@ -184,6 +186,17 @@ type AgentTaskResponse struct {
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
WorkDir string `json:"work_dir,omitempty"` // local working directory pinned for this task; populated once the daemon reports it
// RelativeWorkDir is a privacy-safe display form of WorkDir intended for
// the UI. For standard tasks it strips the daemon's workspaces root so
// the user sees `<wsUUID>/<taskShort>/workdir`; for local_directory
// tasks the absolute path lives outside the envRoot layout, so we strip
// recognised home-directory prefixes (`/Users/<name>/`, `/home/<name>/`,
// `<drive>:/Users/<name>/`) and otherwise fall back to the basename so
// the field never carries the user's home dir or account name. Empty
// when WorkDir is empty, or when stripping leaves nothing. See
// relativeWorkDir() for the full rules. Older clients can still read
// WorkDir directly; newer UIs should prefer RelativeWorkDir.
RelativeWorkDir string `json:"relative_work_dir,omitempty"`
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
@@ -249,7 +262,13 @@ type TaskAgentData struct {
ThinkingLevel string `json:"thinking_level,omitempty"`
}
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
// taskToResponse maps a queue row to its wire shape. workspaceID is threaded
// in because the row itself doesn't carry one (workspace lives on the agent
// / issue / chat session) — we ask the caller to resolve it once and pass it
// down. It populates WorkspaceID and powers the privacy-safe RelativeWorkDir
// derivation; pass "" only on daemon-facing paths that genuinely don't have
// it, in which case RelativeWorkDir falls back to the existing WorkDir.
func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
var result any
if t.Result != nil {
json.Unmarshal(t.Result, &result)
@@ -267,6 +286,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
AgentID: uuidToString(t.AgentID),
RuntimeID: uuidToString(t.RuntimeID),
IssueID: uuidToString(t.IssueID),
WorkspaceID: workspaceID,
Status: t.Status,
Priority: t.Priority,
DispatchedAt: timestampToPtr(t.DispatchedAt),
@@ -282,6 +302,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
TriggerSummary: textToPtr(t.TriggerSummary),
WorkDir: workDir,
RelativeWorkDir: relativeWorkDir(workDir, workspaceID, uuidToString(t.ID)),
// Surface task source so the UI can distinguish issue-linked tasks
// from chat-spawned or autopilot-spawned ones; all three may arrive
// with issue_id = "" once a task has no linked issue.
@@ -291,6 +312,105 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
}
}
// relativeWorkDir produces a privacy-safe display form of the daemon-reported
// absolute work_dir. The contract: the returned string must never contain
// the user's home directory prefix or their account name. The chip is
// rendered in transcripts that frequently end up in screen shares,
// screenshots, and recordings, so this function is the only guard.
//
// - For standard tasks (work_dir laid out as `<workspacesRoot>/<wsUUID>/
// <taskShort>/workdir` by execenv.Prepare), it strips everything up to and
// including the workspaces root, returning `<wsUUID>/<taskShort>/workdir`.
// - For local_directory tasks the absolute path lives outside the envRoot
// layout. We try to recognise common home-directory prefixes
// (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`) and strip
// them, returning the remainder (e.g. `repos/foo`). When the prefix
// can't be recognised — unusual home layouts, network mounts, paths
// under `/opt`, `/srv`, etc. — we fall back to the basename so we never
// accidentally render a path component that happens to be a username.
//
// Returns empty when work_dir is empty, or when stripping leaves nothing
// (i.e. work_dir was exactly the user's home — rendering nothing is
// preferable to a chip that says `<name>`). shortTaskID() must stay in
// lock-step with server/internal/daemon/execenv/git.go:shortID — both
// consume the same task UUID; if that helper changes, this one must too
// or the envRoot match silently degrades to the local_directory fallback.
func relativeWorkDir(workDir, workspaceID, taskID string) string {
if workDir == "" {
return ""
}
// Normalize Windows separators so the rest of the function only
// reasons about forward slashes.
normalized := strings.ReplaceAll(workDir, "\\", "/")
if workspaceID != "" && taskID != "" {
envRootSuffix := workspaceID + "/" + shortTaskID(taskID)
if idx := strings.Index(normalized, envRootSuffix); idx >= 0 {
return normalized[idx:]
}
}
if stripped, ok := stripHomePrefix(normalized); ok {
return stripped
}
return basename(normalized)
}
// shortTaskID mirrors execenv.shortID — first 8 hex chars of the UUID
// with dashes stripped. Kept inline here so the agent handler has zero
// imports from the daemon package (which would create an unwanted cycle
// between handler and daemon).
func shortTaskID(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")
if len(s) > 8 {
return s[:8]
}
return s
}
// homeDirPattern matches the well-known per-user home layouts on macOS,
// Linux, and Windows after backslash normalization:
//
// /Users/<name>[/<rest>]
// /home/<name>[/<rest>]
// <drive>:/Users/<name>[/<rest>]
//
// Case-insensitive because macOS and Windows are case-insensitive at the
// filesystem layer; matching `/users/...` the same as `/Users/...` keeps
// the strip robust against unusual casings seen on shared drives.
// Capture group 1 is the optional remainder after the username segment.
var homeDirPattern = regexp.MustCompile(`(?i)^(?:[A-Za-z]:)?/(?:Users|home)/[^/]+(?:/(.*))?$`)
// stripHomePrefix recognises common home-directory layouts and returns
// the path remainder after the username segment. Returns (remainder, true)
// when a known home prefix matched. The remainder may be the empty string
// (work_dir was exactly the home directory) — the caller treats that as
// "nothing safe to display".
func stripHomePrefix(p string) (string, bool) {
m := homeDirPattern.FindStringSubmatch(p)
if m == nil {
return "", false
}
return m[1], true
}
// basename returns the last non-empty segment of a forward-slash path.
// Used as the ultimate privacy-safe fallback when we can't otherwise
// recognise the path: a single segment can never expose the home prefix,
// and the leaf is almost always the most useful piece of context anyway
// (typically the repo directory name for local_directory tasks).
func basename(p string) string {
p = strings.TrimRight(p, "/")
if p == "" {
return ""
}
if idx := strings.LastIndex(p, "/"); idx >= 0 {
return p[idx+1:]
}
return p
}
// computeTaskKind picks the source-discriminator string the activity UI uses
// to choose how to render a task row. Computed from the existing FK shape so
// no extra DB lookup is needed: chat / autopilot / comment-on-issue (any
@@ -1120,7 +1240,7 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
resp[i] = taskToResponse(t, workspaceID)
}
writeJSON(w, http.StatusOK, resp)
@@ -1257,7 +1377,7 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
if _, ok := allowed[uuidToString(t.AgentID)]; !ok {
continue
}
resp = append(resp, taskToResponse(t))
resp = append(resp, taskToResponse(t, workspaceID))
}
writeJSON(w, http.StatusOK, resp)

View File

@@ -0,0 +1,206 @@
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)
}
}

View File

@@ -736,7 +736,7 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, taskToResponse(*cancelled))
writeJSON(w, http.StatusOK, taskToResponse(*cancelled, workspaceID))
}
// ---------------------------------------------------------------------------

View File

@@ -93,9 +93,21 @@ func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Requ
// requireDaemonTaskAccess looks up a task and verifies the caller owns its workspace.
func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, bool) {
task, _, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
return task, ok
}
// requireDaemonTaskAccessWithWorkspace is the workspace-aware variant of
// requireDaemonTaskAccess. It returns the resolved workspace ID alongside
// the task row so callers that need to forward workspace_id into
// taskToResponse (powering RelativeWorkDir) don't have to repeat the
// ResolveTaskWorkspaceID lookup. The two helpers share their entire
// implementation; the simpler one is preserved for ergonomic call sites
// that genuinely don't need workspace_id.
func (h *Handler) requireDaemonTaskAccessWithWorkspace(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, string, bool) {
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
if !ok {
return db.AgentTaskQueue{}, false
return db.AgentTaskQueue{}, "", false
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
@@ -104,23 +116,23 @@ func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request
// error must not be reported as a deletion.
if isNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return db.AgentTaskQueue{}, false
return db.AgentTaskQueue{}, "", false
}
slog.Warn("get agent task failed", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "failed to load task")
return db.AgentTaskQueue{}, false
return db.AgentTaskQueue{}, "", false
}
wsID := h.TaskService.ResolveTaskWorkspaceID(r.Context(), task)
if wsID == "" {
writeError(w, http.StatusNotFound, "task not found")
return db.AgentTaskQueue{}, false
return db.AgentTaskQueue{}, "", false
}
if !h.requireDaemonWorkspaceAccess(w, r, wsID) {
return db.AgentTaskQueue{}, false
return db.AgentTaskQueue{}, "", false
}
return task, true
return task, wsID, true
}
// verifyDaemonWorkspaceAccess checks workspace access without writing an HTTP error.
@@ -1095,7 +1107,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
buildStart = time.Now()
// Build response with fresh agent data (name + skills + custom_env + custom_args).
resp := taskToResponse(*task)
resp := taskToResponse(*task, runtimeWorkspaceID)
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
var customEnv map[string]string
@@ -1601,9 +1613,11 @@ func (h *Handler) ListPendingTasksByRuntime(w http.ResponseWriter, r *http.Reque
runtimeID := chi.URLParam(r, "runtimeId")
// Verify the caller owns this runtime's workspace.
if _, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID); !ok {
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
if !ok {
return
}
workspaceID := uuidToString(runtime.WorkspaceID)
tasks, err := h.Queries.ListPendingTasksByRuntime(r.Context(), parseUUID(runtimeID))
if err != nil {
@@ -1613,7 +1627,7 @@ func (h *Handler) ListPendingTasksByRuntime(w http.ResponseWriter, r *http.Reque
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
resp[i] = taskToResponse(t, workspaceID)
}
writeJSON(w, http.StatusOK, resp)
@@ -1628,7 +1642,8 @@ func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
// Verify the caller owns this task's workspace.
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
if !ok {
return
}
@@ -1640,7 +1655,7 @@ func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
}
slog.Info("task started", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
writeJSON(w, http.StatusOK, taskToResponse(*task))
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
}
// TaskWaitLocalDirectoryRequest is the body the daemon POSTs when it parks
@@ -1660,7 +1675,8 @@ type TaskWaitLocalDirectoryRequest struct {
func (h *Handler) MarkTaskWaitingLocalDirectory(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
if !ok {
return
}
@@ -1679,7 +1695,7 @@ func (h *Handler) MarkTaskWaitingLocalDirectory(w http.ResponseWriter, r *http.R
return
}
writeJSON(w, http.StatusOK, taskToResponse(*task))
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
}
// ReportTaskProgress broadcasts a progress update.
@@ -1727,7 +1743,8 @@ func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
// Verify the caller owns this task's workspace.
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
if !ok {
return
}
@@ -1758,7 +1775,7 @@ func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
}
slog.Info("task completed", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
writeJSON(w, http.StatusOK, taskToResponse(*task))
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
}
// emitIssueExecutedOnFirstCompletion atomically flips issue.first_executed_at
@@ -1873,7 +1890,8 @@ func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
// Verify the caller owns this task's workspace.
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
if !ok {
return
}
@@ -1898,7 +1916,7 @@ func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
}
slog.Info("task failed", "task_id", taskID, "agent_id", uuidToString(task.AgentID), "task_error", req.Error, "failure_reason", req.FailureReason)
writeJSON(w, http.StatusOK, taskToResponse(*task))
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
}
// ---------------------------------------------------------------------------
@@ -2056,9 +2074,10 @@ func (h *Handler) GetActiveTaskForIssue(w http.ResponseWriter, r *http.Request)
tasks = nil
}
workspaceID := uuidToString(issue.WorkspaceID)
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
resp[i] = taskToResponse(t, workspaceID)
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": resp})
@@ -2090,7 +2109,7 @@ func (h *Handler) CancelTask(w http.ResponseWriter, r *http.Request) {
}
slog.Info("task cancelled by user", "task_id", taskID, "issue_id", uuidToString(task.IssueID))
writeJSON(w, http.StatusOK, taskToResponse(*task))
writeJSON(w, http.StatusOK, taskToResponse(*task, uuidToString(issue.WorkspaceID)))
}
// ListTasksByIssue returns all tasks (any status) for an issue — used for execution history.
@@ -2107,9 +2126,10 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
resp[i] = taskToResponse(t, workspaceID)
}
writeJSON(w, http.StatusOK, resp)

View File

@@ -149,5 +149,5 @@ func (h *Handler) RerunIssue(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, taskToResponse(*task))
writeJSON(w, http.StatusAccepted, taskToResponse(*task, uuidToString(issue.WorkspaceID)))
}