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 { Database } from "@opencode-ai/core/database/database"
|
||||||
import { TodoWriteTool } from "./todo"
|
import { TodoWriteTool } from "./todo"
|
||||||
import { WebFetchTool } from "./webfetch"
|
import { WebFetchTool } from "./webfetch"
|
||||||
|
import { CredentialTool } from "./credential"
|
||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
import { InvalidTool } from "./invalid"
|
import { InvalidTool } from "./invalid"
|
||||||
import { SkillTool } from "./skill"
|
import { SkillTool } from "./skill"
|
||||||
@@ -112,6 +113,7 @@ const layer = Layer.effect(
|
|||||||
const plan = yield* PlanExitTool
|
const plan = yield* PlanExitTool
|
||||||
const webfetch = yield* WebFetchTool
|
const webfetch = yield* WebFetchTool
|
||||||
const websearch = yield* WebSearchTool
|
const websearch = yield* WebSearchTool
|
||||||
|
const credential = yield* CredentialTool
|
||||||
const shell = yield* ShellTool
|
const shell = yield* ShellTool
|
||||||
const globtool = yield* GlobTool
|
const globtool = yield* GlobTool
|
||||||
const lstool = yield* LsTool
|
const lstool = yield* LsTool
|
||||||
@@ -231,6 +233,7 @@ const layer = Layer.effect(
|
|||||||
write: Tool.init(writetool),
|
write: Tool.init(writetool),
|
||||||
task: Tool.init(task),
|
task: Tool.init(task),
|
||||||
fetch: Tool.init(webfetch),
|
fetch: Tool.init(webfetch),
|
||||||
|
credential: Tool.init(credential),
|
||||||
todo: Tool.init(todo),
|
todo: Tool.init(todo),
|
||||||
search: Tool.init(websearch),
|
search: Tool.init(websearch),
|
||||||
skill: Tool.init(skilltool),
|
skill: Tool.init(skilltool),
|
||||||
|
|||||||
Reference in New Issue
Block a user