remove artifacts dir handling (#8945)
This commit is contained in:
@@ -19,9 +19,6 @@
|
||||
{
|
||||
"path": "$HOME/.goose/**"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.goose/artifacts/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**"
|
||||
},
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -61,8 +61,8 @@ mod tests {
|
||||
#[test]
|
||||
fn joins_absolute_path_and_subpath() {
|
||||
assert_eq!(
|
||||
resolve_path_parts(vec!["/tmp/project".to_string(), "artifacts".to_string()]),
|
||||
Ok("/tmp/project/artifacts".to_string())
|
||||
resolve_path_parts(vec!["/tmp/project".to_string(), "src".to_string()]),
|
||||
Ok("/tmp/project/src".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,24 +81,16 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_path_parts(vec![
|
||||
"~".to_string(),
|
||||
".goose".to_string(),
|
||||
"artifacts".to_string()
|
||||
]),
|
||||
Ok(home
|
||||
.join(".goose")
|
||||
.join("artifacts")
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
resolve_path_parts(vec!["~".to_string()]),
|
||||
Ok(home.to_string_lossy().into_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path_parts(vec!["~/artifacts".to_string()]),
|
||||
Ok(home.join("artifacts").to_string_lossy().into_owned())
|
||||
resolve_path_parts(vec!["~/Documents".to_string()]),
|
||||
Ok(home.join("Documents").to_string_lossy().into_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path_parts(vec!["~\\artifacts".to_string()]),
|
||||
Ok(home.join("artifacts").to_string_lossy().into_owned())
|
||||
resolve_path_parts(vec!["~\\Documents".to_string()]),
|
||||
Ok(home.join("Documents").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn projects_dir() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
|
||||
@@ -120,10 +120,6 @@ where
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn project_artifacts_dir(project_dir: &Path) -> String {
|
||||
project_dir.join("artifacts").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoredProjectInfo {
|
||||
@@ -170,10 +166,9 @@ pub struct ProjectInfo {
|
||||
pub archived_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub artifacts_dir: String,
|
||||
}
|
||||
|
||||
fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> ProjectInfo {
|
||||
fn project_info_from_stored(stored: StoredProjectInfo) -> ProjectInfo {
|
||||
ProjectInfo {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
@@ -189,7 +184,6 @@ fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> Pr
|
||||
archived_at: stored.archived_at,
|
||||
created_at: stored.created_at,
|
||||
updated_at: stored.updated_at,
|
||||
artifacts_dir: project_artifacts_dir(project_dir),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +217,7 @@ pub fn list_projects() -> Result<Vec<ProjectInfo>, String> {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
projects.push(project_info_from_stored(&path, info));
|
||||
projects.push(project_info_from_stored(info));
|
||||
}
|
||||
|
||||
projects.sort_by_key(|p| p.order);
|
||||
@@ -253,7 +247,7 @@ pub fn list_archived_projects() -> Result<Vec<ProjectInfo>, String> {
|
||||
let raw = fs::read_to_string(&project_json).unwrap_or_default();
|
||||
if let Ok(info) = serde_json::from_str::<StoredProjectInfo>(&raw) {
|
||||
if info.archived_at.is_some() {
|
||||
projects.push(project_info_from_stored(&path, info));
|
||||
projects.push(project_info_from_stored(info));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,7 +323,7 @@ pub fn create_project(
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
|
||||
|
||||
Ok(project_info_from_stored(&dir, stored))
|
||||
Ok(project_info_from_stored(stored))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -374,7 +368,7 @@ pub fn update_project(
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
|
||||
|
||||
Ok(project_info_from_stored(&dir, stored))
|
||||
Ok(project_info_from_stored(stored))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -400,8 +394,8 @@ pub fn reorder_projects(order: Vec<(String, i32)>) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_project(id: String) -> Result<ProjectInfo, String> {
|
||||
let (dir, info) = find_project_by_id(&id)?;
|
||||
Ok(project_info_from_stored(&dir, info))
|
||||
let (_, info) = find_project_by_id(&id)?;
|
||||
Ok(project_info_from_stored(info))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -472,8 +466,7 @@ pub fn restore_project(id: String) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{project_artifacts_dir, StoredProjectInfo};
|
||||
use std::path::Path;
|
||||
use super::StoredProjectInfo;
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_single_working_dir() {
|
||||
@@ -497,12 +490,4 @@ mod tests {
|
||||
|
||||
assert_eq!(project.working_dirs, vec!["/tmp/legacy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_project_artifacts_dir_inside_project_storage() {
|
||||
assert_eq!(
|
||||
project_artifacts_dir(Path::new("/Users/test/.goose/projects/sample-project")),
|
||||
"/Users/test/.goose/projects/sample-project/artifacts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,7 @@ async fn wait_for_server_ready(port: u16, child: &mut Child) -> Result<(), Strin
|
||||
}
|
||||
|
||||
fn default_serve_working_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".goose")
|
||||
.join("artifacts")
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
fn reserve_free_port() -> Result<u16, String> {
|
||||
|
||||
@@ -83,49 +83,6 @@ function basenameOf(path: string): string {
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function homeArtifactsRootFromRoots(roots: string[]): string | null {
|
||||
return (
|
||||
roots.find((root) => /\/\.goose\/artifacts\/?$/.test(root.trim())) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function stripRootArtifactsSegment(
|
||||
path: string,
|
||||
roots: string[],
|
||||
): string | null {
|
||||
for (const root of roots) {
|
||||
const normalizedRoot = root.replace(/\/+$/, "");
|
||||
const prefix = `${normalizedRoot}/artifacts/`;
|
||||
if (path.startsWith(prefix)) {
|
||||
return `${normalizedRoot}/${path.slice(prefix.length)}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackArtifactPath(path: string, roots: string[]): string | null {
|
||||
const normalized = path.trim();
|
||||
if (!/\/\.goose\/projects\/.+\/artifacts\/.+/.test(normalized)) {
|
||||
const rootAnchored = stripRootArtifactsSegment(normalized, roots);
|
||||
if (rootAnchored) {
|
||||
return rootAnchored;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const filename = basenameOf(normalized);
|
||||
if (!filename || filename === normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const homeArtifactsRoot = homeArtifactsRootFromRoots(roots);
|
||||
if (!homeArtifactsRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${homeArtifactsRoot.replace(/\/+$/, "")}/${filename}`;
|
||||
}
|
||||
|
||||
export function ArtifactPolicyProvider({
|
||||
messages,
|
||||
allowedRoots,
|
||||
@@ -190,14 +147,9 @@ export function ArtifactPolicyProvider({
|
||||
return path;
|
||||
}
|
||||
|
||||
const fallbackPath = fallbackArtifactPath(path, normalizedRoots);
|
||||
if (fallbackPath && (await pathExists(fallbackPath))) {
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[normalizedRoots],
|
||||
[],
|
||||
);
|
||||
|
||||
const checkPathExists = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
import {
|
||||
@@ -85,32 +85,6 @@ function ReadOnlyProbe({ readArgs }: { readArgs: Record<string, unknown> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackProbe({
|
||||
path = "/Users/test/.goose/projects/sample-project/artifacts/report.md",
|
||||
}: {
|
||||
path?: string;
|
||||
}) {
|
||||
const { pathExists, openResolvedPath } = useArtifactPolicyContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const exists = await pathExists(path);
|
||||
(window as Window & { __artifactExists?: boolean }).__artifactExists =
|
||||
exists;
|
||||
}}
|
||||
>
|
||||
Check path
|
||||
</button>
|
||||
<button type="button" onClick={() => void openResolvedPath(path)}>
|
||||
Open path
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ArtifactPolicyContext", () => {
|
||||
it("computes one primary host per message and resolves tool cards by args identity", () => {
|
||||
mockPathExists.mockReset();
|
||||
@@ -163,7 +137,7 @@ describe("ArtifactPolicyContext", () => {
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={messages}
|
||||
allowedRoots={["/Users/test/project-a", "/Users/test/.goose/artifacts"]}
|
||||
allowedRoots={["/Users/test/project-a", "/Users/test"]}
|
||||
>
|
||||
<Probe
|
||||
readArgs={readArgs}
|
||||
@@ -215,7 +189,7 @@ describe("ArtifactPolicyContext", () => {
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={messages}
|
||||
allowedRoots={["/Users/test/project-a", "/Users/test/.goose/artifacts"]}
|
||||
allowedRoots={["/Users/test/project-a", "/Users/test"]}
|
||||
>
|
||||
<ReadOnlyProbe readArgs={readArgs} />
|
||||
</ArtifactPolicyProvider>,
|
||||
@@ -225,150 +199,6 @@ describe("ArtifactPolicyContext", () => {
|
||||
expect(screen.getByTestId("read-only-artifacts")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("falls back to the home artifacts root when a project artifacts path is missing", async () => {
|
||||
mockPathExists.mockReset();
|
||||
vi.mocked(openPath).mockReset();
|
||||
mockPathExists.mockImplementation(
|
||||
async (path: string) => path === "/Users/test/.goose/artifacts/report.md",
|
||||
);
|
||||
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={[]}
|
||||
allowedRoots={[
|
||||
"/Users/test/.goose/projects/sample-project/artifacts",
|
||||
"/Users/test/.goose/artifacts",
|
||||
]}
|
||||
>
|
||||
<FallbackProbe />
|
||||
</ArtifactPolicyProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Check path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window as Window & { __artifactExists?: boolean }).__artifactExists,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Open path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
|
||||
"/Users/test/.goose/artifacts/report.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back from a working-dir artifacts path to the project root when the file lives there", async () => {
|
||||
mockPathExists.mockReset();
|
||||
vi.mocked(openPath).mockReset();
|
||||
mockPathExists.mockImplementation(
|
||||
async (path: string) =>
|
||||
path === "/Users/test/project-a/README_ENHANCED.md",
|
||||
);
|
||||
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={[]}
|
||||
allowedRoots={[
|
||||
"/Users/test/project-a/artifacts",
|
||||
"/Users/test/project-a",
|
||||
"/Users/test/.goose/artifacts",
|
||||
]}
|
||||
>
|
||||
<FallbackProbe path="/Users/test/project-a/artifacts/README_ENHANCED.md" />
|
||||
</ArtifactPolicyProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Check path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window as Window & { __artifactExists?: boolean }).__artifactExists,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Open path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
|
||||
"/Users/test/project-a/README_ENHANCED.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not strip /artifacts/ from a parent directory in the path", async () => {
|
||||
mockPathExists.mockReset();
|
||||
vi.mocked(openPath).mockReset();
|
||||
// The file lives at the nested artifacts path — the parent `/artifacts/` should NOT be stripped
|
||||
mockPathExists.mockImplementation(
|
||||
async (path: string) =>
|
||||
path === "/Users/test/artifacts/project/artifacts/README_ENHANCED.md",
|
||||
);
|
||||
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={[]}
|
||||
allowedRoots={[
|
||||
"/Users/test/artifacts/project/artifacts",
|
||||
"/Users/test/artifacts/project",
|
||||
"/Users/test/.goose/artifacts",
|
||||
]}
|
||||
>
|
||||
<FallbackProbe path="/Users/test/artifacts/project/artifacts/README_ENHANCED.md" />
|
||||
</ArtifactPolicyProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Check path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window as Window & { __artifactExists?: boolean }).__artifactExists,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Open path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
|
||||
"/Users/test/artifacts/project/artifacts/README_ENHANCED.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back correctly when /artifacts/ appears in a parent dir", async () => {
|
||||
mockPathExists.mockReset();
|
||||
vi.mocked(openPath).mockReset();
|
||||
// File is NOT at the artifacts path, but IS at the root-stripped path
|
||||
mockPathExists.mockImplementation(
|
||||
async (path: string) =>
|
||||
path === "/Users/test/artifacts/project/README.md",
|
||||
);
|
||||
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={[]}
|
||||
allowedRoots={[
|
||||
"/Users/test/artifacts/project/artifacts",
|
||||
"/Users/test/artifacts/project",
|
||||
"/Users/test/.goose/artifacts",
|
||||
]}
|
||||
>
|
||||
<FallbackProbe path="/Users/test/artifacts/project/artifacts/README.md" />
|
||||
</ArtifactPolicyProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Check path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window as Window & { __artifactExists?: boolean }).__artifactExists,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Open path" }).click();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
|
||||
"/Users/test/artifacts/project/README.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses assistant text after a tool call to populate file actions and the Files tab", () => {
|
||||
mockPathExists.mockReset();
|
||||
vi.mocked(openPath).mockReset();
|
||||
@@ -396,7 +226,7 @@ describe("ArtifactPolicyContext", () => {
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "The file alpha.md has been created at /Users/test/.goose/artifacts/alpha.md.",
|
||||
text: "The file alpha.md has been created at /Users/test/alpha.md.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -405,7 +235,7 @@ describe("ArtifactPolicyContext", () => {
|
||||
render(
|
||||
<ArtifactPolicyProvider
|
||||
messages={messages}
|
||||
allowedRoots={["/Users/test/.goose/artifacts"]}
|
||||
allowedRoots={["/Users/test"]}
|
||||
>
|
||||
<TextFollowupProbe writeArgs={writeArgs} />
|
||||
</ArtifactPolicyProvider>,
|
||||
@@ -415,10 +245,10 @@ describe("ArtifactPolicyContext", () => {
|
||||
"primary_host",
|
||||
);
|
||||
expect(screen.getByTestId("text-followup-path")).toHaveTextContent(
|
||||
"/Users/test/.goose/artifacts/alpha.md",
|
||||
"/Users/test/alpha.md",
|
||||
);
|
||||
expect(screen.getByTestId("text-followup-artifacts")).toHaveTextContent(
|
||||
"/Users/test/.goose/artifacts/alpha.md",
|
||||
"/Users/test/alpha.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("useArtifactLinkHandler", () => {
|
||||
const user = userEvent.setup();
|
||||
const blocked = makeCandidate({
|
||||
allowed: false,
|
||||
blockedReason: "Path is outside allowed project/artifacts roots.",
|
||||
blockedReason: "Path is outside allowed roots.",
|
||||
});
|
||||
mockResolveMarkdownHref.mockReturnValue(blocked);
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("useArtifactLinkHandler", () => {
|
||||
|
||||
expect(mockOpenResolvedPath).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("notice")).toHaveTextContent(
|
||||
"Path is outside allowed project/artifacts roots.",
|
||||
"Path is outside allowed roots.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("useArtifactLinkHandler", () => {
|
||||
await user.click(screen.getByText("Blocked"));
|
||||
|
||||
expect(screen.getByTestId("notice")).toHaveTextContent(
|
||||
"Path is outside allowed project/artifacts roots.",
|
||||
"Path is outside allowed roots.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,7 @@ export function useArtifactLinkHandler() {
|
||||
|
||||
if (!resolved.allowed) {
|
||||
setPathNotice(
|
||||
resolved.blockedReason ||
|
||||
"Path is outside allowed project/artifacts roots.",
|
||||
resolved.blockedReason || "Path is outside allowed roots.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
resolvePathCandidate,
|
||||
} from "../artifactPathPolicy";
|
||||
|
||||
const roots = [
|
||||
"/Users/test/project-a",
|
||||
"/Users/test/project-b",
|
||||
"/Users/test/.goose/artifacts",
|
||||
];
|
||||
const roots = ["/Users/test/project-a", "/Users/test/project-b", "/Users/test"];
|
||||
|
||||
describe("artifactPathPolicy", () => {
|
||||
it("prefers the latest write-oriented tool call over earlier tool calls", () => {
|
||||
@@ -112,7 +108,7 @@ describe("artifactPathPolicy", () => {
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.blockedReason).toBeNull();
|
||||
|
||||
const blocked = evaluatePathScope("/Users/test/outside/file.md", roots);
|
||||
const blocked = evaluatePathScope("/Users/other/outside/file.md", roots);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.blockedReason).toContain("outside allowed");
|
||||
});
|
||||
@@ -128,7 +124,7 @@ describe("artifactPathPolicy", () => {
|
||||
toolCallIndex: 0,
|
||||
},
|
||||
],
|
||||
["/Users/test/.goose/artifacts"],
|
||||
["/Users/test"],
|
||||
);
|
||||
|
||||
expect(ranking.primaryCandidate?.resolvedPath).toBe(
|
||||
@@ -146,7 +142,7 @@ describe("artifactPathPolicy", () => {
|
||||
args: { path: "/Users/test/Desktop/coffee_shop_inventory.csv" },
|
||||
toolCallIndex: 0,
|
||||
},
|
||||
["/Users/test/.goose/artifacts"],
|
||||
["/Users/test/project-a"],
|
||||
);
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
@@ -268,7 +264,7 @@ describe("artifactPathPolicy", () => {
|
||||
toolCallIndex: 0,
|
||||
},
|
||||
],
|
||||
["/Users/test", "/Users/test/.goose/artifacts"],
|
||||
["/Users/test", "/Users/test"],
|
||||
);
|
||||
|
||||
expect(ranking.primaryCandidate?.resolvedPath).toBe(
|
||||
@@ -301,18 +297,18 @@ describe("artifactPathPolicy", () => {
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "The file alpha.md has been created at /Users/test/.goose/artifacts/alpha.md.",
|
||||
text: "The file alpha.md has been created at /Users/test/alpha.md.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
["/Users/test/.goose/artifacts"],
|
||||
["/Users/test"],
|
||||
);
|
||||
|
||||
const ranking = result.byMessageId.get("assistant-1");
|
||||
expect(ranking?.primaryToolCallId).toBe("tool-1");
|
||||
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
|
||||
"/Users/test/.goose/artifacts/alpha.md",
|
||||
"/Users/test/alpha.md",
|
||||
);
|
||||
expect(ranking?.primaryCandidate?.allowed).toBe(true);
|
||||
});
|
||||
@@ -367,7 +363,7 @@ describe("artifactPathPolicy", () => {
|
||||
toolCallIndex: 0,
|
||||
},
|
||||
],
|
||||
["/Users/test/.goose/artifacts"],
|
||||
["/Users/test"],
|
||||
);
|
||||
|
||||
expect(ranking.primaryCandidate?.allowed).toBe(true);
|
||||
@@ -382,10 +378,7 @@ describe("artifactPathPolicy", () => {
|
||||
});
|
||||
|
||||
it("preserves Windows drive roots when resolving relative paths", () => {
|
||||
const windowsRoots = [
|
||||
"C:/Users/test/project-a",
|
||||
"C:/Users/test/.goose/artifacts",
|
||||
];
|
||||
const windowsRoots = ["C:/Users/test/project-a", "C:/Users/test"];
|
||||
const resolved = resolvePathCandidate(
|
||||
"output/final_report.md",
|
||||
windowsRoots,
|
||||
|
||||
@@ -245,11 +245,7 @@ function pickBaseRoot(allowedRoots: string[]): string | null {
|
||||
const normalizedRoots = allowedRoots
|
||||
.map((root) => normalizePath(root))
|
||||
.filter(Boolean);
|
||||
if (normalizedRoots.length === 0) return null;
|
||||
const projectRoots = normalizedRoots.filter(
|
||||
(root) => !root.includes("/.goose/artifacts"),
|
||||
);
|
||||
return projectRoots[0] ?? normalizedRoots[0];
|
||||
return normalizedRoots[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolvePathCandidate(
|
||||
@@ -294,7 +290,7 @@ export function evaluatePathScope(
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
blockedReason: "Path is outside allowed project/artifacts roots.",
|
||||
blockedReason: "Path is outside allowed roots.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
|
||||
it("disables button and shows blocked reason for disallowed primary candidate", () => {
|
||||
const blocked = makeCandidate({
|
||||
allowed: false,
|
||||
blockedReason: "Path is outside allowed project/artifacts roots.",
|
||||
blockedReason: "Path is outside allowed roots.",
|
||||
});
|
||||
mockResolveToolCardDisplay.mockReturnValue({
|
||||
role: "primary_host",
|
||||
@@ -179,7 +179,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
|
||||
|
||||
expect(screen.getByRole("button", { name: /open file/i })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("Path is outside allowed project/artifacts roots."),
|
||||
screen.getByText("Path is outside allowed roots."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
|
||||
rawPath: "/outside/secret.md",
|
||||
resolvedPath: "/Users/test/outside/secret.md",
|
||||
allowed: false,
|
||||
blockedReason: "Path is outside allowed project/artifacts roots.",
|
||||
blockedReason: "Path is outside allowed roots.",
|
||||
});
|
||||
mockResolveToolCardDisplay.mockReturnValue({
|
||||
role: "primary_host",
|
||||
@@ -205,7 +205,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
|
||||
const secondaryBtn = screen.getByTitle(blockedSecondary.resolvedPath);
|
||||
expect(secondaryBtn).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("Path is outside allowed project/artifacts roots."),
|
||||
screen.getByText("Path is outside allowed roots."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ProjectInfo {
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
artifactsDir: string;
|
||||
}
|
||||
|
||||
export interface ProjectIconCandidate {
|
||||
|
||||
@@ -19,7 +19,6 @@ describe("chatProjectContext", () => {
|
||||
preferredProvider: "goose",
|
||||
preferredModel: "claude-sonnet-4",
|
||||
workingDirs: ["/Users/wesb/dev/goose2"],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
|
||||
useWorktrees: true,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
@@ -33,7 +32,7 @@ describe("chatProjectContext", () => {
|
||||
"Working directories: /Users/wesb/dev/goose2",
|
||||
);
|
||||
expect(systemPrompt).toContain(
|
||||
"Artifact directory: /Users/wesb/dev/goose2/artifacts",
|
||||
"Default working directory: /Users/wesb/dev/goose2",
|
||||
);
|
||||
expect(systemPrompt).toContain("Preferred provider: goose");
|
||||
expect(systemPrompt).toContain(
|
||||
@@ -41,7 +40,7 @@ describe("chatProjectContext", () => {
|
||||
);
|
||||
expect(systemPrompt).toContain("<project-file-policy>");
|
||||
expect(systemPrompt).toContain(
|
||||
"Write newly generated files to /Users/wesb/dev/goose2/artifacts by default.",
|
||||
"Use /Users/wesb/dev/goose2 as the default working directory for this project.",
|
||||
);
|
||||
expect(systemPrompt).toContain("<project-instructions>");
|
||||
expect(systemPrompt).toContain("Always read AGENTS.md before editing.");
|
||||
@@ -62,18 +61,17 @@ describe("chatProjectContext", () => {
|
||||
expect(
|
||||
getProjectFolderOption({
|
||||
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "/Users/wesb/dev/goose2/artifacts",
|
||||
name: "artifacts",
|
||||
path: "/Users/wesb/dev/goose2/artifacts",
|
||||
id: "/Users/wesb/dev/goose2",
|
||||
name: "goose2",
|
||||
path: "/Users/wesb/dev/goose2",
|
||||
},
|
||||
{
|
||||
id: "/Users/wesb/dev/other/artifacts",
|
||||
name: "artifacts",
|
||||
path: "/Users/wesb/dev/other/artifacts",
|
||||
id: "/Users/wesb/dev/other",
|
||||
name: "other",
|
||||
path: "/Users/wesb/dev/other",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -82,30 +80,19 @@ describe("chatProjectContext", () => {
|
||||
expect(
|
||||
getProjectFolderOption({
|
||||
workingDirs: [],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "/Users/wesb/.goose/projects/sample-project/artifacts",
|
||||
name: "artifacts",
|
||||
path: "/Users/wesb/.goose/projects/sample-project/artifacts",
|
||||
},
|
||||
]);
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when project is null", () => {
|
||||
expect(getProjectFolderOption(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only artifact subdirectories for working dirs", () => {
|
||||
it("returns working dirs unchanged", () => {
|
||||
expect(
|
||||
getProjectArtifactRoots({
|
||||
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
|
||||
}),
|
||||
).toEqual([
|
||||
"/Users/wesb/dev/goose2/artifacts",
|
||||
"/Users/wesb/dev/other/artifacts",
|
||||
]);
|
||||
).toEqual(["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,53 +21,35 @@ export function getProjectFolderName(path: string): string {
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function appendArtifactsSegment(path: string): string {
|
||||
return `${path.replace(/[\\/]+$/, "")}/artifacts`;
|
||||
}
|
||||
|
||||
function resolveProjectArtifactRoots(
|
||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||
function resolveProjectRoots(
|
||||
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
|
||||
): string[] {
|
||||
const workingDirs = (project?.workingDirs ?? [])
|
||||
return (project?.workingDirs ?? [])
|
||||
.map((directory) => trimValue(directory))
|
||||
.filter((directory): directory is string => directory !== null);
|
||||
|
||||
if (workingDirs.length > 0) {
|
||||
return workingDirs.map(appendArtifactsSegment);
|
||||
}
|
||||
|
||||
const artifactsDir = trimValue(project?.artifactsDir);
|
||||
return artifactsDir ? [artifactsDir] : [];
|
||||
}
|
||||
|
||||
export function getProjectArtifactRoots(
|
||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
|
||||
): string[] {
|
||||
return resolveProjectArtifactRoots(project);
|
||||
return resolveProjectRoots(project);
|
||||
}
|
||||
|
||||
export function resolveProjectDefaultArtifactRoot(
|
||||
project: ProjectInfo | null | undefined,
|
||||
): string | undefined {
|
||||
const workingDirs = (project?.workingDirs ?? [])
|
||||
.map((directory) => trimValue(directory))
|
||||
.filter((directory): directory is string => directory !== null);
|
||||
|
||||
if (workingDirs.length > 0) {
|
||||
return appendArtifactsSegment(workingDirs[0]);
|
||||
}
|
||||
|
||||
return trimValue(project?.artifactsDir) ?? undefined;
|
||||
const workingDirs = resolveProjectRoots(project);
|
||||
return workingDirs[0];
|
||||
}
|
||||
|
||||
export async function defaultGlobalArtifactRoot(): Promise<string> {
|
||||
return (await resolvePath({ parts: ["~", ".goose", "artifacts"] })).path;
|
||||
return (await resolvePath({ parts: ["~"] })).path;
|
||||
}
|
||||
|
||||
export function getProjectFolderOption(
|
||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
|
||||
): ProjectFolderOption[] {
|
||||
return resolveProjectArtifactRoots(project).map((d) => ({
|
||||
return resolveProjectRoots(project).map((d) => ({
|
||||
id: d,
|
||||
name: getProjectFolderName(d),
|
||||
path: d,
|
||||
@@ -81,12 +63,10 @@ export function buildProjectSystemPrompt(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const artifactDir = resolveProjectDefaultArtifactRoot(project);
|
||||
const workingDir = resolveProjectDefaultArtifactRoot(project);
|
||||
const settings: string[] = [`Project name: ${project.name}`];
|
||||
const description = trimValue(project.description);
|
||||
const workingDirs = (project.workingDirs ?? [])
|
||||
.map((d) => trimValue(d))
|
||||
.filter((d): d is string => d !== null);
|
||||
const workingDirs = resolveProjectRoots(project);
|
||||
const prompt = trimValue(project.prompt);
|
||||
|
||||
if (description) {
|
||||
@@ -95,8 +75,8 @@ export function buildProjectSystemPrompt(
|
||||
if (workingDirs.length > 0) {
|
||||
settings.push(`Working directories: ${workingDirs.join(", ")}`);
|
||||
}
|
||||
if (artifactDir) {
|
||||
settings.push(`Artifact directory: ${artifactDir}`);
|
||||
if (workingDir) {
|
||||
settings.push(`Default working directory: ${workingDir}`);
|
||||
}
|
||||
if (project.preferredProvider) {
|
||||
settings.push(`Preferred provider: ${project.preferredProvider}`);
|
||||
@@ -114,13 +94,12 @@ export function buildProjectSystemPrompt(
|
||||
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
|
||||
];
|
||||
|
||||
if (artifactDir) {
|
||||
if (workingDir) {
|
||||
sections.push(
|
||||
`<project-file-policy>\n` +
|
||||
`Write newly generated files to ${artifactDir} by default.\n` +
|
||||
`When creating translations, variants, summaries, or derived documents from existing project files, save the new file in ${artifactDir} instead of the project root.\n` +
|
||||
`Only write outside ${artifactDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
|
||||
`If you need to read existing files elsewhere in the project, that is fine, but generated outputs should stay in ${artifactDir} unless the user says otherwise.\n` +
|
||||
`Use ${workingDir} as the default working directory for this project.\n` +
|
||||
`Write newly generated files relative to ${workingDir} by default.\n` +
|
||||
`Only write outside ${workingDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
|
||||
`</project-file-policy>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
archivedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
artifactsDir: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -37,70 +36,64 @@ describe("sessionCwdSelection", () => {
|
||||
vi.mocked(resolvePath).mockReset();
|
||||
});
|
||||
|
||||
it("resolves the first workspace root to the default artifact root", () => {
|
||||
it("resolves the first workspace root unchanged", () => {
|
||||
expect(
|
||||
resolveProjectDefaultArtifactRoot(
|
||||
makeProject({
|
||||
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
|
||||
}),
|
||||
),
|
||||
).toBe("/Users/wesb/dev/goose2/artifacts");
|
||||
).toBe("/Users/wesb/dev/goose2");
|
||||
});
|
||||
|
||||
it("falls back to the stored project artifact root when no workspace roots exist", () => {
|
||||
it("returns undefined when no workspace roots exist", () => {
|
||||
expect(
|
||||
resolveProjectDefaultArtifactRoot(
|
||||
makeProject({
|
||||
workingDirs: [],
|
||||
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
|
||||
}),
|
||||
),
|
||||
).toBe("/Users/wesb/.goose/projects/sample-project/artifacts");
|
||||
});
|
||||
|
||||
it("returns undefined for a pathless project artifact root", () => {
|
||||
expect(
|
||||
resolveProjectDefaultArtifactRoot(
|
||||
makeProject({
|
||||
workingDirs: [],
|
||||
artifactsDir: " ",
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to global artifacts for a pathless project session cwd", async () => {
|
||||
it("returns undefined for a pathless project fallback directory", () => {
|
||||
expect(
|
||||
resolveProjectDefaultArtifactRoot(
|
||||
makeProject({
|
||||
workingDirs: [],
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to home for a pathless project session cwd", async () => {
|
||||
vi.mocked(resolvePath).mockResolvedValue({
|
||||
path: "/Users/wesb/.goose/artifacts",
|
||||
path: "/Users/wesb",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSessionCwd(
|
||||
makeProject({
|
||||
workingDirs: [],
|
||||
artifactsDir: " ",
|
||||
}),
|
||||
),
|
||||
).resolves.toBe("/Users/wesb/.goose/artifacts");
|
||||
).resolves.toBe("/Users/wesb");
|
||||
|
||||
expect(resolvePath).toHaveBeenCalledWith({
|
||||
parts: ["~", ".goose", "artifacts"],
|
||||
parts: ["~"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultGlobalArtifactRoot", () => {
|
||||
it("resolves the global artifact root through the path resolver", async () => {
|
||||
it("resolves the home directory through the path resolver", async () => {
|
||||
vi.mocked(resolvePath).mockResolvedValue({
|
||||
path: "/Users/wesb/.goose/artifacts",
|
||||
path: "/Users/wesb",
|
||||
});
|
||||
|
||||
await expect(defaultGlobalArtifactRoot()).resolves.toBe(
|
||||
"/Users/wesb/.goose/artifacts",
|
||||
);
|
||||
await expect(defaultGlobalArtifactRoot()).resolves.toBe("/Users/wesb");
|
||||
|
||||
expect(resolvePath).toHaveBeenCalledWith({
|
||||
parts: ["~", ".goose", "artifacts"],
|
||||
parts: ["~"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,15 +19,10 @@ function buildSessionCwdParts(
|
||||
.map((directory) => trimValue(directory))
|
||||
.filter((directory): directory is string => directory !== null);
|
||||
if (workingDirs.length > 0) {
|
||||
return [workingDirs[0], "artifacts"];
|
||||
return [workingDirs[0]];
|
||||
}
|
||||
|
||||
const artifactRoot = trimValue(project?.artifactsDir);
|
||||
if (artifactRoot) {
|
||||
return [artifactRoot];
|
||||
}
|
||||
|
||||
return ["~", ".goose", "artifacts"];
|
||||
return ["~"];
|
||||
}
|
||||
|
||||
export async function resolveSessionCwd(
|
||||
|
||||
@@ -106,7 +106,6 @@ function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
artifactsDir: "/home/user/code/.goose",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("acpNotificationHandler", () => {
|
||||
"local-session",
|
||||
"goose-session",
|
||||
"goose",
|
||||
"/Users/aharvard/.goose/artifacts",
|
||||
"/Users/aharvard",
|
||||
);
|
||||
setActiveMessageId("goose-session", "assistant-1");
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export async function acpLoadSession(
|
||||
gooseSessionId: string,
|
||||
workingDir?: string,
|
||||
): Promise<void> {
|
||||
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
|
||||
const effectiveWorkingDir = workingDir ?? "~";
|
||||
const sid = sessionId.slice(0, 8);
|
||||
const t0 = performance.now();
|
||||
const rollbackSessionRegistration = sessionTracker.registerSession(
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
|
||||
const client = await getClient();
|
||||
const response = await client.unstable_forkSession({
|
||||
sessionId,
|
||||
cwd: "~/.goose/artifacts",
|
||||
cwd: "~",
|
||||
});
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
"openFile": "Open file",
|
||||
"openFolder": "Open folder",
|
||||
"openPath": "Open path",
|
||||
"pathOutsideRoots": "Path is outside allowed project/artifacts roots.",
|
||||
"pathOutsideRoots": "Path is outside allowed roots",
|
||||
"structuredContent": "Structured content",
|
||||
"structuredOutput": "Structured output",
|
||||
"structuredOutputLines": "{{count}} lines"
|
||||
|
||||
Reference in New Issue
Block a user