feat(opencode): add keychain-backed credential broker tool

This commit is contained in:
2026-08-21 19:13:51 -05:00
parent 8e59e48f84
commit 4bc84bdb2e
3 changed files with 112 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION from "./credential.txt"
const KEYCHAIN_SERVICE = "neuron"
const GITEA_BASE = "https://git.neuralplatform.ai/api/v1/"
const MAX_RESPONSE_SIZE = 20_000
const redact = (text: string, secret: string) => (secret ? text.split(secret).join("[redacted]") : text)
const readSecret = (account: string) =>
Effect.tryPromise({
try: async () => {
const result = await Bun.$`security find-generic-password -s ${KEYCHAIN_SERVICE} -a ${account} -w`.quiet().nothrow()
if (result.exitCode !== 0) throw new Error(`no keychain credential "${account}" under service "${KEYCHAIN_SERVICE}"`)
return result.stdout.toString().trim()
},
catch: (error) => new Error(error instanceof Error ? error.message : String(error)),
}).pipe(Effect.orDie)
const Parameters = Schema.Struct({
action: Schema.Literals(["gitea-api", "check"]).annotate({
description: "gitea-api performs an authenticated Gitea API call; check only verifies a credential exists",
}),
account: Schema.optional(Schema.String).annotate({
description: 'Keychain account name. Defaults to "gitea-token".',
default: "gitea-token",
}),
path: Schema.optional(Schema.String).annotate({
description: "API path after /api/v1/, e.g. user/keys. Required for gitea-api.",
}),
method: Schema.optional(Schema.Literals(["GET", "POST", "PATCH", "PUT", "DELETE"])).annotate({
description: "HTTP method for gitea-api. Defaults to GET.",
default: "GET",
}),
body: Schema.optional(Schema.String).annotate({
description: "JSON request body for gitea-api.",
}),
})
export const CredentialTool = Tool.define(
"credential",
Effect.gen(function* () {
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, _ctx: Tool.Context) =>
Effect.gen(function* () {
const account = params.account ?? "gitea-token"
if (params.action === "check") {
yield* readSecret(account)
return {
title: `credential check: ${account}`,
metadata: {},
output: `Credential "${account}" exists in the Keychain. Value withheld by design.`,
}
}
const path = params.path
if (!path) throw new Error("path is required for gitea-api")
const secret = yield* readSecret(account)
const response = yield* Effect.tryPromise({
try: () =>
fetch(new URL(path, GITEA_BASE), {
method: params.method ?? "GET",
headers: {
Authorization: `token ${secret}`,
...(params.body ? { "Content-Type": "application/json" } : {}),
},
body: params.body,
}),
catch: (error) => new Error(error instanceof Error ? error.message : String(error)),
}).pipe(Effect.orDie)
const text = yield* Effect.promise(() => response.text())
const safeBody = redact(text.length > MAX_RESPONSE_SIZE ? `${text.slice(0, MAX_RESPONSE_SIZE)}\n...[truncated]` : text, secret)
const safeHeaders = JSON.stringify(Object.fromEntries(response.headers)).replace(/\s+/g, " ").slice(0, 500)
return {
title: `gitea ${params.method ?? "GET"} /${path}`,
metadata: {},
output: `HTTP ${response.status}\nheaders: ${redact(safeHeaders, secret)}\n${safeBody}`,
}
}),
}
}),
)
+22
View File
@@ -0,0 +1,22 @@
Broker credentials without ever seeing them.
Secrets live in the macOS Keychain under service name "neuron", one per
account name. The user stores them once, outside of any chat transcript:
security add-generic-password -s neuron -a gitea-token -w <secret>
This tool reads a secret from the Keychain, uses it to perform the requested
operation, and returns only results — the secret itself is never printed,
returned, or logged. If an output would contain the secret it is replaced
with [redacted].
Actions:
- `gitea-api`: call the Gitea API at git.neuralplatform.ai with the stored
`gitea-token` credential. Provide `path` (after /api/v1/), optional HTTP
`method` (default GET) and JSON `body`. Returns status and response body.
- `check`: verify that a named Keychain credential exists. Never returns the
value.
If a required credential is missing, tell the user to store it with the
security command above rather than pasting secrets into the conversation.
+3
View File
@@ -18,6 +18,7 @@ import { TaskTool } from "./task"
import { Database } from "@opencode-ai/core/database/database"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { CredentialTool } from "./credential"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
@@ -112,6 +113,7 @@ const layer = Layer.effect(
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const credential = yield* CredentialTool
const shell = yield* ShellTool
const globtool = yield* GlobTool
const lstool = yield* LsTool
@@ -231,6 +233,7 @@ const layer = Layer.effect(
write: Tool.init(writetool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
credential: Tool.init(credential),
todo: Tool.init(todo),
search: Tool.init(websearch),
skill: Tool.init(skilltool),