mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d4a802ac | |||
| c4ae3e429c | |||
| e9cb360cb7 | |||
| 596d4e4490 | |||
| 45451095f7 | |||
| aae354c951 | |||
| 4cddda3e16 | |||
| 834aa036a4 | |||
| 6d1b6a6fb1 | |||
| e78fe8dcba | |||
| f62d826037 | |||
| 342f4239e4 | |||
| 5141fe8d7d | |||
| 3a9dd306db | |||
| e32b6e2cc2 | |||
| fab0e5de04 | |||
| 61105b487f | |||
| 6f584ec641 | |||
| 3a2b2f13f2 | |||
| be13e71fb9 | |||
| dd0c049119 | |||
| ee2b57958d | |||
| 4178b3c6ae | |||
| 2c2752ee02 | |||
| 5a17f44da4 | |||
| 354e55ecef | |||
| 10735f93ca | |||
| ccaebdcd16 |
@@ -0,0 +1,50 @@
|
||||
name: Duplicate Issue Detection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check for duplicate issues
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "*": "deny" } }'
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
|
||||
|
||||
Issue body:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Please search through existing issues in this repository to find any potential duplicates of this new issue. Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find any potential duplicates, please comment on the new issue with:
|
||||
- A brief explanation of why it might be a duplicate
|
||||
- Links to the potentially duplicate issues
|
||||
- A suggestion to check those issues first
|
||||
|
||||
Use this format for the comment:
|
||||
'👋 This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
If none of these address your specific case, please let us know how this issue differs.'
|
||||
|
||||
If no clear duplicates are found, do not comment."
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Guidelines Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-guidelines:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check PR guidelines compliance
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh review*": "deny", "*": "deny" } }'
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
|
||||
|
||||
<pr-number>
|
||||
${{ github.event.pull_request.number }}
|
||||
</pr-number>
|
||||
|
||||
<pr-description>
|
||||
${{ github.event.pull_request.body }}
|
||||
</pr-description>
|
||||
|
||||
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||
|
||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||
|
||||
Command MUST be like this.
|
||||
```
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
|
||||
-f 'body=[summary of issue]' -f 'commit_id=${{ github.event.pull_request.head.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT'
|
||||
```
|
||||
|
||||
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."
|
||||
|
||||
@@ -48,20 +48,20 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
# Temporarily disabled AUR steps
|
||||
# - name: Install makepkg
|
||||
# run: |
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y pacman-package-manager
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
# ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cloud-core",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-function",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-web",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"build": {}
|
||||
},
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -97,6 +97,7 @@ if (!snapshot) {
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
|
||||
/*
|
||||
// AUR package
|
||||
const pkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
@@ -136,6 +137,7 @@ if (!snapshot) {
|
||||
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
|
||||
}
|
||||
*/
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -14,6 +15,11 @@ export namespace Agent {
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
permission: z.object({
|
||||
edit: Config.Permission,
|
||||
bash: z.record(z.string(), Config.Permission),
|
||||
webfetch: Config.Permission.optional(),
|
||||
}),
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
@@ -31,6 +37,13 @@ export namespace Agent {
|
||||
|
||||
const state = App.state("agent", async () => {
|
||||
const cfg = await Config.get()
|
||||
const defaultPermission: Info["permission"] = {
|
||||
edit: "allow",
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
webfetch: "allow",
|
||||
}
|
||||
const result: Record<string, Info> = {
|
||||
general: {
|
||||
name: "general",
|
||||
@@ -41,17 +54,20 @@ export namespace Agent {
|
||||
todowrite: false,
|
||||
},
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
mode: "subagent",
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
tools: {},
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
mode: "primary",
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
tools: {
|
||||
write: false,
|
||||
edit: false,
|
||||
@@ -70,25 +86,48 @@ export namespace Agent {
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: defaultPermission,
|
||||
options: {},
|
||||
tools: {},
|
||||
}
|
||||
const { model, prompt, tools, description, temperature, top_p, mode, ...extra } = value
|
||||
const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
|
||||
item.options = {
|
||||
...item.options,
|
||||
...extra,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools)
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (prompt) item.prompt = prompt
|
||||
if (tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...value.tools,
|
||||
...tools,
|
||||
}
|
||||
if (value.description) item.description = value.description
|
||||
if (value.temperature != undefined) item.temperature = value.temperature
|
||||
if (value.top_p != undefined) item.topP = value.top_p
|
||||
if (value.mode) item.mode = value.mode
|
||||
if (description) item.description = description
|
||||
if (temperature != undefined) item.temperature = temperature
|
||||
if (top_p != undefined) item.topP = top_p
|
||||
if (mode) item.mode = mode
|
||||
|
||||
if (permission ?? cfg.permission) {
|
||||
const merged = mergeDeep(cfg.permission ?? {}, permission ?? {})
|
||||
if (merged.edit) item.permission.edit = merged.edit
|
||||
if (merged.webfetch) item.permission.webfetch = merged.webfetch
|
||||
if (merged.bash) {
|
||||
if (typeof merged.bash === "string") {
|
||||
item.permission.bash = {
|
||||
"*": merged.bash,
|
||||
}
|
||||
}
|
||||
// if granular permissions are provided, default to "ask"
|
||||
if (typeof merged.bash === "object") {
|
||||
item.permission.bash = mergeDeep(
|
||||
{
|
||||
"*": "ask",
|
||||
},
|
||||
merged.bash,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -110,6 +110,10 @@ export namespace Config {
|
||||
].map((x) => "file://" + x),
|
||||
)
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
@@ -164,6 +168,9 @@ export namespace Config {
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Agent = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
@@ -174,6 +181,13 @@ export namespace Config {
|
||||
disable: z.boolean().optional(),
|
||||
description: z.string().optional().describe("Description of when to use the agent"),
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
|
||||
permission: z
|
||||
.object({
|
||||
edit: Permission.optional(),
|
||||
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
||||
webfetch: Permission.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.openapi({
|
||||
@@ -243,9 +257,6 @@ export namespace Config {
|
||||
})
|
||||
export type Layout = z.infer<typeof Layout>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
|
||||
@@ -3,6 +3,7 @@ export namespace Flag {
|
||||
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
|
||||
@@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
try {
|
||||
} catch (e) {}
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
|
||||
@@ -155,7 +155,7 @@ export namespace Permission {
|
||||
public readonly permissionID: string,
|
||||
public readonly toolCallID?: string,
|
||||
) {
|
||||
super(`The user rejected permission to use this functionality`)
|
||||
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,6 +523,7 @@ export namespace Session {
|
||||
t.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: new AbortController().signal,
|
||||
agent: input.agent!,
|
||||
messageID: userMsg.id,
|
||||
metadata: async () => {},
|
||||
}),
|
||||
@@ -765,7 +766,7 @@ export namespace Session {
|
||||
|
||||
const enabledTools = pipe(
|
||||
agent.tools,
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)),
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
|
||||
mergeDeep(input.tools ?? {}),
|
||||
)
|
||||
for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
|
||||
@@ -791,6 +792,7 @@ export namespace Session {
|
||||
abort: options.abortSignal!,
|
||||
messageID: assistantMsg.id,
|
||||
callID: options.toolCallId,
|
||||
agent: agent.name,
|
||||
metadata: async (val) => {
|
||||
const match = processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
import { App } from "../app/app"
|
||||
import { Permission } from "../permission"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { $ } from "bun"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
@@ -40,20 +40,8 @@ export const BashTool = Tool.define("bash", {
|
||||
async execute(params, ctx) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
const app = App.info()
|
||||
const cfg = await Config.get()
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
const permissions = (() => {
|
||||
const value = cfg.permission?.bash
|
||||
if (!value)
|
||||
return {
|
||||
"*": "allow",
|
||||
}
|
||||
if (typeof value === "string")
|
||||
return {
|
||||
"*": value,
|
||||
}
|
||||
return value
|
||||
})()
|
||||
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
|
||||
|
||||
let needsAsk = false
|
||||
for (const node of tree.rootNode.descendantsOfType("command")) {
|
||||
@@ -93,17 +81,10 @@ export const BashTool = Tool.define("bash", {
|
||||
|
||||
// always allow cd if it passes above check
|
||||
if (!needsAsk && command[0] !== "cd") {
|
||||
const action = (() => {
|
||||
for (const [pattern, value] of Object.entries(permissions)) {
|
||||
const match = Wildcard.match(node.text, pattern)
|
||||
log.info("checking", { text: node.text.trim(), pattern, match })
|
||||
if (match) return value
|
||||
}
|
||||
return "ask"
|
||||
})()
|
||||
const action = Wildcard.all(node.text, permissions)
|
||||
if (action === "deny") {
|
||||
throw new Error(
|
||||
"The user has specifically restricted access to this command, you are not allowed to execute it.",
|
||||
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
|
||||
)
|
||||
}
|
||||
if (action === "ask") needsAsk = true
|
||||
|
||||
@@ -14,8 +14,8 @@ import { App } from "../app/app"
|
||||
import { File } from "../file"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
@@ -40,7 +40,7 @@ export const EditTool = Tool.define("edit", {
|
||||
throw new Error(`File ${filePath} is not in the current working directory`)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
@@ -48,7 +48,7 @@ export const EditTool = Tool.define("edit", {
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
@@ -77,7 +77,7 @@ export const EditTool = Tool.define("edit", {
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { Config } from "../config/config"
|
||||
import type { Agent } from "../agent/agent"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const ALL = [
|
||||
@@ -66,20 +66,23 @@ export namespace ToolRegistry {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function enabled(_providerID: string, _modelID: string): Promise<Record<string, boolean>> {
|
||||
const cfg = await Config.get()
|
||||
export async function enabled(
|
||||
_providerID: string,
|
||||
_modelID: string,
|
||||
agent: Agent.Info,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const result: Record<string, boolean> = {}
|
||||
result["patch"] = false
|
||||
|
||||
if (cfg.permission?.edit === "deny") {
|
||||
if (agent.permission.edit === "deny") {
|
||||
result["edit"] = false
|
||||
result["patch"] = false
|
||||
result["write"] = false
|
||||
}
|
||||
if (cfg?.permission?.bash === "deny") {
|
||||
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
|
||||
result["bash"] = false
|
||||
}
|
||||
if (cfg?.permission?.webfetch === "deny") {
|
||||
if (agent.permission.webfetch === "deny") {
|
||||
result["webfetch"] = false
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export namespace Tool {
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
callID?: string
|
||||
abort: AbortSignal
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
|
||||
@@ -8,8 +8,8 @@ import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
@@ -28,8 +28,8 @@ export const WriteTool = Tool.define("write", {
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.permission?.edit === "ask")
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
if (agent.permission.edit === "ask")
|
||||
await Permission.ask({
|
||||
type: "write",
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
@@ -33,7 +34,7 @@ describe("tool.bash", () => {
|
||||
|
||||
test("cd ../ should fail outside of project root", async () => {
|
||||
await App.provide({ cwd: projectRoot }, async () => {
|
||||
await expect(
|
||||
expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -912,6 +912,15 @@ export type AgentConfig = {
|
||||
*/
|
||||
description?: string
|
||||
mode?: string
|
||||
permission?: {
|
||||
edit?: string
|
||||
bash?:
|
||||
| string
|
||||
| {
|
||||
[key: string]: string
|
||||
}
|
||||
webfetch?: string
|
||||
}
|
||||
[key: string]:
|
||||
| unknown
|
||||
| string
|
||||
@@ -921,6 +930,15 @@ export type AgentConfig = {
|
||||
}
|
||||
| boolean
|
||||
| string
|
||||
| {
|
||||
edit?: string
|
||||
bash?:
|
||||
| string
|
||||
| {
|
||||
[key: string]: string
|
||||
}
|
||||
webfetch?: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
@@ -1061,6 +1079,13 @@ export type Agent = {
|
||||
mode: string
|
||||
topP?: number
|
||||
temperature?: number
|
||||
permission: {
|
||||
edit: string
|
||||
bash: {
|
||||
[key: string]: string
|
||||
}
|
||||
webfetch?: string
|
||||
}
|
||||
model?: {
|
||||
modelID: string
|
||||
providerID: string
|
||||
|
||||
@@ -325,7 +325,9 @@ func renderText(
|
||||
|
||||
// wrap styled text
|
||||
styledText := result.String()
|
||||
wrappedText := ansi.WordwrapWc(styledText, width-6, " -")
|
||||
styledText = strings.ReplaceAll(styledText, "-", "\u2011")
|
||||
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
|
||||
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
|
||||
content = base.Width(width - 6).Render(wrappedText)
|
||||
}
|
||||
|
||||
|
||||
@@ -2045,6 +2045,109 @@ func itemWidth(item any) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// forceWrapAttachment splits an attachment's display text across multiple lines
|
||||
func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
|
||||
if width <= 0 {
|
||||
return [][]any{{att}}
|
||||
}
|
||||
|
||||
display := att.Display
|
||||
displayRunes := []rune(display)
|
||||
|
||||
if len(displayRunes) <= width {
|
||||
return [][]any{{att}}
|
||||
}
|
||||
|
||||
var lines [][]any
|
||||
start := 0
|
||||
|
||||
for start < len(displayRunes) {
|
||||
// Calculate how many runes fit in this line
|
||||
end := start + width
|
||||
if end > len(displayRunes) {
|
||||
end = len(displayRunes)
|
||||
}
|
||||
|
||||
// Create a wrapped attachment for this segment
|
||||
wrappedAtt := &attachment.Attachment{
|
||||
ID: att.ID,
|
||||
Type: att.Type,
|
||||
Display: string(displayRunes[start:end]),
|
||||
URL: att.URL,
|
||||
Filename: att.Filename,
|
||||
MediaType: att.MediaType,
|
||||
Source: att.Source,
|
||||
}
|
||||
|
||||
lines = append(lines, []any{wrappedAtt})
|
||||
start = end
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// forceWrapWord splits a word that's too long to fit within the given width
|
||||
func forceWrapWord(word []any, width int) [][]any {
|
||||
if width <= 0 || len(word) == 0 {
|
||||
return [][]any{word}
|
||||
}
|
||||
|
||||
var lines [][]any
|
||||
currentLine := []any{}
|
||||
currentWidth := 0
|
||||
|
||||
for _, item := range word {
|
||||
if att, ok := item.(*attachment.Attachment); ok {
|
||||
// Handle attachment that might be too wide
|
||||
attWidth := uniseg.StringWidth(att.Display)
|
||||
|
||||
// If the attachment display is too wide, split it
|
||||
if attWidth > width {
|
||||
// Finish current line if it has content
|
||||
if len(currentLine) > 0 {
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = []any{}
|
||||
currentWidth = 0
|
||||
}
|
||||
|
||||
// Split the attachment display across multiple lines
|
||||
wrappedAttachment := forceWrapAttachment(att, width)
|
||||
lines = append(lines, wrappedAttachment...)
|
||||
continue
|
||||
}
|
||||
|
||||
// If adding this attachment would exceed the width, start a new line
|
||||
if currentWidth+attWidth > width && len(currentLine) > 0 {
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = []any{}
|
||||
currentWidth = 0
|
||||
}
|
||||
|
||||
currentLine = append(currentLine, item)
|
||||
currentWidth += attWidth
|
||||
} else if r, ok := item.(rune); ok {
|
||||
itemWidth := rw.RuneWidth(r)
|
||||
|
||||
// If adding this rune would exceed the width, start a new line
|
||||
if currentWidth+itemWidth > width && len(currentLine) > 0 {
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = []any{}
|
||||
currentWidth = 0
|
||||
}
|
||||
|
||||
currentLine = append(currentLine, item)
|
||||
currentWidth += itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it has content
|
||||
if len(currentLine) > 0 {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func wrapInterfaces(content []any, width int) [][]any {
|
||||
if width <= 0 {
|
||||
return [][]any{content}
|
||||
@@ -2076,11 +2179,49 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
if !inSpaces {
|
||||
// End of a word
|
||||
if lineW > 0 && lineW+wordW > width {
|
||||
lines = append(lines, word)
|
||||
lineW = wordW
|
||||
// If the word itself is too long to fit on a line, force-wrap it
|
||||
if wordW > width {
|
||||
wrappedLines := forceWrapWord(word, width)
|
||||
lines = append(lines, wrappedLines...)
|
||||
// Calculate width of the last wrapped line
|
||||
lastLine := wrappedLines[len(wrappedLines)-1]
|
||||
lineW = 0
|
||||
for _, item := range lastLine {
|
||||
if r, ok := item.(rune); ok {
|
||||
lineW += rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
lineW += uniseg.StringWidth(att.Display)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, word)
|
||||
lineW = wordW
|
||||
}
|
||||
} else {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
|
||||
lineW += wordW
|
||||
// Check if the word needs to be force-wrapped even when it fits on the current line
|
||||
if wordW > width {
|
||||
currentLine := lines[len(lines)-1]
|
||||
wrappedWord := forceWrapWord(word, width-lineW)
|
||||
if len(wrappedWord) > 0 {
|
||||
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
|
||||
for i := 1; i < len(wrappedWord); i++ {
|
||||
lines = append(lines, wrappedWord[i])
|
||||
}
|
||||
// Calculate width of the last wrapped line
|
||||
lastLine := wrappedWord[len(wrappedWord)-1]
|
||||
lineW = 0
|
||||
for _, item := range lastLine {
|
||||
if r, ok := item.(rune); ok {
|
||||
lineW += rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
lineW += uniseg.StringWidth(att.Display)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
|
||||
lineW += wordW
|
||||
}
|
||||
}
|
||||
word = nil
|
||||
wordW = 0
|
||||
@@ -2110,11 +2251,49 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
// Handle any remaining word/spaces at the end of the content.
|
||||
if wordW > 0 {
|
||||
if lineW > 0 && lineW+wordW > width {
|
||||
lines = append(lines, word)
|
||||
lineW = wordW
|
||||
// If the word itself is too long to fit on a line, force-wrap it
|
||||
if wordW > width {
|
||||
wrappedLines := forceWrapWord(word, width)
|
||||
lines = append(lines, wrappedLines...)
|
||||
// Calculate width of the last wrapped line
|
||||
lastLine := wrappedLines[len(wrappedLines)-1]
|
||||
lineW = 0
|
||||
for _, item := range lastLine {
|
||||
if r, ok := item.(rune); ok {
|
||||
lineW += rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
lineW += uniseg.StringWidth(att.Display)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, word)
|
||||
lineW = wordW
|
||||
}
|
||||
} else {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
|
||||
lineW += wordW
|
||||
// Check if the word needs to be force-wrapped even when it fits on the current line
|
||||
if wordW > width {
|
||||
currentLine := lines[len(lines)-1]
|
||||
wrappedWord := forceWrapWord(word, width-lineW)
|
||||
if len(wrappedWord) > 0 {
|
||||
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
|
||||
for i := 1; i < len(wrappedWord); i++ {
|
||||
lines = append(lines, wrappedWord[i])
|
||||
}
|
||||
// Calculate width of the last wrapped line
|
||||
lastLine := wrappedWord[len(wrappedWord)-1]
|
||||
lineW = 0
|
||||
for _, item := range lastLine {
|
||||
if r, ok := item.(rune); ok {
|
||||
lineW += rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
lineW += uniseg.StringWidth(att.Display)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
|
||||
lineW += wordW
|
||||
}
|
||||
}
|
||||
}
|
||||
if spaceW > 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -85,6 +86,8 @@ func Extension(path string) string {
|
||||
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
|
||||
content = strings.ReplaceAll(content, RootPath+"/", "")
|
||||
hyphenRegex := regexp.MustCompile(`-([^ ]|$)`)
|
||||
content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
|
||||
rendered, _ := r.Render(content)
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
||||
@@ -105,5 +108,6 @@ func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
|
||||
}
|
||||
}
|
||||
content = strings.Join(lines, "\n")
|
||||
content = strings.ReplaceAll(content, "\u2011", "-")
|
||||
return strings.TrimSuffix(content, "\n")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
|
||||
|
||||
@@ -358,6 +358,147 @@ Here are all the tools can be controlled through the agent config.
|
||||
|
||||
---
|
||||
|
||||
### Permissions
|
||||
|
||||
Permissions control what actions an agent can take.
|
||||
|
||||
- edit, bash, webfetch
|
||||
|
||||
Each permission can be set to allow, ask, or deny.
|
||||
|
||||
- allow, ask, deny
|
||||
|
||||
Configure permissions globally in opencode.json.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "allow",
|
||||
"webfetch": "deny"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can override permissions per agent in JSON.
|
||||
|
||||
```json title="opencode.json" {7-18}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"build": {
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"git push": "ask",
|
||||
"terraform *": "deny"
|
||||
},
|
||||
"webfetch": "ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also set permissions in Markdown agents.
|
||||
|
||||
```markdown title="~/.config/opencode/agent/review.md"
|
||||
---
|
||||
description: Code review without edits
|
||||
mode: subagent
|
||||
permission:
|
||||
edit: deny
|
||||
bash: ask
|
||||
webfetch: deny
|
||||
---
|
||||
|
||||
Only analyze code and suggest changes.
|
||||
```
|
||||
|
||||
Bash permissions support granular patterns for fine-grained control.
|
||||
|
||||
```json title="Allow most, ask for risky, deny terraform"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"git push": "ask",
|
||||
"terraform *": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you provide a granular bash map, the default becomes ask unless you set \* explicitly.
|
||||
|
||||
```json title="Granular defaults to ask"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"bash": {
|
||||
"git status": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agent-level permissions merge over global settings.
|
||||
|
||||
- Global sets defaults; agent overrides when specified
|
||||
|
||||
Specific bash rules can override a global default.
|
||||
|
||||
```json title="Global ask, agent allows safe commands"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": { "bash": "ask" },
|
||||
"agent": {
|
||||
"build": {
|
||||
"permission": {
|
||||
"bash": { "git status": "allow", "*": "ask" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Permissions affect tool availability and prompts differently.
|
||||
|
||||
- deny hides tools (edit also hides write/patch); ask prompts; allow runs
|
||||
|
||||
For quick reference, here are common setups.
|
||||
|
||||
```json title="Read-only reviewer"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"review": {
|
||||
"permission": { "edit": "deny", "bash": "deny", "webfetch": "allow" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json title="Planning agent that can browse but cannot change code"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"plan": {
|
||||
"permission": { "edit": "deny", "bash": "deny", "webfetch": "ask" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the full permissions guide for more patterns.
|
||||
|
||||
- /docs/permissions
|
||||
|
||||
---
|
||||
|
||||
### Mode
|
||||
|
||||
Control the agent's mode with the `mode` config. The `mode` option is used to determine how the agent can be used.
|
||||
|
||||
@@ -21,6 +21,8 @@ Permissions are configured in your `opencode.json` file under the `permission` k
|
||||
| `bash` | Control bash command execution |
|
||||
| `webfetch` | Control web content fetching |
|
||||
|
||||
They can also be configured per agent, see [Agent Configuration](/docs/agents#agent-configuration) for more details.
|
||||
|
||||
---
|
||||
|
||||
### edit
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.36",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user