feat(opencode): add keychain-backed credential broker tool
This commit is contained in:
@@ -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}`,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user