chore: merge dev into v2 (#35962)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
2347f4d7b4
commit
687dbba6a3
150 changed files with 8987 additions and 772 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.15",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import { ConfigMarkdown } from "../markdown"
|
|||
import { FSUtil } from "../../fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
import { Global } from "../../global"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import type { LocationMutation } from "../../location-mutation"
|
||||
import type { ReadTool } from "../../tool/read"
|
||||
import type { EditTool } from "../../tool/edit"
|
||||
|
||||
const legacySources = [
|
||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||
|
|
@ -18,6 +23,11 @@ const legacySources = [
|
|||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set([
|
||||
"model",
|
||||
"variant",
|
||||
|
|
@ -37,6 +47,7 @@ export const Plugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
|
|
@ -58,11 +69,14 @@ export const Plugin = define({
|
|||
})
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const permissions = expandPermissions(
|
||||
loaded.documents.flatMap((document) => document.info.permissions ?? []),
|
||||
global.home,
|
||||
)
|
||||
const configuredDefault = Config.latest(loaded.documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
}
|
||||
|
||||
for (const document of loaded.documents) {
|
||||
|
|
@ -75,7 +89,7 @@ export const Plugin = define({
|
|||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (!exists) agent.permissions.push(...permissions)
|
||||
if (item.model !== undefined)
|
||||
agent.model = {
|
||||
id: item.model.model,
|
||||
|
|
@ -92,7 +106,9 @@ export const Plugin = define({
|
|||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
if (item.permissions !== undefined) {
|
||||
agent.permissions.push(...expandPermissions(item.permissions, global.home))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +126,27 @@ export const Plugin = define({
|
|||
}),
|
||||
})
|
||||
|
||||
function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
|
||||
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
|
||||
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
|
||||
return rules.map((rule) =>
|
||||
isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule,
|
||||
)
|
||||
}
|
||||
|
||||
function isPathAction(action: string): action is PathAction {
|
||||
return pathActions.some((item) => item === action)
|
||||
}
|
||||
|
||||
function expandHome(resource: string, home: string) {
|
||||
if (resource.startsWith("~/")) return home + resource.slice(1)
|
||||
if (resource === "~") return home
|
||||
if (resource === "$HOME") return home
|
||||
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
||||
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
||||
return resource
|
||||
}
|
||||
|
||||
function discover(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.forEach(legacySources, (source) =>
|
||||
fs
|
||||
|
|
|
|||
|
|
@ -62,9 +62,10 @@ export namespace FSUtil {
|
|||
})
|
||||
|
||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||
return yield* fs
|
||||
.readFileString(path)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
return yield* fs.readFileString(path).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.succeed(undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
|
|
@ -22,6 +24,33 @@ const defaultPermissions = [
|
|||
] satisfies PermissionV2.Ruleset
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("matches POSIX paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadHomePermissions("/home/test")
|
||||
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
|
||||
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
|
||||
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
|
||||
expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
|
||||
expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
|
||||
expect(PermissionV2.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||
expect(
|
||||
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
|
||||
"deny",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
|
@ -280,3 +309,51 @@ Use native v2 fields.`,
|
|||
),
|
||||
)
|
||||
})
|
||||
|
||||
function loadHomePermissions(home: string) {
|
||||
return Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
external_directory: {
|
||||
"~/p/**": "allow",
|
||||
"/some/~/path": "deny",
|
||||
"$HOMELESS/**": "deny",
|
||||
},
|
||||
bash: {
|
||||
"$HOME/private/**": "deny",
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
build: {
|
||||
permission: {
|
||||
external_directory: {
|
||||
"$HOME/cache/**": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
)
|
||||
|
||||
const agent = yield* agents.get(build)
|
||||
if (!agent) throw new Error("expected configured build agent")
|
||||
return agent.permissions
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,35 +185,33 @@ describeWatcher("LocationWatcher", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("watches non-git roots", () =>
|
||||
it.live("skips non-git roots", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "plain.txt")
|
||||
yield* ready(directory)
|
||||
expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
|
||||
file,
|
||||
event: "add",
|
||||
})
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -225,7 +223,10 @@ describeWatcher("LocationWatcher", () => {
|
|||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
|
||||
yield* ready(tmp.path).pipe(
|
||||
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
|
||||
Effect.scoped,
|
||||
)
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue