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
12 changes: 6 additions & 6 deletions backend/cli/src/project/trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,23 +140,23 @@ export namespace ProjectTrust {
export async function status(project: Project.Info): Promise<Status> {
const canonical = root(project)
const saved = await record(project)
if (saved?.root === canonical && saved.state === "trusted") {
if (saved?.root !== canonical || saved.state !== "revoked") {
return {
projectID: project.id,
root: canonical,
revision: saved.revision,
revision: saved?.revision ?? 1,
state: "trusted",
source: "persisted",
source: saved ? "persisted" : "default",
canExecuteProjectCode: true,
time: saved.time,
time: saved?.time,
}
}
return {
projectID: project.id,
root: canonical,
revision: saved?.revision ?? 1,
state: saved?.state === "revoked" ? "revoked" : "untrusted",
source: saved ? "persisted" : "default",
state: "revoked",
source: "persisted",
canExecuteProjectCode: false,
time: saved?.time,
remediation: remediation(project),
Expand Down
9 changes: 0 additions & 9 deletions backend/cli/src/server/routes/notebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,6 @@ const identity = (input: { sessionID: string; id: string; language: Language }):
language: input.language,
})

const primary = (sessionID: string): KernelIdentity => ({
projectID: Instance.project.id,
sessionID,
name: "agent",
language: "python",
})

const owner = async (c: Context, sessionID: string) =>
Session.get(sessionID)
.then((session) => {
Expand Down Expand Up @@ -189,12 +182,10 @@ export const NotebookRoutes = lazy(() =>
const owners = new Set<string>()
if (query.sessionID) {
owners.add(query.sessionID)
KernelRuntime.ensure(primary(query.sessionID))
}
if (!query.sessionID) {
for await (const session of Session.list()) {
owners.add(session.id)
KernelRuntime.ensure(primary(session.id))
}
}
const live = KernelRuntime.list(query.sessionID).filter((kernel) => owners.has(kernel.sessionID))
Expand Down
2 changes: 1 addition & 1 deletion backend/cli/src/server/routes/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const ProjectRoutes = lazy(() =>
describeRoute({
summary: "Inspect project trust",
description:
"Inspect whether project-local code may execute. Projects are untrusted by default; read-only project opening remains available.",
"Inspect whether project-local code may execute. Project code is enabled by default and remains disabled only after an explicit revocation.",
operationId: "project.trust.get",
responses: {
200: {
Expand Down
8 changes: 6 additions & 2 deletions backend/cli/test/project/execution-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ test("session execution authority is inspectable through the project route", asy
const project = await Project.fromDirectory(tmp.path)
const sessionID = await Instance.provide({
directory: tmp.path,
fn: async () => (await Session.create({})).id,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
return (await Session.create({})).id
},
})
const fetch = Server.internalFetch()
const response = await fetch(
Expand Down Expand Up @@ -58,6 +61,7 @@ test("read-only project authority rejects terminal, shell, and kernel before pro
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
const session = await Session.create({})
const marker = path.join(tmp.path, "process-spawned")
const decision = await ExecutionAuthority.decide({
Expand All @@ -72,7 +76,7 @@ test("read-only project authority rejects terminal, shell, and kernel before pro
mode: "read_only",
projectID: Instance.project.id,
sessionID: session.id,
trustRevision: 1,
trustRevision: 2,
sandbox: {
enabled: true,
network: "deny",
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/test/project/execution-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test("built-in project formatter checks trust on every cached file edit", async
directory: tmp.path,
fn: async () => {
try {
await ProjectTrust.update(Instance.project, { trusted: false })
Format.init()
await Bus.publish(File.Event.Edited, { file: tmp.extra.file })
expect(await Bun.file(tmp.extra.marker).exists()).toBe(false)
Expand Down Expand Up @@ -143,6 +144,7 @@ test("built-in project LSP denies, executes when trusted, and stops its cached c
directory: tmp.path,
fn: async () => {
try {
await ProjectTrust.update(Instance.project, { trusted: false })
await LSP.init()
await LSP.touchFile(tmp.extra.file)
expect(await Bun.file(tmp.extra.marker).exists()).toBe(false)
Expand Down
60 changes: 34 additions & 26 deletions backend/cli/test/project/trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ description: ${name} trust test skill.
)
}

test("untrusted project opens read-only without importing or executing project code", async () => {
test("project code is enabled by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const local = path.join(dir, ".openscience")
Expand Down Expand Up @@ -87,20 +87,20 @@ export default async function Probe() {
const skills = await Skill.all()
const mcps = await MCP.status()

expect(status.state).toBe("untrusted")
expect(status.canExecuteProjectCode).toBe(false)
expect(status.remediation?.body.root).toBe(Instance.project.worktree)
expect(status.state).toBe("trusted")
expect(status.source).toBe("default")
expect(status.canExecuteProjectCode).toBe(true)
expect(status.remediation).toBeUndefined()
expect(visible.mcp?.probe).toBeDefined()
expect(executable.mcp?.probe).toBeUndefined()
expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeUndefined()
expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeUndefined()
expect(skills.some((item) => item.name === "project-probe")).toBe(false)
expect(mcps.probe).toBeUndefined()
expect(executable.mcp?.probe).toBeDefined()
expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeDefined()
expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeDefined()
expect(skills.some((item) => item.name === "project-probe")).toBe(true)
expect(mcps.probe).toBeDefined()
},
})

expect(await Bun.file(tmp.extra).exists()).toBe(false)
expect(await Bun.file(path.join(tmp.path, ".openscience", "node_modules")).exists()).toBe(false)
expect(await Bun.file(tmp.extra).exists()).toBe(true)
})

test("trust is canonical, project-isolated, and revocation stops project hooks", async () => {
Expand Down Expand Up @@ -159,7 +159,8 @@ test("trust is canonical, project-isolated, and revocation stops project hooks",
})
expect(alias.state).toBe("trusted")
expect(alias.root).toBe(trusted.root)
expect(isolated.state).toBe("untrusted")
expect(isolated.state).toBe("trusted")
expect(isolated.source).toBe("default")
expect(isolated.projectID).not.toBe(trusted.projectID)

await Instance.disposeAll()
Expand Down Expand Up @@ -192,7 +193,7 @@ test("trust is canonical, project-isolated, and revocation stops project hooks",
expect(await Bun.file(first.extra).exists()).toBe(false)
})

test("user-global plugins and skills remain available in an untrusted project", async () => {
test("user-global and project-local plugins and skills are available by default", async () => {
const file = path.join(Global.Path.home, ".claude", "skills", "global-probe", "SKILL.md")
const global = path.dirname(file)
const plugin = path.join(Global.Path.config, "plugin", "global-probe.ts")
Expand Down Expand Up @@ -224,7 +225,7 @@ test("user-global plugins and skills remain available in an untrusted project",
fn: async () => {
const skills = await Skill.all()
expect(skills.some((item) => item.name === "global-probe")).toBe(true)
expect(skills.some((item) => item.name === "local-probe")).toBe(false)
expect(skills.some((item) => item.name === "local-probe")).toBe(true)
},
})
expect(await Bun.file(marker).text()).toBe("ran")
Expand All @@ -235,11 +236,12 @@ test("user-global plugins and skills remain available in an untrusted project",
}
})

test("denials carry structured remediation without blocking project inspection", async () => {
test("explicit revocation carries structured remediation without blocking project inspection", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
const status = await ProjectTrust.status(Instance.project)
expect(await Config.get()).toBeDefined()
await expect(ProjectTrust.require(Instance.project, "startup_script")).rejects.toMatchObject({
Expand All @@ -253,12 +255,13 @@ test("denials carry structured remediation without blocking project inspection",
})
})

test("untrusted startup scripts fail closed before spawning a shell", async () => {
test("revoked startup scripts fail closed before spawning a shell", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "startup-ran")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
await Project.update({
projectID: Instance.project.id,
commands: {
Expand Down Expand Up @@ -288,7 +291,7 @@ test("untrusted startup scripts fail closed before spawning a shell", async () =
expect(await Bun.file(marker).text()).toBe("startup")
})

test("trust state is inspectable and revocable through the project permission surface", async () => {
test("default trust is inspectable and revocable through the project permission surface", async () => {
await using tmp = await tmpdir()
const project = await Project.fromDirectory(tmp.path)
const fetch = Server.internalFetch()
Expand All @@ -300,18 +303,11 @@ test("trust state is inspectable and revocable through the project permission su
const status = ProjectTrust.Status.parse(await initial.json())

expect(initial.status).toBe(200)
expect(status.state).toBe("untrusted")

const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, {
method: "PUT",
headers,
body: JSON.stringify(status.remediation?.body),
})
expect(trusted.status).toBe(200)
expect(await trusted.json()).toMatchObject({
expect(status).toMatchObject({
projectID: project.project.id,
root: project.project.worktree,
state: "trusted",
source: "default",
canExecuteProjectCode: true,
})

Expand All @@ -328,4 +324,16 @@ test("trust state is inspectable and revocable through the project permission su
code: "trust_project_required",
},
})

const disabled = await ProjectTrust.status(project.project)
const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, {
method: "PUT",
headers,
body: JSON.stringify(disabled.remediation?.body),
})
expect(trusted.status).toBe(200)
expect(await trusted.json()).toMatchObject({
state: "trusted",
canExecuteProjectCode: true,
})
})
1 change: 1 addition & 0 deletions backend/cli/test/provider/project-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ test("untrusted project provider remains readable without importing its file mod
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
const model = await Provider.getModel("probe", "m")
expect(model.api.npm.startsWith("file://")).toBe(true)
expect(model.api.npm.endsWith("/test/fixture/provider-module.mjs")).toBe(true)
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/test/provider/token-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ test("untrusted project tokenCommand cannot spawn", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
const model = await Provider.getModel("token-cmd", "m")
const language = await Provider.getLanguage(model)
await generateText({ model: language, prompt: "hi" }).catch(() => {})
Expand All @@ -147,6 +148,7 @@ test("untrusted project npm provider cannot install or import", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await ProjectTrust.update(Instance.project, { trusted: false })
const model = await Provider.getModel("probe", "m")
expect(model.api.npm).toBe("project-provider-probe")
await expect(Provider.getLanguage(model)).rejects.toBeInstanceOf(Provider.InitError)
Expand Down
51 changes: 9 additions & 42 deletions backend/cli/test/server/notebook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,46 +77,21 @@ describe("/notebook routes", () => {
)
})

test("represents every real session with a lazy default Python record", async () => {
test("does not invent kernels for untouched sessions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const app = NotebookRoutes()
const first = await Session.create({})
const second = await Session.create({})
const response = await app.request("/kernels")
const result = (await response.json()) as {
kernels: Array<{
active: boolean
state: string
sessionID: string
name: string
language: string
incarnation: number | null
execution_count: number
process_id: number | null
process_started_at: number | null
}>
const session = await Session.create({})
const project = (await (await app.request("/kernels")).json()) as { kernels: unknown[] }
const scoped = (await (await app.request(`/kernels?sessionID=${encodeURIComponent(session.id)}`)).json()) as {
kernels: unknown[]
}
const defaults = result.kernels.filter((kernel) => kernel.name === "agent")

expect(defaults).toHaveLength(2)
expect(defaults.map((kernel) => kernel.sessionID).sort()).toEqual([first.id, second.id].sort())
expect(defaults).toEqual(
expect.arrayContaining([
expect.objectContaining({
active: false,
state: "lazy",
language: "python",
incarnation: null,
execution_count: 0,
process_id: null,
process_started_at: null,
target: { kind: "local" },
}),
]),
)
expect(project.kernels).toEqual([])
expect(scoped.kernels).toEqual([])
expect(KernelRuntime.list()).toEqual([])
},
})
})
Expand Down Expand Up @@ -298,14 +273,6 @@ describe("/notebook routes", () => {
language: "python",
execution_count: 2,
}),
expect.objectContaining({
active: false,
state: "lazy",
sessionID: session.id,
name: "agent",
language: "python",
execution_count: 0,
}),
]),
)

Expand Down Expand Up @@ -1040,7 +1007,7 @@ describe("/notebook routes", () => {
await app.request(`/kernels?sessionID=${encodeURIComponent(session.id)}`)
).json()) as typeof inventory
expect(listed.kernels.some((value) => value.id === kernel.id)).toBe(false)
expect(listed.kernels).toContainEqual(expect.objectContaining({ name: "agent", language: "python" }))
expect(listed.kernels).toEqual([])
},
})
}, 30_000)
Expand Down
1 change: 1 addition & 0 deletions backend/cli/test/server/settings-compute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async function session(directory: string, trusted = true) {
init: InstanceBootstrap,
fn: async () => {
if (trusted) return executionSession()
await ProjectTrust.update(Instance.project, { trusted: false })
return Session.create({})
},
})
Expand Down
6 changes: 4 additions & 2 deletions frontend/workspace/src/artifacts/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,16 @@ describe("artifact context", () => {
expect(clearOwnedArtifact(undefined, active.id)).toBeUndefined()
})

test("isolates and restores selected artifacts by project and session", () => {
test("keeps selected artifacts across sessions while isolating projects", () => {
const storage = memoryStorage()
const first = createArtifactState({ storage })
const alpha = createArtifactContext({ directory: "/alpha", path: "result.csv" })
const beta = createArtifactContext({ directory: "/beta", path: "report.pdf" })

first.activateScope("project-a", "session-a")
first.activate(alpha)
first.activateScope("project-a", "session-b")
expect(first.active()?.id).toBe(alpha.id)
first.activateScope("project-b", "session-a")
expect(first.active()).toBeUndefined()
first.activate(beta)
Expand All @@ -110,6 +112,6 @@ describe("artifact context", () => {
restored.activateScope("project-b", "session-a")
expect(restored.active()?.id).toBe(beta.id)
restored.activateScope("project-a", "session-b")
expect(restored.active()).toBeUndefined()
expect(restored.active()?.id).toBe(alpha.id)
})
})
Loading