diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..d7dc6aac2dd7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.github/hardened/*.patch -whitespace diff --git a/.github/hardened/README.md b/.github/hardened/README.md new file mode 100644 index 000000000000..7286c0ed3b0e --- /dev/null +++ b/.github/hardened/README.md @@ -0,0 +1,16 @@ +# Hardened OpenCode CLI prerelease + +This directory freezes the reviewed patch used by the isolated hardened release +workflow. The patch is applied to base commit +`826d9ad46a22bef0294998e08daa3c4904fea28f`; its resulting tree must equal source +commit `94540b2cbd116bb5bcd6c9ac8f4734f3df637a2b`. + +The workflow intentionally produces a draft prerelease that is never production +eligible. It does not replace or modify the upstream publish workflow. Release +mutation occurs only after identity, reproducibility, native execution, manifest, +and provenance gates pass. A protected `hardened-release` GitHub environment must +require reviewer approval. + +The full five-platform build and native verification matrix is CI-only. Local +validation covers the immutable patch, script tests, YAML parsing, and static +workflow policy; it is not evidence that foreign-platform binaries execute. diff --git a/.github/hardened/opencode-hosted-approval-v2-r3.patch b/.github/hardened/opencode-hosted-approval-v2-r3.patch new file mode 100644 index 000000000000..091dd4a07126 --- /dev/null +++ b/.github/hardened/opencode-hosted-approval-v2-r3.patch @@ -0,0 +1,1955 @@ +diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts +index 86238f1a844c1df3ae0c765bc787eeb05d70b165..25653316394264bd9ac224a203d467e1ee6ef29d 100644 +--- a/packages/opencode/src/config/config.ts ++++ b/packages/opencode/src/config/config.ts +@@ -18,7 +18,7 @@ import { isRecord } from "@/util/record" + import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" + import { FSUtil } from "@opencode-ai/core/fs-util" + import { InstanceState } from "@/effect/instance-state" +-import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" ++import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema, Semaphore } from "effect" + import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" + import { EffectFlock } from "@opencode-ai/core/util/effect-flock" + import { containsPath, type InstanceContext } from "../project/instance-context" +@@ -181,6 +181,15 @@ const layer = Layer.effect( + const env = yield* Env.Service + const npmSvc = yield* Npm.Service + const http = yield* HttpClient.HttpClient ++ const writeLocks = new Map() ++ ++ const writeLock = (file: string) => { ++ const hit = writeLocks.get(file) ++ if (hit) return hit ++ const next = Semaphore.makeUnsafe(1) ++ writeLocks.set(file, next) ++ return next ++ } + + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) + +@@ -624,10 +633,14 @@ const layer = Layer.effect( + const update = Effect.fn("Config.update")(function* (config: Info) { + const dir = yield* InstanceState.directory + const file = path.join(dir, "config.json") +- const existing = yield* loadFile(file) +- yield* fs +- .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) +- .pipe(Effect.orDie) ++ yield* writeLock(file).withPermit( ++ Effect.gen(function* () { ++ const existing = yield* loadFile(file) ++ yield* fs ++ .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) ++ .pipe(Effect.orDie) ++ }), ++ ) + }) + + const invalidate = Effect.fn("Config.invalidate")(function* () { +@@ -636,27 +649,31 @@ const layer = Layer.effect( + + const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { + const file = globalConfigFile() +- const before = (yield* readConfigFile(file)) ?? "{}" +- const patch = writableGlobal(config) +- +- let next: Info +- let changed: boolean +- if (!file.endsWith(".jsonc")) { +- const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file) +- const merged = mergeDeep(writable(existing), patch) +- const serialized = JSON.stringify(merged, null, 2) +- changed = serialized !== before +- if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) +- next = merged +- } else { +- const updated = patchJsonc(before, patch) +- next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file) +- changed = updated !== before +- if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) +- } ++ return yield* writeLock(file).withPermit( ++ Effect.gen(function* () { ++ const before = (yield* readConfigFile(file)) ?? "{}" ++ const patch = writableGlobal(config) ++ ++ let next: Info ++ let changed: boolean ++ if (!file.endsWith(".jsonc")) { ++ const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file) ++ const merged = mergeDeep(writable(existing), patch) ++ const serialized = JSON.stringify(merged, null, 2) ++ changed = serialized !== before ++ if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) ++ next = merged ++ } else { ++ const updated = patchJsonc(before, patch) ++ next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file) ++ changed = updated !== before ++ if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) ++ } + +- if (changed) yield* invalidate() +- return { info: next, changed } ++ if (changed) yield* invalidate() ++ return { info: next, changed } ++ }), ++ ) + }) + + return Service.of({ +diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts +index 2e27ff2424dbb000ea9ed7f73471769716ba40a1..f58fcf3a19d983bb1d9173ac52a985b9e4b157ee 100644 +--- a/packages/opencode/src/permission/index.ts ++++ b/packages/opencode/src/permission/index.ts +@@ -2,27 +2,67 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" + import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" + import { InstanceState } from "@/effect/instance-state" + import { Wildcard } from "@opencode-ai/core/util/wildcard" +-import { Deferred, Effect, Layer, Context } from "effect" ++import { Deferred, Effect, Layer, Context, Semaphore } from "effect" + import os from "os" + import { PermissionV1 } from "@opencode-ai/core/v1/permission" ++import { SessionV1 } from "@opencode-ai/core/v1/session" + import { EventV2Bridge } from "@/event-v2-bridge" ++import { randomUUID } from "node:crypto" ++import { HostedApprovalCoordinator } from "@/hosted-approval/coordinator" + + export const Event = PermissionV1.Event + + export interface Interface { + readonly ask: (input: PermissionV1.AskInput) => Effect.Effect + readonly reply: (input: PermissionV1.ReplyInput) => Effect.Effect ++ /** ++ * Claims and settles exactly one pending request when its full external ++ * incarnation still matches. Unlike the interactive reply operation this ++ * never cascades to other requests and never persists an approval rule. ++ */ ++ readonly conditionalReply: ( ++ input: ConditionalReplyInput, ++ ) => Effect.Effect + readonly list: () => Effect.Effect> ++ readonly hostedList: () => Effect.Effect> ++} ++ ++export interface ConditionalReplyInput { ++ readonly requestID: PermissionV1.ID ++ readonly sessionID: PermissionV1.Request["sessionID"] ++ readonly sessionIncarnation: string ++ readonly requestIncarnation: string ++ readonly reply: "once" | "reject" ++ readonly message?: string ++ /** Runs synchronously while the permission state lock is held. */ ++ readonly matches: (request: PermissionV1.Request) => boolean ++} ++ ++export type ConditionalReplyResult = ++ | { readonly status: "applied"; readonly request: PermissionV1.Request } ++ | { readonly status: "mismatch" } ++ ++export interface HostedPendingRequest { ++ readonly request: PermissionV1.Request ++ readonly sessionIncarnation: string ++ readonly requestIncarnation: string + } + + interface PendingEntry { + info: PermissionV1.Request ++ sessionIncarnation: string ++ requestIncarnation: string + deferred: Deferred.Deferred + } + + interface State { + pending: Map + approved: PermissionV1.Rule[] ++ sessionIncarnations: Map ++} ++ ++function incarnation(prefix: "session_incarnation" | "request_incarnation") { ++ return `${prefix}_${randomUUID().replaceAll("-", "")}` + } + + export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule { +@@ -43,20 +83,48 @@ const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service ++ const hostedApproval = yield* HostedApprovalCoordinator.Service ++ const mutex = Semaphore.makeUnsafe(1) + const state = yield* InstanceState.make( + Effect.fn("Permission.state")(function* (ctx) { + void ctx + const state = { + pending: new Map(), + approved: [], ++ sessionIncarnations: new Map(), + } + ++ const unsubscribe = yield* events.listen((event) => { ++ if (event.location?.directory !== ctx.directory) return Effect.void ++ if (event.type !== SessionV1.Event.Created.type && event.type !== SessionV1.Event.Deleted.type) { ++ return Effect.void ++ } ++ const data = event.data as { sessionID: PermissionV1.Request["sessionID"] } ++ return hostedApproval.withConditionalReply( ++ mutex.withPermit( ++ Effect.sync(() => { ++ if (event.type === SessionV1.Event.Deleted.type) { ++ state.sessionIncarnations.delete(data.sessionID) ++ return ++ } ++ // Creation only rotates an identity already observed by this ++ // permission service. New sessions are minted lazily on ask. ++ if (state.sessionIncarnations.has(data.sessionID)) { ++ state.sessionIncarnations.set(data.sessionID, incarnation("session_incarnation")) ++ } ++ }), ++ ), ++ ) ++ }) ++ yield* Effect.addFinalizer(() => unsubscribe) ++ + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) + } + state.pending.clear() ++ state.sessionIncarnations.clear() + }), + ) + +@@ -65,113 +133,192 @@ const layer = Layer.effect( + ) + + const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { +- const { approved, pending } = yield* InstanceState.get(state) +- const { ruleset, ...request } = input +- let needsAsk = false +- +- for (const pattern of request.patterns) { +- const rule = evaluate(request.permission, pattern, ruleset, approved) +- yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) +- if (rule.action === "deny") { +- return yield* new PermissionV1.DeniedError({ +- ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), +- }) +- } +- if (rule.action === "allow") continue +- needsAsk = true +- } +- +- if (!needsAsk) return +- +- const id = request.id ?? PermissionV1.ID.ascending() +- const info: PermissionV1.Request = { +- id, +- sessionID: request.sessionID, +- permission: request.permission, +- patterns: request.patterns, +- metadata: request.metadata, +- always: request.always, +- tool: request.tool, +- } +- yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) +- + const deferred = yield* Deferred.make() +- pending.set(id, { info, deferred }) +- yield* events.publish(Event.Asked, info) ++ const pendingID = yield* hostedApproval.withConditionalReply(mutex.withPermit( ++ Effect.gen(function* () { ++ const { approved, pending, sessionIncarnations } = yield* InstanceState.get(state) ++ const { ruleset, ...request } = input ++ let needsAsk = false ++ for (const pattern of request.patterns) { ++ const rule = evaluate(request.permission, pattern, ruleset, approved) ++ yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) ++ if (rule.action === "deny") { ++ return yield* new PermissionV1.DeniedError({ ++ ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), ++ }) ++ } ++ if (rule.action !== "allow") needsAsk = true ++ } ++ if (!needsAsk) return undefined ++ ++ const id = request.id ?? PermissionV1.ID.ascending() ++ // Caller-provided IDs are allowed by the public schema, but replacing ++ // a live entry would enable request-incarnation ABA and orphan its deferred. ++ if (pending.has(id)) return yield* new PermissionV1.DeniedError({ ruleset: [] }) ++ const info: PermissionV1.Request = { ++ id, ++ sessionID: request.sessionID, ++ permission: request.permission, ++ patterns: request.patterns, ++ metadata: request.metadata, ++ always: request.always, ++ tool: request.tool, ++ } ++ const sessionIncarnation = sessionIncarnations.get(info.sessionID) ?? incarnation("session_incarnation") ++ sessionIncarnations.set(info.sessionID, sessionIncarnation) ++ yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) ++ pending.set(id, { ++ info, ++ sessionIncarnation, ++ requestIncarnation: incarnation("request_incarnation"), ++ deferred, ++ }) ++ yield* events.publish(Event.Asked, info) ++ return id ++ }), ++ )) ++ if (!pendingID) return + return yield* Effect.ensuring( + Deferred.await(deferred), +- Effect.sync(() => { +- pending.delete(id) +- }), ++ hostedApproval.withConditionalReply(mutex.withPermit( ++ Effect.gen(function* () { ++ // Delete only the exact entry owned by this ask. A future request ++ // that reuses the ID must not be removed by this finalizer. ++ const { pending } = yield* InstanceState.get(state) ++ if (pending.get(pendingID)?.deferred === deferred) pending.delete(pendingID) ++ }), ++ )), + ) + }) + +- const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { +- const { approved, pending } = yield* InstanceState.get(state) +- const existing = pending.get(input.requestID) +- if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) +- +- pending.delete(input.requestID) +- yield* events.publish(Event.Replied, { +- sessionID: existing.info.sessionID, +- requestID: existing.info.id, +- reply: input.reply, +- }) +- +- if (input.reply === "reject") { +- yield* Deferred.fail( +- existing.deferred, +- input.message +- ? new PermissionV1.CorrectedError({ feedback: input.message }) +- : new PermissionV1.RejectedError(), +- ) ++ const reply = Effect.fn("Permission.reply")((input: PermissionV1.ReplyInput) => { ++ const settle = mutex.withPermit( ++ Effect.gen(function* () { ++ const { approved, pending } = yield* InstanceState.get(state) ++ const existing = pending.get(input.requestID) ++ if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) + +- for (const [id, item] of pending.entries()) { +- if (item.info.sessionID !== existing.info.sessionID) continue +- pending.delete(id) ++ pending.delete(input.requestID) + yield* events.publish(Event.Replied, { +- sessionID: item.info.sessionID, +- requestID: item.info.id, +- reply: "reject", ++ sessionID: existing.info.sessionID, ++ requestID: existing.info.id, ++ reply: input.reply, + }) +- yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) +- } +- return +- } + +- yield* Deferred.succeed(existing.deferred, undefined) +- if (input.reply === "once") return ++ if (input.reply === "reject") { ++ yield* Deferred.fail( ++ existing.deferred, ++ input.message ++ ? new PermissionV1.CorrectedError({ feedback: input.message }) ++ : new PermissionV1.RejectedError(), ++ ) + +- for (const pattern of existing.info.always) { +- approved.push({ +- permission: existing.info.permission, +- pattern, +- action: "allow", +- }) +- } ++ for (const [id, item] of pending.entries()) { ++ if (item.info.sessionID !== existing.info.sessionID) continue ++ pending.delete(id) ++ yield* events.publish(Event.Replied, { ++ sessionID: item.info.sessionID, ++ requestID: item.info.id, ++ reply: "reject", ++ }) ++ yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) ++ } ++ return ++ } + +- for (const [id, item] of pending.entries()) { +- if (item.info.sessionID !== existing.info.sessionID) continue +- const ok = item.info.patterns.every( +- (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", +- ) +- if (!ok) continue +- pending.delete(id) +- yield* events.publish(Event.Replied, { +- sessionID: item.info.sessionID, +- requestID: item.info.id, +- reply: "always", +- }) +- yield* Deferred.succeed(item.deferred, undefined) +- } +- }) ++ yield* Deferred.succeed(existing.deferred, undefined) ++ if (input.reply === "once") return ++ ++ for (const pattern of existing.info.always) { ++ approved.push({ ++ permission: existing.info.permission, ++ pattern, ++ action: "allow", ++ }) ++ } + +- const list = Effect.fn("Permission.list")(function* () { +- const pending = (yield* InstanceState.get(state)).pending +- return Array.from(pending.values(), (item) => item.info) ++ for (const [id, item] of pending.entries()) { ++ if (item.info.sessionID !== existing.info.sessionID) continue ++ const ok = item.info.patterns.every( ++ (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", ++ ) ++ if (!ok) continue ++ pending.delete(id) ++ yield* events.publish(Event.Replied, { ++ sessionID: item.info.sessionID, ++ requestID: item.info.id, ++ reply: "always", ++ }) ++ yield* Deferred.succeed(item.deferred, undefined) ++ } ++ }), ++ ) ++ return input.reply === "always" ++ ? hostedApproval.withConfigMutation(settle) ++ : hostedApproval.withConditionalReply(settle) + }) + +- return Service.of({ ask, reply, list }) ++ const conditionalReply = Effect.fn("Permission.conditionalReply")((input: ConditionalReplyInput) => ++ Effect.uninterruptible( ++ mutex.withPermit( ++ Effect.gen(function* () { ++ const { pending, sessionIncarnations } = yield* InstanceState.get(state) ++ const existing = pending.get(input.requestID) ++ if (!existing) return { status: "mismatch" } as const ++ if ( ++ existing.info.sessionID !== input.sessionID || ++ existing.sessionIncarnation !== input.sessionIncarnation || ++ sessionIncarnations.get(input.sessionID) !== input.sessionIncarnation || ++ existing.requestIncarnation !== input.requestIncarnation || ++ !input.matches(existing.info) ++ ) { ++ return { status: "mismatch" } as const ++ } ++ ++ pending.delete(input.requestID) ++ yield* events.publish(Event.Replied, { ++ sessionID: existing.info.sessionID, ++ requestID: existing.info.id, ++ reply: input.reply, ++ }) ++ if (input.reply === "once") yield* Deferred.succeed(existing.deferred, undefined) ++ else { ++ yield* Deferred.fail( ++ existing.deferred, ++ input.message ++ ? new PermissionV1.CorrectedError({ feedback: input.message }) ++ : new PermissionV1.RejectedError(), ++ ) ++ } ++ return { status: "applied", request: existing.info } as const ++ }), ++ ), ++ ), ++ ) ++ ++ const list = Effect.fn("Permission.list")(() => ++ hostedApproval.withConditionalReply(mutex.withPermit( ++ Effect.gen(function* () { ++ const pending = (yield* InstanceState.get(state)).pending ++ return Array.from(pending.values(), (item) => item.info) ++ }), ++ )), ++ ) ++ ++ const hostedList = Effect.fn("Permission.hostedList")(() => ++ mutex.withPermit( ++ Effect.gen(function* () { ++ const pending = (yield* InstanceState.get(state)).pending ++ return Array.from(pending.values(), ({ info: request, sessionIncarnation, requestIncarnation }) => ({ ++ request, ++ sessionIncarnation, ++ requestIncarnation, ++ })) ++ }), ++ ), ++ ) ++ ++ return Service.of({ ask, reply, conditionalReply, list, hostedList }) + }), + ) + +@@ -218,6 +365,10 @@ export function visibleTools(tools: Record, ruleset: PermissionV1. + return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) + } + +-export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) ++export const node = LayerNode.make({ ++ service: Service, ++ layer: layer, ++ deps: [EventV2Bridge.node, HostedApprovalCoordinator.node], ++}) + + export * as Permission from "." +diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +index 79959db499bd12a359ac84a9a189faebc84c016e..80ff005def818509dd6fa2b95417f7a58766115e 100644 +--- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts ++++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +@@ -8,6 +8,74 @@ import { InstanceContextMiddleware } from "../middleware/instance-context" + import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" + import { described } from "./metadata" + ++const RuntimeInstanceId = Schema.String.check(Schema.isPattern(/^runtime_instance_[0-9a-f]{32}$/)) ++const ConfigGeneration = Schema.String.check(Schema.isPattern(/^config_generation_[0-9a-f]{32}$/)) ++const SessionIncarnation = Schema.String.check(Schema.isPattern(/^session_incarnation_[0-9a-f]{32}$/)) ++const RequestIncarnation = Schema.String.check(Schema.isPattern(/^request_incarnation_[0-9a-f]{32}$/)) ++const HostedDigest = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)) ++export class HostedConflictError extends Schema.TaggedErrorClass()( ++ "HostedApprovalConflict", ++ {}, ++ { httpApiStatus: 409 }, ++) {} ++export class HostedPreconditionError extends Schema.TaggedErrorClass()( ++ "HostedApprovalPreconditionFailed", ++ {}, ++ { httpApiStatus: 412 }, ++) {} ++export class HostedUnavailableError extends Schema.TaggedErrorClass()( ++ "HostedApprovalUnavailable", ++ {}, ++ { httpApiStatus: 404 }, ++) {} ++export const HostedReplyPayload = Schema.Struct({ ++ schemaVersion: Schema.Literal(2), ++ protocol: Schema.Literal("agent-teams-hosted-approval-v2"), ++ runtimeInstanceId: RuntimeInstanceId, ++ expectedConfigGeneration: ConfigGeneration, ++ requestId: PermissionV1.ID, ++ sessionId: Schema.String, ++ sessionIncarnation: SessionIncarnation, ++ requestIncarnation: RequestIncarnation, ++ expectedPermissionDigest: HostedDigest, ++ decision: Schema.Union([Schema.Literal("allow_once"), Schema.Literal("reject")]), ++}) ++const HostedCapability = Schema.Struct({ ++ schemaVersion: Schema.Literal(2), ++ protocol: Schema.Literal("agent-teams-hosted-approval-v2"), ++ runtimeInstanceId: RuntimeInstanceId, ++ configGeneration: ConfigGeneration, ++ authentication: Schema.Literal("opencode-basic"), ++}) ++const HostedObserve = Schema.Struct({ ++ schemaVersion: Schema.Literal(2), ++ protocol: Schema.Literal("agent-teams-hosted-approval-v2"), ++ runtimeInstanceId: RuntimeInstanceId, ++ configGeneration: ConfigGeneration, ++ sessionId: Schema.String, ++ permissions: Schema.Array(Schema.Struct({ ++ requestId: PermissionV1.ID, ++ sessionId: Schema.String, ++ sessionIncarnation: SessionIncarnation, ++ requestIncarnation: RequestIncarnation, ++ permissionDigest: HostedDigest, ++ rawPermission: PermissionV1.Request, ++ })), ++}) ++const HostedReplySuccess = Schema.Struct({ ++ schemaVersion: Schema.Literal(2), ++ protocol: Schema.Literal("agent-teams-hosted-approval-v2"), ++ status: Schema.Literal("applied"), ++ runtimeInstanceId: RuntimeInstanceId, ++ configGeneration: ConfigGeneration, ++ requestId: PermissionV1.ID, ++ sessionId: Schema.String, ++ sessionIncarnation: SessionIncarnation, ++ requestIncarnation: RequestIncarnation, ++ permissionDigest: HostedDigest, ++ decision: Schema.Union([Schema.Literal("allow_once"), Schema.Literal("reject")]), ++}) ++ + const root = "/permission" + const ReplyPayload = Schema.Struct({ + reply: PermissionV1.Reply, +@@ -18,6 +86,24 @@ export const PermissionApi = HttpApi.make("permission") + .add( + HttpApiGroup.make("permission") + .add( ++ HttpApiEndpoint.get("hostedCapability", "/experimental/agent-teams/hosted-approval-capability", { ++ query: WorkspaceRoutingQuery, ++ success: HostedCapability, ++ error: HostedUnavailableError, ++ }), ++ HttpApiEndpoint.get("hostedObserve", "/experimental/agent-teams/hosted-approval/session/:sessionID/permissions", { ++ params: { sessionID: Schema.String }, ++ query: WorkspaceRoutingQuery, ++ success: HostedObserve, ++ error: [HostedUnavailableError, HttpApiError.InternalServerError], ++ }), ++ HttpApiEndpoint.post("hostedReply", "/experimental/agent-teams/hosted-approval/session/:sessionID/permission/:requestID/reply", { ++ params: { sessionID: Schema.String, requestID: PermissionV1.ID }, ++ query: WorkspaceRoutingQuery, ++ payload: HostedReplyPayload, ++ success: HostedReplySuccess, ++ error: Schema.Union([HttpApiError.BadRequest, HostedConflictError, HostedPreconditionError, HostedUnavailableError]), ++ }), + HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(PermissionV1.Request), "List of pending permissions"), +diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +index 3d0e8a06c09c6b3a2366b521ff05f7eaca63eff8..0ba6cd511d057757c30a53925cd8b37c8b81fb18 100644 +--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts ++++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +@@ -5,19 +5,25 @@ import { Effect } from "effect" + import { HttpApiBuilder } from "effect/unstable/httpapi" + import { InstanceHttpApi } from "../api" + import { markInstanceForDisposal } from "../lifecycle" ++import { HostedApprovalCoordinator } from "@/hosted-approval/coordinator" + + export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) => + Effect.gen(function* () { + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service ++ const hostedApproval = yield* HostedApprovalCoordinator.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) + + const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { +- yield* configSvc.update(ctx.payload) +- yield* markInstanceForDisposal(yield* InstanceState.context) ++ yield* hostedApproval.withConfigMutation( ++ Effect.gen(function* () { ++ yield* configSvc.update(ctx.payload) ++ yield* markInstanceForDisposal(yield* InstanceState.context) ++ }), ++ ) + return ctx.payload + }) + +diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +index c1f588d5a146e66c85f560da20c030873dde2c7e..09f55adba5df7f1e7f45174846e26cc93197d13f 100644 +--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts ++++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +@@ -12,6 +12,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" + import * as Sse from "effect/unstable/encoding/Sse" + import { RootHttpApi } from "../api" + import { GlobalUpgradeInput } from "../groups/global" ++import { HostedApprovalCoordinator } from "@/hosted-approval/coordinator" + + function eventData(data: unknown): Sse.Event { + return { +@@ -70,6 +71,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl + const config = yield* Config.Service + const installation = yield* Installation.Service + const bridge = yield* EffectBridge.make() ++ const hostedApproval = yield* HostedApprovalCoordinator.Service + + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } +@@ -84,7 +86,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl + }) + + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { +- const result = yield* config.updateGlobal(ctx.payload) ++ const result = yield* hostedApproval.withConfigMutation(config.updateGlobal(ctx.payload), (value) => value.changed) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info + }) +diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +index cdb88490605f1311f02a7d8d0c69806a21ddc514..24fe6661578cc45c58011bad789abeb5b4f49cde 100644 +--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts ++++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +@@ -1,13 +1,257 @@ + import { PermissionV1 } from "@opencode-ai/core/v1/permission" + import { Permission } from "@/permission" + import { Effect } from "effect" +-import { HttpApiBuilder } from "effect/unstable/httpapi" ++import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" + import { InstanceHttpApi } from "../api" + import { PermissionNotFoundError } from "../errors" ++import { HostedApprovalCoordinator } from "@/hosted-approval/coordinator" ++import { digest, protocol, rawPermission } from "@/hosted-approval/protocol" ++import { HostedConflictError, HostedPreconditionError, HostedReplyPayload, HostedUnavailableError } from "../groups/permission" ++import { ServerAuth } from "@/server/auth" ++import { Schema, Stream } from "effect" ++import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" ++ ++function rejectDuplicateJsonKeys(text: string): void { ++ let offset = 0 ++ const whitespace = () => { while (/\s/.test(text[offset] ?? "")) offset++ } ++ const string = () => { ++ const start = offset ++ if (text[offset++] !== '"') throw new Error("invalid string") ++ while (offset < text.length) { ++ const char = text[offset++] ++ if (char === '"') return JSON.parse(text.slice(start, offset)) as string ++ if (char === "\\") offset++ ++ } ++ throw new Error("unterminated string") ++ } ++ const value = (): void => { ++ whitespace() ++ if (text[offset] === "{") { ++ offset++ ++ const keys = new Set() ++ whitespace() ++ if (text[offset] === "}") { offset++; return } ++ while (true) { ++ whitespace() ++ const key = string() ++ if (keys.has(key)) throw new Error("duplicate key") ++ keys.add(key) ++ whitespace() ++ if (text[offset++] !== ":") throw new Error("invalid object") ++ value() ++ whitespace() ++ const delimiter = text[offset++] ++ if (delimiter === "}") return ++ if (delimiter !== ",") throw new Error("invalid object") ++ } ++ } ++ if (text[offset] === "[") { ++ offset++ ++ whitespace() ++ if (text[offset] === "]") { offset++; return } ++ while (true) { ++ value(); whitespace() ++ const delimiter = text[offset++] ++ if (delimiter === "]") return ++ if (delimiter !== ",") throw new Error("invalid array") ++ } ++ } ++ if (text[offset] === '"') { string(); return } ++ const match = text.slice(offset).match(/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/) ++ if (!match) throw new Error("invalid value") ++ offset += match[0].length ++ } ++ value(); whitespace() ++ if (offset !== text.length) throw new Error("trailing data") ++} ++ ++const HOSTED_REPLY_KEYS = [ ++ "decision", ++ "expectedConfigGeneration", ++ "expectedPermissionDigest", ++ "protocol", ++ "requestId", ++ "requestIncarnation", ++ "runtimeInstanceId", ++ "schemaVersion", ++ "sessionId", ++ "sessionIncarnation", ++] as const ++ ++export class HostedReplyBodyError extends Error {} ++ ++export const readHostedReplyBody = Effect.fn("PermissionHttpApi.readHostedReplyBody")( ++ (stream: Stream.Stream, limit = 16 * 1024) => ++ Stream.runFoldEffect( ++ stream, ++ () => [] as Uint8Array[], ++ (chunks, chunk) => { ++ const size = chunks.reduce((total, value) => total + value.byteLength, 0) ++ if (size + chunk.byteLength > limit) return Effect.fail(new HostedReplyBodyError("body too large")) ++ chunks.push(chunk) ++ return Effect.succeed(chunks) ++ }, ++ ).pipe( ++ Effect.mapError((error) => error instanceof HostedReplyBodyError ? error : new HostedReplyBodyError("body read failed")), ++ Effect.map((chunks) => { ++ const size = chunks.reduce((total, value) => total + value.byteLength, 0) ++ const bytes = new Uint8Array(size) ++ let offset = 0 ++ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength } ++ return bytes ++ }), ++ ), ++) + + export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => + Effect.gen(function* () { + const svc = yield* Permission.Service ++ const hostedApproval = yield* HostedApprovalCoordinator.Service ++ const serverAuth = yield* ServerAuth.Config ++ ++ const requireHosted = Effect.fn("PermissionHttpApi.requireHosted")(function* () { ++ if (!ServerAuth.required(serverAuth)) return yield* new HostedUnavailableError() ++ return yield* Effect.void ++ }) ++ ++ const hostedCapability = Effect.fn("PermissionHttpApi.hostedCapability")(function* () { ++ yield* requireHosted() ++ return yield* hostedApproval.withConditionalReply( ++ Effect.sync(() => ({ ++ schemaVersion: 2 as const, ++ protocol, ++ ...hostedApproval.snapshot(), ++ authentication: "opencode-basic" as const, ++ })), ++ ) ++ }) ++ ++ const hostedObserve = Effect.fn("PermissionHttpApi.hostedObserve")(function* (ctx: { ++ params: { sessionID: string } ++ }) { ++ yield* requireHosted() ++ return yield* hostedApproval.withConditionalReply( ++ Effect.gen(function* () { ++ const snapshot = hostedApproval.snapshot() ++ const pending = yield* svc.hostedList() ++ const permissions = pending ++ .filter(({ request }) => request.sessionID === ctx.params.sessionID) ++ .map(({ request, sessionIncarnation, requestIncarnation }) => { ++ const raw = rawPermission(request) ++ return { ++ requestId: request.id, ++ sessionId: request.sessionID, ++ sessionIncarnation, ++ requestIncarnation, ++ permissionDigest: digest(raw), ++ rawPermission: raw, ++ } ++ }) ++ const response = { ++ schemaVersion: 2 as const, ++ protocol, ++ ...snapshot, ++ sessionId: ctx.params.sessionID, ++ permissions, ++ } ++ const bytes = Buffer.byteLength(JSON.stringify(response), "utf8") ++ if (permissions.length > 256 || bytes > 1024 * 1024) { ++ yield* Effect.logWarning("hosted approval observe overflow", { ++ sessionIdentity: digest(ctx.params.sessionID).slice(0, 16), ++ droppedCount: permissions.length, ++ droppedBytes: bytes, ++ }) ++ return yield* new HttpApiError.InternalServerError() ++ } ++ return response ++ }), ++ ) ++ }) ++ ++ const hostedReply = Effect.fn("PermissionHttpApi.hostedReply")(function* (ctx: { ++ params: { sessionID: string; requestID: PermissionV1.ID } ++ payload: typeof HostedReplyPayload.Type ++ }) { ++ yield* requireHosted() ++ if (ctx.params.requestID !== ctx.payload.requestId || ctx.params.sessionID !== ctx.payload.sessionId) { ++ return yield* new HttpApiError.BadRequest() ++ } ++ return yield* hostedApproval.withConditionalReply( ++ Effect.gen(function* () { ++ const snapshot = hostedApproval.snapshot() ++ if ( ++ snapshot.runtimeInstanceId !== ctx.payload.runtimeInstanceId || ++ snapshot.configGeneration !== ctx.payload.expectedConfigGeneration ++ ) return yield* new HostedPreconditionError() ++ ++ const result = yield* svc.conditionalReply({ ++ requestID: ctx.params.requestID, ++ sessionID: ctx.payload.sessionId as PermissionV1.Request["sessionID"], ++ sessionIncarnation: ctx.payload.sessionIncarnation, ++ requestIncarnation: ctx.payload.requestIncarnation, ++ reply: ctx.payload.decision === "allow_once" ? "once" : "reject", ++ matches: (request) => digest(rawPermission(request)) === ctx.payload.expectedPermissionDigest, ++ }) ++ if (result.status === "mismatch") return yield* new HostedConflictError() ++ return { ++ schemaVersion: 2 as const, ++ protocol, ++ status: "applied" as const, ++ runtimeInstanceId: snapshot.runtimeInstanceId, ++ configGeneration: snapshot.configGeneration, ++ requestId: ctx.payload.requestId, ++ sessionId: ctx.payload.sessionId, ++ sessionIncarnation: ctx.payload.sessionIncarnation, ++ requestIncarnation: ctx.payload.requestIncarnation, ++ permissionDigest: ctx.payload.expectedPermissionDigest, ++ decision: ctx.payload.decision, ++ } ++ }), ++ ) ++ }) ++ ++ const hostedReplyRaw = Effect.fn("PermissionHttpApi.hostedReplyRaw")(function* (ctx: { ++ params: { sessionID: string; requestID: PermissionV1.ID } ++ request: HttpServerRequest.HttpServerRequest ++ }) { ++ const unavailable = yield* requireHosted().pipe( ++ Effect.match({ onFailure: () => true, onSuccess: () => false }), ++ ) ++ if (unavailable) return HttpServerResponse.empty({ status: 404 }) ++ const bytes = yield* readHostedReplyBody(ctx.request.stream).pipe( ++ Effect.catch(() => Effect.succeed(undefined)), ++ ) ++ if (!bytes) return HttpServerResponse.empty({ status: 400 }) ++ let text: string ++ try { ++ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes) ++ rejectDuplicateJsonKeys(text) ++ } catch { ++ return HttpServerResponse.empty({ status: 400 }) ++ } ++ let untyped: unknown ++ try { untyped = JSON.parse(text) } catch { return HttpServerResponse.empty({ status: 400 }) } ++ if (!untyped || typeof untyped !== "object" || Array.isArray(untyped)) return HttpServerResponse.empty({ status: 400 }) ++ const keys = Object.keys(untyped).sort() ++ if (keys.length !== HOSTED_REPLY_KEYS.length || keys.some((key, index) => key !== HOSTED_REPLY_KEYS[index])) { ++ return HttpServerResponse.empty({ status: 400 }) ++ } ++ const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(HostedReplyPayload))(text, { ++ onExcessProperty: "error", ++ }).pipe( ++ Effect.catch(() => Effect.succeed(undefined)), ++ ) ++ if (!payload) return HttpServerResponse.empty({ status: 400 }) ++ const result = yield* hostedReply({ params: ctx.params, payload }).pipe( ++ Effect.catchTags({ ++ BadRequest: () => Effect.succeed(HttpServerResponse.empty({ status: 400 })), ++ HostedApprovalConflict: () => Effect.succeed(HttpServerResponse.empty({ status: 409 })), ++ HostedApprovalPreconditionFailed: () => Effect.succeed(HttpServerResponse.empty({ status: 412 })), ++ }), ++ ) ++ if (HttpServerResponse.isHttpServerResponse(result)) return result ++ return HttpServerResponse.jsonUnsafe(result) ++ }) + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() +@@ -36,6 +280,11 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss + return true + }) + +- return handlers.handle("list", list).handle("reply", reply) ++ return handlers ++ .handle("hostedCapability", hostedCapability) ++ .handle("hostedObserve", hostedObserve) ++ .handleRaw("hostedReply", hostedReplyRaw) ++ .handle("list", list) ++ .handle("reply", reply) + }), + ) +diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts +index fb9d2db65621540a514786f2061714f16b6c766c..d942753d204268d24d53a36804cd832ff6f884f5 100644 +--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts ++++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts +@@ -49,6 +49,7 @@ import { ToolRegistry } from "@/tool/registry" + import { Truncate } from "@/tool/truncate" + import { Worktree } from "@/worktree" + import { RuntimeFlags } from "@/effect/runtime-flags" ++import { HostedApprovalCoordinator } from "@/hosted-approval/coordinator" + import { MoveSession } from "@opencode-ai/core/control-plane/move-session" + import { Database } from "@opencode-ai/core/database/database" + import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" +@@ -140,6 +141,7 @@ const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(Serve + const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), ++ Layer.provide(HostedApprovalCoordinator.layer), + Layer.provide(schemaErrorLayer), + Layer.provide(httpApiAuthLayer), + ) +@@ -169,6 +171,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( + tuiHandlers, + workspaceHandlers, + ]), ++ Layer.provide(HostedApprovalCoordinator.layer), ++ Layer.provide(ServerAuth.Config.layer), + ) + + const instanceRoutes = instanceApiRoutes.pipe( +@@ -237,6 +241,7 @@ const app = LayerNode.group([ + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, ++ HostedApprovalCoordinator.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, +diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts +index 8f72c0cb7f6390450dc747d9d5b6a75829ad9919..fc05fb716c0894a6492c7ede415df6199bd356ac 100644 +--- a/packages/opencode/test/config/config.test.ts ++++ b/packages/opencode/test/config/config.test.ts +@@ -371,6 +371,22 @@ it.instance("updates config and preserves empty shell sentinel", () => + }), + ) + ++it.instance("serializes concurrent updates to the same config file", () => ++ Effect.gen(function* () { ++ const test = yield* TestInstance ++ const config = yield* Config.Service ++ yield* Effect.all( ++ [config.update({ model: "test/model" }), config.update({ username: "test-user" })], ++ { concurrency: "unbounded" }, ++ ) ++ ++ expect(yield* FSUtil.use.readJson(path.join(test.directory, "config.json"))).toMatchObject({ ++ model: "test/model", ++ username: "test-user", ++ }) ++ }), ++) ++ + it.effect("updates global config and omits empty shell key in json", () => + withGlobalConfig({ config: { shell: "bash" } }, ({ dir }) => + Effect.gen(function* () { +diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts +index 2f2ce98efb341ed27ca140ace2c8cac9992c9120..f79f20f9fc1df5bbcc6e734ddfa5a7f32829b101 100644 +--- a/packages/opencode/test/permission/next.test.ts ++++ b/packages/opencode/test/permission/next.test.ts +@@ -1,4 +1,5 @@ + import { PermissionV1 } from "@opencode-ai/core/v1/permission" ++import { SessionV1 } from "@opencode-ai/core/v1/session" + import { test, expect } from "bun:test" + import os from "os" + import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +@@ -68,12 +69,24 @@ const reply = (input: Parameters[0]) => + return yield* permission.reply(input) + }) + ++const conditionalReply = (input: Parameters[0]) => ++ Effect.gen(function* () { ++ const permission = yield* Permission.Service ++ return yield* permission.conditionalReply(input) ++ }) ++ + const list = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }) + ++const hostedList = () => ++ Effect.gen(function* () { ++ const permission = yield* Permission.Service ++ return yield* permission.hostedList() ++ }) ++ + // fromConfig tests + + test("fromConfig - string value becomes wildcard rule", () => { +@@ -741,6 +754,266 @@ it.instance( + { git: true }, + ) + ++it.instance( ++ "conditional reply - concurrent callers settle exactly once", ++ () => ++ Effect.gen(function* () { ++ const requestID = PermissionV1.ID.make("per_conditional_concurrent") ++ const sessionID = SessionID.make("session_conditional_concurrent") ++ const asked = yield* ask({ ++ id: requestID, ++ sessionID, ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ ++ yield* waitForPending(1) ++ const [{ sessionIncarnation, requestIncarnation }] = yield* hostedList() ++ let matched = 0 ++ const settle = conditionalReply({ ++ requestID, ++ sessionID, ++ sessionIncarnation, ++ requestIncarnation, ++ reply: "once", ++ matches: () => { ++ matched += 1 ++ return true ++ }, ++ }).pipe(Effect.exit) ++ const [left, right] = yield* Effect.all([settle, settle], { concurrency: "unbounded" }) ++ ++ expect([left, right].filter((result) => Exit.isSuccess(result) && result.value.status === "applied")).toHaveLength(1) ++ expect([left, right].filter((result) => Exit.isSuccess(result) && result.value.status === "mismatch")).toHaveLength(1) ++ expect(matched).toBe(1) ++ yield* Fiber.join(asked) ++ expect(yield* list()).toHaveLength(0) ++ }), ++ { git: true }, ++) ++ ++it.instance( ++ "ask - duplicate live request ID cannot replace its incarnation", ++ () => ++ Effect.gen(function* () { ++ const requestID = PermissionV1.ID.make("per_duplicate_live") ++ const first = yield* ask({ ++ id: requestID, ++ sessionID: SessionID.make("session_duplicate_first"), ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ yield* waitForPending(1) ++ const [before] = yield* hostedList() ++ ++ const duplicate = yield* ask({ ++ id: requestID, ++ sessionID: SessionID.make("session_duplicate_second"), ++ permission: "edit", ++ patterns: ["file.ts"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.exit) ++ expect(Exit.isFailure(duplicate)).toBe(true) ++ const [after] = yield* hostedList() ++ expect(after.request.sessionID).toBe(before.request.sessionID) ++ expect(after.requestIncarnation).toBe(before.requestIncarnation) ++ ++ yield* conditionalReply({ ++ requestID, ++ sessionID: before.request.sessionID, ++ sessionIncarnation: before.sessionIncarnation, ++ requestIncarnation: before.requestIncarnation, ++ reply: "once", ++ matches: () => true, ++ }) ++ yield* Fiber.join(first) ++ }), ++ { git: true }, ++) ++ ++it.instance( ++ "conditional reply - reject is scoped to one request", ++ () => ++ Effect.gen(function* () { ++ const sessionID = SessionID.make("session_conditional_scoped") ++ const firstID = PermissionV1.ID.make("per_conditional_first") ++ const secondID = PermissionV1.ID.make("per_conditional_second") ++ const first = yield* ask({ ++ id: firstID, ++ sessionID, ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ const second = yield* ask({ ++ id: secondID, ++ sessionID, ++ permission: "edit", ++ patterns: ["file.ts"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ ++ yield* waitForPending(2) ++ const incarnations = new Map((yield* hostedList()).map((item) => [item.request.id, item])) ++ yield* conditionalReply({ ++ requestID: firstID, ++ sessionID, ++ sessionIncarnation: incarnations.get(firstID)!.sessionIncarnation, ++ requestIncarnation: incarnations.get(firstID)!.requestIncarnation, ++ reply: "reject", ++ matches: () => true, ++ }) ++ ++ const firstExit = yield* Fiber.await(first) ++ expect(Exit.isFailure(firstExit)).toBe(true) ++ expect((yield* list()).map((request) => request.id)).toEqual([secondID]) ++ yield* conditionalReply({ ++ requestID: secondID, ++ sessionID, ++ sessionIncarnation: incarnations.get(secondID)!.sessionIncarnation, ++ requestIncarnation: incarnations.get(secondID)!.requestIncarnation, ++ reply: "once", ++ matches: () => true, ++ }) ++ yield* Fiber.join(second) ++ }), ++ { git: true }, ++) ++ ++it.instance( ++ "conditional reply - mismatched incarnation performs no effect", ++ () => ++ Effect.gen(function* () { ++ const requestID = PermissionV1.ID.make("per_conditional_mismatch") ++ const sessionID = SessionID.make("session_conditional_mismatch") ++ const asked = yield* ask({ ++ id: requestID, ++ sessionID, ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ ++ yield* waitForPending(1) ++ const [{ sessionIncarnation, requestIncarnation }] = yield* hostedList() ++ const mismatch = yield* conditionalReply({ ++ requestID, ++ sessionID, ++ sessionIncarnation, ++ requestIncarnation, ++ reply: "once", ++ matches: () => false, ++ }) ++ expect(mismatch.status).toBe("mismatch") ++ expect((yield* list()).map((request) => request.id)).toEqual([requestID]) ++ ++ yield* conditionalReply({ ++ requestID, ++ sessionID, ++ sessionIncarnation, ++ requestIncarnation, ++ reply: "once", ++ matches: () => true, ++ }) ++ yield* Fiber.join(asked) ++ }), ++ { git: true }, ++) ++ ++it.instance( ++ "conditional reply - a still-live request is stale after session delete and recreate", ++ () => ++ Effect.gen(function* () { ++ const staleRequestID = PermissionV1.ID.make("per_session_incarnation_stale") ++ const currentRequestID = PermissionV1.ID.make("per_session_incarnation_current") ++ const sessionID = SessionID.make("ses_incarnation_reuse") ++ const staleAsk = yield* ask({ ++ id: staleRequestID, ++ sessionID, ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ yield* waitForPending(1) ++ const [stale] = yield* hostedList() ++ ++ const events = yield* EventV2Bridge.Service ++ const info = { ++ id: sessionID, ++ slug: "session-incarnation-reuse", ++ projectID: "project-incarnation-reuse", ++ directory: "/tmp/session-incarnation-reuse", ++ cost: 0, ++ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, ++ title: "session incarnation reuse", ++ version: "test", ++ time: { created: Date.now(), updated: Date.now() }, ++ } ++ yield* events.publish( ++ SessionV1.Event.Deleted, ++ { sessionID, info } as typeof SessionV1.Event.Deleted.data.Type, ++ ) ++ yield* events.publish( ++ SessionV1.Event.Created, ++ { sessionID, info } as typeof SessionV1.Event.Created.data.Type, ++ ) ++ ++ const currentAsk = yield* ask({ ++ id: currentRequestID, ++ sessionID, ++ permission: "bash", ++ patterns: ["pwd"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }).pipe(Effect.forkScoped) ++ yield* waitForPending(2) ++ const pending = new Map((yield* hostedList()).map((item) => [item.request.id, item])) ++ const current = pending.get(currentRequestID)! ++ expect(current.sessionIncarnation).not.toBe(stale.sessionIncarnation) ++ ++ const result = yield* conditionalReply({ ++ requestID: staleRequestID, ++ sessionID, ++ sessionIncarnation: stale.sessionIncarnation, ++ requestIncarnation: stale.requestIncarnation, ++ reply: "once", ++ matches: () => true, ++ }) ++ expect(result.status).toBe("mismatch") ++ expect((yield* list()).map((request) => request.id)).toEqual([staleRequestID, currentRequestID]) ++ ++ yield* conditionalReply({ ++ requestID: currentRequestID, ++ sessionID, ++ sessionIncarnation: current.sessionIncarnation, ++ requestIncarnation: current.requestIncarnation, ++ reply: "once", ++ matches: () => true, ++ }) ++ yield* Fiber.join(currentAsk) ++ yield* reply({ requestID: staleRequestID, reply: "once" }) ++ yield* Fiber.join(staleAsk) ++ }), ++ { git: true }, ++) ++ + it.instance( + "reply - reject with message throws CorrectedError", + () => +diff --git a/packages/opencode/test/server/httpapi-control-plane.test.ts b/packages/opencode/test/server/httpapi-control-plane.test.ts +index b837bf7556daf7a8fe436535d7605816fa8d2618..ca978671457a05a5b22cfa2025887240924f4be3 100644 +--- a/packages/opencode/test/server/httpapi-control-plane.test.ts ++++ b/packages/opencode/test/server/httpapi-control-plane.test.ts +@@ -17,6 +17,7 @@ import { globalHandlers } from "../../src/server/routes/instance/httpapi/handler + import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" + import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error" + import { testEffect } from "../lib/effect" ++import { HostedApprovalCoordinator } from "../../src/hosted-approval/coordinator" + + const input = MoveSession.Input.make({ + sessionID: SessionV2.ID.make("ses_move"), +@@ -28,6 +29,7 @@ const called = Ref.makeUnsafe(undefined) + const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), ++ Layer.provide(HostedApprovalCoordinator.layer), + Layer.provide([authorizationLayer, schemaErrorLayer]), + // Raw HttpApi routes expose an opaque handler context at the request boundary. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion +diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts +index bcbe7aecbba4dca653d83c81658d3d08ff08f846..5651f16b50c72f3dd557e112751a258bdd1d1f30 100644 +--- a/packages/opencode/test/server/httpapi-global.test.ts ++++ b/packages/opencode/test/server/httpapi-global.test.ts +@@ -16,10 +16,12 @@ import { globalHandlers } from "../../src/server/routes/instance/httpapi/handler + import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" + import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error" + import { testEffect } from "../lib/effect" ++import { HostedApprovalCoordinator } from "../../src/hosted-approval/coordinator" + + const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), ++ Layer.provide(HostedApprovalCoordinator.layer), + Layer.provide([authorizationLayer, schemaErrorLayer]), + // Raw HttpApi routes expose an opaque handler context at the request boundary. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion +diff --git a/packages/opencode/docs/hosted-approval-v2.md b/packages/opencode/docs/hosted-approval-v2.md +new file mode 100644 +index 0000000000000000000000000000000000000000..8b798a4c23ef97e1ffd474674b6095166ae9453e +--- /dev/null ++++ b/packages/opencode/docs/hosted-approval-v2.md +@@ -0,0 +1,36 @@ ++# Hosted approval v2 ++ ++OpenCode exposes hosted approval only when standard server Basic authentication ++is configured. The capability, observe, and conditional reply endpoints use the ++same Authorization and workspace routing middleware as `/permission`. Without a ++server password all three return 404; bad credentials return 401. ++ ++The protocol identifier is `agent-teams-hosted-approval-v2`. One process-wide ++coordinator owns a random `runtime_instance_<32 lowercase hex>` and a random ++`config_generation_<32 lowercase hex>`. Each session observed by the permission ++service also owns a random `session_incarnation_<32 lowercase hex>` that changes ++after deletion/recreation and across process restarts. The generation changes after successful ++config writes and after an ordinary `always` reply changes approval policy. ++Credentials are outside this generation and must be bound separately by clients. ++ ++Pending requests are observed per session at ++`GET /experimental/agent-teams/hosted-approval/session/:sessionID/permissions`. ++Each insertion receives a random `request_incarnation_<32 lowercase hex>` and is ++bound to its session incarnation. ++Responses are all-or-nothing, limited to 256 requests and 1 MiB JSON. ++ ++Conditional replies use ++`POST /experimental/agent-teams/hosted-approval/session/:sessionID/permission/:requestID/reply`. ++Bodies are limited to 16 KiB, fatal UTF-8 decoded, checked for duplicate JSON ++keys, and exact-schema decoded. Runtime/config mismatches return 412 with no ++effect. Missing, replayed, wrong-session, stale-session-incarnation, ++wrong-request-incarnation, or digest mismatches ++return 409 with no effect. Successful replies settle exactly one request; reject ++never cascades and `always` is not supported. ++ ++Permission digests are lowercase SHA-256 over UTF-8 canonical JSON. Object keys ++are recursively sorted by JavaScript UTF-16 order, arrays retain order, and ++JSON primitives use `JSON.stringify`. Golden vectors: ++ ++- `{"requestID":"permission_1","sessionID":"session_1","tool":"bash"}` -> `bf6bdf3651bc31505a7430699c5b3e55b6c51c787cd1ab27cc2017736fa679b2` ++- `{"always":[],"id":"per_1","metadata":{"cwd":"/tmp","risk":1},"patterns":["ls","pwd"],"permission":"bash","sessionID":"ses_1"}` -> `04c8063915eb1c84563b998516a670e59885c0fc453318e7ca8545a94b8accca` +diff --git a/packages/opencode/src/hosted-approval/coordinator.ts b/packages/opencode/src/hosted-approval/coordinator.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..bc3fbbb4f24cccb90247376e182736f57f202d63 +--- /dev/null ++++ b/packages/opencode/src/hosted-approval/coordinator.ts +@@ -0,0 +1,63 @@ ++import { LayerNode } from "@opencode-ai/core/effect/layer-node" ++import { Context, Effect, Layer, Semaphore } from "effect" ++import { randomUUID } from "node:crypto" ++ ++export interface Snapshot { ++ readonly runtimeInstanceId: string ++ readonly configGeneration: string ++} ++ ++export interface Interface { ++ readonly snapshot: () => Snapshot ++ readonly withConfigMutation: ( ++ effect: Effect.Effect, ++ changed?: (result: A) => boolean, ++ ) => Effect.Effect ++ readonly withConditionalReply: (effect: Effect.Effect) => Effect.Effect ++} ++ ++export class Service extends Context.Service()("@opencode/HostedApprovalCoordinator") {} ++ ++function token(prefix: string) { ++ return `${prefix}_${randomUUID().replaceAll("-", "")}` ++} ++ ++export function makeTestLayer() { ++ return Layer.succeed( ++ Service, ++ (() => { ++ const mutex = Semaphore.makeUnsafe(1) ++ const runtimeInstanceId = token("runtime_instance") ++ let configGeneration = token("config_generation") ++ const service: Interface = { ++ snapshot: () => ({ runtimeInstanceId, configGeneration }), ++ withConfigMutation: (effect, changed = () => true) => ++ Effect.uninterruptibleMask((restore) => ++ Effect.gen(function* () { ++ // Config I/O remains interruptible and does not hold the process ++ // coordinator. Only the generation's linearization point is shared. ++ const result = yield* restore(effect) ++ if (changed(result)) { ++ yield* mutex.withPermit( ++ Effect.sync(() => { ++ configGeneration = token("config_generation") ++ }), ++ ) ++ } ++ return result ++ }), ++ ), ++ withConditionalReply: (effect) => mutex.withPermit(effect), ++ } ++ return Service.of(service) ++ })(), ++ ) ++} ++ ++// One process authority value. Reusing this Layer in independently assembled ++// route/service graphs cannot mint a second identity or mutex. ++export const layer = makeTestLayer() ++ ++export const node = LayerNode.make({ service: Service, layer, deps: [] }) ++ ++export const HostedApprovalCoordinator = { Service, layer, makeTestLayer, node } +diff --git a/packages/opencode/src/hosted-approval/protocol.ts b/packages/opencode/src/hosted-approval/protocol.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..b37e769f07e1f6fbe2ffa14f7b6e562e115be395 +--- /dev/null ++++ b/packages/opencode/src/hosted-approval/protocol.ts +@@ -0,0 +1,26 @@ ++import { createHash } from "node:crypto" ++ ++export const protocol = "agent-teams-hosted-approval-v2" as const ++ ++export function canonicalJson(value: unknown): string { ++ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") { ++ const encoded = JSON.stringify(value) ++ if (encoded === undefined) throw new TypeError("Unsupported hosted approval canonical value") ++ return encoded ++ } ++ if (Array.isArray(value)) return `[${Array.from(value, canonicalJson).join(",")}]` ++ if (!value || typeof value !== "object") throw new TypeError("Unsupported hosted approval canonical value") ++ const record = value as Record ++ return `{${Object.keys(record) ++ .sort() ++ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) ++ .join(",")}}` ++} ++ ++export function digest(value: unknown): string { ++ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex") ++} ++ ++export function rawPermission(value: T): T { ++ return JSON.parse(JSON.stringify(value)) as T ++} +diff --git a/packages/opencode/test/hosted-approval/coordinator.test.ts b/packages/opencode/test/hosted-approval/coordinator.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..da099bdb34ca2b13345a369a89f57dd8bb34cc51 +--- /dev/null ++++ b/packages/opencode/test/hosted-approval/coordinator.test.ts +@@ -0,0 +1,130 @@ ++import { expect, test } from "bun:test" ++import { Deferred, Effect, Fiber } from "effect" ++import { HostedApprovalCoordinator } from "../../src/hosted-approval/coordinator" ++ ++test("coordinator uses process-unique tokens and rotates generation after mutation", async () => { ++ const result = await Effect.gen(function* () { ++ const coordinator = yield* HostedApprovalCoordinator.Service ++ const before = coordinator.snapshot() ++ let visibleDuringMutation = "" ++ yield* coordinator.withConfigMutation( ++ Effect.sync(() => { ++ visibleDuringMutation = coordinator.snapshot().configGeneration ++ }), ++ ) ++ return { before, after: coordinator.snapshot(), visibleDuringMutation } ++ }).pipe(Effect.provide(HostedApprovalCoordinator.layer), Effect.runPromise) ++ ++ expect(result.before.runtimeInstanceId).toMatch(/^runtime_instance_[0-9a-f]{32}$/) ++ expect(result.before.configGeneration).toMatch(/^config_generation_[0-9a-f]{32}$/) ++ expect(result.after.runtimeInstanceId).toBe(result.before.runtimeInstanceId) ++ expect(result.after.configGeneration).not.toBe(result.before.configGeneration) ++ expect(result.visibleDuringMutation).toBe(result.before.configGeneration) ++}) ++ ++test("separate coordinator boots never reuse runtime or config incarnation", async () => { ++ const read = Effect.gen(function* () { ++ return (yield* HostedApprovalCoordinator.Service).snapshot() ++ }) ++ const [first, second] = await Promise.all([ ++ Effect.runPromise(read.pipe(Effect.provide(HostedApprovalCoordinator.makeTestLayer()))), ++ Effect.runPromise(read.pipe(Effect.provide(HostedApprovalCoordinator.makeTestLayer()))), ++ ]) ++ expect(first.runtimeInstanceId).not.toBe(second.runtimeInstanceId) ++ expect(first.configGeneration).not.toBe(second.configGeneration) ++}) ++ ++test("all providers of the production layer share one process authority", async () => { ++ const snapshots = await Effect.all( ++ [ ++ Effect.gen(function* () { ++ return (yield* HostedApprovalCoordinator.Service).snapshot() ++ }).pipe(Effect.provide(HostedApprovalCoordinator.layer)), ++ Effect.gen(function* () { ++ return (yield* HostedApprovalCoordinator.Service).snapshot() ++ }).pipe(Effect.provide(HostedApprovalCoordinator.layer)), ++ ], ++ { concurrency: "unbounded" }, ++ ).pipe(Effect.runPromise) ++ expect(snapshots[0]).toEqual(snapshots[1]) ++}) ++ ++test("cancellation cannot split a committed mutation from generation rotation", async () => { ++ const result = await Effect.gen(function* () { ++ const coordinator = yield* HostedApprovalCoordinator.Service ++ const locked = yield* Deferred.make() ++ const unlock = yield* Deferred.make() ++ const committed = yield* Deferred.make() ++ const before = coordinator.snapshot().configGeneration ++ const lock = yield* coordinator.withConditionalReply( ++ Effect.gen(function* () { ++ yield* Deferred.succeed(locked, undefined) ++ yield* Deferred.await(unlock) ++ }), ++ ).pipe(Effect.forkScoped) ++ yield* Deferred.await(locked) ++ const fiber = yield* coordinator.withConfigMutation( ++ Effect.gen(function* () { ++ yield* Deferred.succeed(committed, undefined) ++ return true ++ }), ++ ).pipe(Effect.forkScoped) ++ yield* Deferred.await(committed) ++ yield* Fiber.interrupt(fiber).pipe(Effect.forkScoped) ++ yield* Deferred.succeed(unlock, undefined) ++ yield* Fiber.await(lock) ++ yield* Fiber.await(fiber) ++ return { before, after: coordinator.snapshot().configGeneration } ++ }).pipe(Effect.scoped, Effect.provide(HostedApprovalCoordinator.makeTestLayer()), Effect.runPromise) ++ expect(result.after).not.toBe(result.before) ++}) ++ ++test("config work does not hold the process coordinator", async () => { ++ const result = await Effect.gen(function* () { ++ const coordinator = yield* HostedApprovalCoordinator.Service ++ const started = yield* Deferred.make() ++ const release = yield* Deferred.make() ++ const before = coordinator.snapshot().configGeneration ++ const mutation = yield* coordinator.withConfigMutation( ++ Effect.gen(function* () { ++ yield* Deferred.succeed(started, undefined) ++ yield* Deferred.await(release) ++ }), ++ ).pipe(Effect.forkScoped) ++ yield* Deferred.await(started) ++ const during = yield* coordinator.withConditionalReply( ++ Effect.sync(() => coordinator.snapshot().configGeneration), ++ ).pipe(Effect.timeout("250 millis")) ++ yield* Deferred.succeed(release, undefined) ++ yield* Fiber.join(mutation) ++ return { before, during, after: coordinator.snapshot().configGeneration } ++ }).pipe(Effect.scoped, Effect.provide(HostedApprovalCoordinator.makeTestLayer()), Effect.runPromise) ++ expect(result.during).toBe(result.before) ++ expect(result.after).not.toBe(result.before) ++}) ++ ++test("conditional operations remain single-writer atomic", async () => { ++ const order = await Effect.gen(function* () { ++ const coordinator = yield* HostedApprovalCoordinator.Service ++ const entered = yield* Deferred.make() ++ const release = yield* Deferred.make() ++ const values: string[] = [] ++ const first = yield* coordinator.withConditionalReply( ++ Effect.gen(function* () { ++ values.push("first-enter") ++ yield* Deferred.succeed(entered, undefined) ++ yield* Deferred.await(release) ++ values.push("first-exit") ++ }), ++ ).pipe(Effect.forkScoped) ++ yield* Deferred.await(entered) ++ const second = yield* coordinator.withConditionalReply( ++ Effect.sync(() => values.push("second")), ++ ).pipe(Effect.forkScoped) ++ yield* Deferred.succeed(release, undefined) ++ yield* Fiber.join(first) ++ yield* Fiber.join(second) ++ return values ++ }).pipe(Effect.scoped, Effect.provide(HostedApprovalCoordinator.makeTestLayer()), Effect.runPromise) ++ expect(order).toEqual(["first-enter", "first-exit", "second"]) ++}) +diff --git a/packages/opencode/test/hosted-approval/protocol.test.ts b/packages/opencode/test/hosted-approval/protocol.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..66f91bf650a5d10bbb338c84982cefdd7a44c5e3 +--- /dev/null ++++ b/packages/opencode/test/hosted-approval/protocol.test.ts +@@ -0,0 +1,61 @@ ++import { describe, expect, test } from "bun:test" ++import { canonicalJson, digest, rawPermission } from "../../src/hosted-approval/protocol" ++import { readHostedReplyBody } from "../../src/server/routes/instance/httpapi/handlers/permission" ++import { Effect, Stream } from "effect" ++ ++describe("hosted approval protocol", () => { ++ test("canonical digest matches the frozen golden vector", () => { ++ expect(digest({ requestID: "permission_1", sessionID: "session_1", tool: "bash" })).toBe( ++ "bf6bdf3651bc31505a7430699c5b3e55b6c51c787cd1ab27cc2017736fa679b2", ++ ) ++ }) ++ ++ test("canonical digest matches the full permission golden vector", () => { ++ expect(digest({ ++ always: [], ++ id: "per_1", ++ metadata: { cwd: "/tmp", risk: 1 }, ++ patterns: ["ls", "pwd"], ++ permission: "bash", ++ sessionID: "ses_1", ++ })).toBe("04c8063915eb1c84563b998516a670e59885c0fc453318e7ca8545a94b8accca") ++ }) ++ ++ test("sorts object keys recursively while preserving arrays", () => { ++ expect(canonicalJson({ z: [{ b: 2, a: 1 }], a: true })).toBe('{"a":true,"z":[{"a":1,"b":2}]}') ++ }) ++ ++ test("digests the JSON wire projection without undefined fields", () => { ++ const internal = { id: "permission_1", tool: undefined, metadata: { value: 1, absent: undefined } } ++ const wire = rawPermission(internal) as unknown ++ expect(wire).toEqual({ id: "permission_1", metadata: { value: 1 } }) ++ expect(() => digest(wire)).not.toThrow() ++ }) ++ ++ test("rejects unsupported canonical values", () => { ++ expect(() => canonicalJson({ value: BigInt(1) })).toThrow("Unsupported hosted approval canonical value") ++ }) ++ ++ test("rejects undefined at every nesting position", () => { ++ const sparse: unknown[] = [] ++ sparse.length = 1 ++ expect(() => canonicalJson(undefined)).toThrow("Unsupported hosted approval canonical value") ++ expect(() => canonicalJson([undefined])).toThrow("Unsupported hosted approval canonical value") ++ expect(() => canonicalJson(sparse)).toThrow("Unsupported hosted approval canonical value") ++ expect(() => canonicalJson({ nested: { value: undefined } })).toThrow( ++ "Unsupported hosted approval canonical value", ++ ) ++ }) ++}) ++ ++test("bounded request reader stops before consuming chunks after the limit", async () => { ++ let consumed = 0 ++ const stream = Stream.fromIterable([ ++ new Uint8Array(10), ++ new Uint8Array(7), ++ new Uint8Array(1), ++ ]).pipe(Stream.tap(() => Effect.sync(() => { consumed += 1 }))) ++ const exit = await Effect.runPromiseExit(readHostedReplyBody(stream, 16)) ++ expect(exit._tag).toBe("Failure") ++ expect(consumed).toBe(2) ++}) +diff --git a/packages/opencode/test/server/httpapi-hosted-approval.test.ts b/packages/opencode/test/server/httpapi-hosted-approval.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..6634507c14f9238f9ef0bee4e6d51fd7e1e2c385 +--- /dev/null ++++ b/packages/opencode/test/server/httpapi-hosted-approval.test.ts +@@ -0,0 +1,240 @@ ++import { afterEach, describe, expect, test } from "bun:test" ++import { ConfigProvider, Effect, Exit, Fiber, Layer } from "effect" ++import { HttpRouter } from "effect/unstable/http" ++import { PermissionV1 } from "@opencode-ai/core/v1/permission" ++import { AppRuntime } from "../../src/effect/app-runtime" ++import { memoMap } from "@opencode-ai/core/effect/memo-map" ++import { Permission } from "../../src/permission" ++import { InstanceStore } from "../../src/project/instance-store" ++import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" ++import { ServerAuth } from "../../src/server/auth" ++import { SessionID } from "../../src/session/schema" ++import { disposeAllInstances, tmpdir } from "../fixture/fixture" ++import { resetDatabase } from "../fixture/db" ++ ++const apps = new Set<{ dispose: () => Promise }>() ++ ++function app(password?: string) { ++ const web = HttpRouter.toWebHandler( ++ HttpApiApp.routes.pipe( ++ Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password }))), ++ ), ++ { disableLogger: true, memoMap }, ++ ) ++ apps.add(web) ++ return (path: string, init?: RequestInit) => ++ web.handler(new Request(new URL(path, "http://localhost"), init), HttpApiApp.context) ++} ++ ++function auth(password = "secret") { ++ return ServerAuth.header({ username: "opencode", password }) ?? "" ++} ++ ++afterEach(async () => { ++ await Promise.all(Array.from(apps, (web) => web.dispose())) ++ apps.clear() ++ await disposeAllInstances() ++ await resetDatabase() ++}) ++ ++describe("hosted approval v2 HttpApi", () => { ++ test("maps unavailable capability and observe errors through their declared response contract", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app() ++ const headers = { "x-opencode-directory": dir.path } ++ const capability = await request("/experimental/agent-teams/hosted-approval-capability", { headers }) ++ const observe = await request( ++ "/experimental/agent-teams/hosted-approval/session/ses_missing/permissions", ++ { headers }, ++ ) ++ const observeBody = await observe.text() ++ expect(capability.status).toBe(404) ++ expect(await capability.json()).toEqual({ _tag: "HostedApprovalUnavailable" }) ++ expect({ status: observe.status, body: observeBody }).toEqual({ ++ status: 404, ++ body: JSON.stringify({ _tag: "HostedApprovalUnavailable" }), ++ }) ++ }) ++ ++ test("is unavailable before reading malformed bodies when server auth is absent", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app() ++ const response = await request( ++ "/experimental/agent-teams/hosted-approval/session/ses_missing/permission/per_missing/reply", ++ { ++ method: "POST", ++ headers: { "content-type": "application/json", "x-opencode-directory": dir.path }, ++ body: '{"duplicate":1,"duplicate":2}', ++ }, ++ ) ++ expect(response.status).toBe(404) ++ }) ++ ++ test("requires standard Basic auth for capability", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app("secret") ++ const path = "/experimental/agent-teams/hosted-approval-capability" ++ const headers = { "x-opencode-directory": dir.path } ++ expect((await request(path, { headers })).status).toBe(401) ++ expect((await request(path, { headers: { ...headers, authorization: auth("wrong") } })).status).toBe(401) ++ const accepted = await request(path, { headers: { ...headers, authorization: auth() } }) ++ expect(accepted.status).toBe(200) ++ expect(await accepted.json()).toMatchObject({ ++ schemaVersion: 2, ++ protocol: "agent-teams-hosted-approval-v2", ++ authentication: "opencode-basic", ++ runtimeInstanceId: expect.stringMatching(/^runtime_instance_[0-9a-f]{32}$/), ++ configGeneration: expect.stringMatching(/^config_generation_[0-9a-f]{32}$/), ++ }) ++ }) ++ ++ test("rejects extra reply keys before any permission effect", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app("secret") ++ const headers = { ++ "content-type": "application/json", ++ "x-opencode-directory": dir.path, ++ authorization: auth(), ++ } ++ const capability = await (await request("/experimental/agent-teams/hosted-approval-capability", { headers })).json() ++ const body = { ++ schemaVersion: 2, ++ protocol: "agent-teams-hosted-approval-v2", ++ runtimeInstanceId: capability.runtimeInstanceId, ++ expectedConfigGeneration: capability.configGeneration, ++ requestId: "per_missing", ++ sessionId: "ses_missing", ++ sessionIncarnation: `session_incarnation_${"3".repeat(32)}`, ++ requestIncarnation: `request_incarnation_${"1".repeat(32)}`, ++ expectedPermissionDigest: "2".repeat(64), ++ decision: "allow_once", ++ extra: true, ++ } ++ const response = await request( ++ "/experimental/agent-teams/hosted-approval/session/ses_missing/permission/per_missing/reply", ++ { method: "POST", headers, body: JSON.stringify(body) }, ++ ) ++ expect(response.status).toBe(400) ++ const observe = await request( ++ "/experimental/agent-teams/hosted-approval/session/ses_missing/permissions", ++ { headers }, ++ ) ++ expect((await observe.json()).permissions).toEqual([]) ++ }) ++ ++ test("returns explicit no-effect statuses for malformed, duplicate, oversize, conflict, and precondition", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app("secret") ++ const headers = { ++ "content-type": "application/json", ++ "x-opencode-directory": dir.path, ++ authorization: auth(), ++ } ++ const capability = await (await request("/experimental/agent-teams/hosted-approval-capability", { headers })).json() ++ const path = "/experimental/agent-teams/hosted-approval/session/ses_missing/permission/per_missing/reply" ++ const valid = { ++ schemaVersion: 2, ++ protocol: "agent-teams-hosted-approval-v2", ++ runtimeInstanceId: capability.runtimeInstanceId, ++ expectedConfigGeneration: capability.configGeneration, ++ requestId: "per_missing", ++ sessionId: "ses_missing", ++ sessionIncarnation: `session_incarnation_${"3".repeat(32)}`, ++ requestIncarnation: `request_incarnation_${"1".repeat(32)}`, ++ expectedPermissionDigest: "2".repeat(64), ++ decision: "allow_once", ++ } ++ const post = (body: BodyInit) => request(path, { method: "POST", headers, body }) ++ expect((await post("{" )).status).toBe(400) ++ expect((await post('{"schemaVersion":2,"schemaVersion":2}')).status).toBe(400) ++ expect((await post("x".repeat(16 * 1024 + 1))).status).toBe(400) ++ const legacy = Object.fromEntries(Object.entries(valid).filter(([key]) => key !== "sessionIncarnation")) ++ expect((await post(JSON.stringify(legacy))).status).toBe(400) ++ expect((await post(JSON.stringify(valid))).status).toBe(409) ++ expect((await post(JSON.stringify({ ...valid, runtimeInstanceId: `runtime_instance_${"f".repeat(32)}` }))).status).toBe(412) ++ }) ++ ++ test("authenticates and applies one real pending request exactly once", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app("secret") ++ const headers = { ++ "content-type": "application/json", ++ "x-opencode-directory": dir.path, ++ authorization: auth(), ++ } ++ const capability = await (await request("/experimental/agent-teams/hosted-approval-capability", { headers })).json() ++ const requestID = PermissionV1.ID.make("per_hosted_http_once") ++ const sessionID = SessionID.make("ses_hosted_http_once") ++ const asked = AppRuntime.runFork( ++ InstanceStore.Service.use((store) => ++ store.provide( ++ { directory: dir.path }, ++ Permission.Service.use((permission) => ++ permission.ask({ ++ id: requestID, ++ sessionID, ++ permission: "bash", ++ patterns: ["ls"], ++ metadata: {}, ++ always: [], ++ ruleset: [], ++ }), ++ ), ++ ), ++ ), ++ ) ++ const observePath = `/experimental/agent-teams/hosted-approval/session/${sessionID}/permissions` ++ const observed = await Effect.runPromise( ++ Effect.gen(function* () { ++ while (true) { ++ const response = yield* Effect.promise(() => request(observePath, { headers })) ++ const body = yield* Effect.promise(() => response.json()) ++ if (body.permissions.length === 1) return body ++ yield* Effect.sleep("10 millis") ++ } ++ }).pipe(Effect.timeout("2 seconds")), ++ ) ++ const pending = observed.permissions[0] ++ const body = { ++ schemaVersion: 2, ++ protocol: "agent-teams-hosted-approval-v2", ++ runtimeInstanceId: capability.runtimeInstanceId, ++ expectedConfigGeneration: capability.configGeneration, ++ requestId: pending.requestId, ++ sessionId: pending.sessionId, ++ sessionIncarnation: pending.sessionIncarnation, ++ requestIncarnation: pending.requestIncarnation, ++ expectedPermissionDigest: pending.permissionDigest, ++ decision: "allow_once", ++ } ++ const path = `/experimental/agent-teams/hosted-approval/session/${sessionID}/permission/${requestID}/reply` ++ const applied = await request(path, { method: "POST", headers, body: JSON.stringify(body) }) ++ expect(applied.status).toBe(200) ++ expect(await applied.json()).toMatchObject({ ++ status: "applied", ++ requestId: requestID, ++ sessionId: sessionID, ++ permissionDigest: pending.permissionDigest, ++ decision: "allow_once", ++ }) ++ expect((await request(path, { method: "POST", headers, body: JSON.stringify(body) })).status).toBe(409) ++ expect(Exit.isSuccess(await Effect.runPromise(Fiber.await(asked)))).toBe(true) ++ expect((await (await request(observePath, { headers })).json()).permissions).toEqual([]) ++ }) ++ ++ test("config mutation rotates the same authority observed by hosted routes", async () => { ++ await using dir = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) ++ const request = app("secret") ++ const headers = { ++ "content-type": "application/json", ++ "x-opencode-directory": dir.path, ++ authorization: auth(), ++ } ++ const before = await (await request("/experimental/agent-teams/hosted-approval-capability", { headers })).json() ++ const updated = await request("/config", { method: "PATCH", headers, body: JSON.stringify({ formatter: false }) }) ++ expect(updated.status).toBe(200) ++ const after = await (await request("/experimental/agent-teams/hosted-approval-capability", { headers })).json() ++ expect(after.runtimeInstanceId).toBe(before.runtimeInstanceId) ++ expect(after.configGeneration).not.toBe(before.configGeneration) ++ }) ++}) diff --git a/.github/hardened/release-manifest.schema.json b/.github/hardened/release-manifest.schema.json new file mode 100644 index 000000000000..a26650f4e114 --- /dev/null +++ b/.github/hardened/release-manifest.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/777genius/opencode-anomaly/blob/dev/.github/hardened/release-manifest.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "release", "workflow", "assets"], + "properties": { + "schemaVersion": { "const": 1 }, + "release": { + "type": "object", + "required": [ + "sourceCommit", + "sourceTree", + "baseCommit", + "patchSha256", + "patchSize", + "version", + "tag", + "bunVersion", + "productionEligible" + ], + "properties": { + "sourceCommit": { "const": "94540b2cbd116bb5bcd6c9ac8f4734f3df637a2b" }, + "sourceTree": { "const": "762a2f4322b572639b12f2ec30a126de72138d18" }, + "baseCommit": { "const": "826d9ad46a22bef0294998e08daa3c4904fea28f" }, + "patchSha256": { "const": "c8a1389e6e296e25d5cb7f9da563d09c5d87560ea2ca1a0aab0d6dc1c9a11968" }, + "patchSize": { "const": 85072 }, + "version": { "const": "1.18.21-agentteams.1" }, + "tag": { "const": "v1.18.21-agentteams.1" }, + "bunVersion": { "const": "1.3.14" }, + "productionEligible": { "const": false } + }, + "additionalProperties": false + }, + "workflow": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "workflow", "runId", "runAttempt", "actor", "ref", "sha"], + "properties": { + "repository": { "type": "string" }, + "workflow": { "type": "string" }, + "runId": { "type": "string" }, + "runAttempt": { "type": "string" }, + "actor": { "type": "string" }, + "ref": { "type": "string" }, + "sha": { "type": "string" } + } + }, + "assets": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "archive": { "type": "string" }, + "archiveSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "archiveSize": { "type": "integer", "exclusiveMinimum": 0 }, + "binaryPath": { "enum": ["opencode", "opencode.exe"] }, + "binarySha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "binarySize": { "type": "integer", "exclusiveMinimum": 0 }, + "platform": { "type": "string" }, + "os": { "enum": ["linux", "darwin", "windows"] }, + "arch": { "enum": ["x64", "arm64"] }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": ["binaryStatus", "reason", "provenanceAction", "provenanceStatus"], + "properties": { + "binaryStatus": { "const": "unsigned" }, + "reason": { "const": "non-production fork prerelease" }, + "provenanceAction": { + "const": "actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8" + }, + "provenanceStatus": { "const": "required-after-manifest" } + } + } + }, + "required": [ + "archive", + "archiveSha256", + "archiveSize", + "binaryPath", + "binarySha256", + "binarySize", + "platform", + "os", + "arch", + "signing" + ] + } + } + } +} diff --git a/.github/scripts/hardened-artifacts.test.ts b/.github/scripts/hardened-artifacts.test.ts new file mode 100644 index 000000000000..2fa3ef0b975d --- /dev/null +++ b/.github/scripts/hardened-artifacts.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + archive, + PLATFORMS, + RELEASE, + validateArchiveEntry, + validateConstants, + validateReleaseManifest, +} from "./hardened-artifacts" + +describe("hardened release contract", () => { + test("freezes identity and non-production eligibility", () => { + expect(validateConstants()).toBeUndefined() + expect(RELEASE).toEqual({ + sourceCommit: "94540b2cbd116bb5bcd6c9ac8f4734f3df637a2b", + sourceTree: "762a2f4322b572639b12f2ec30a126de72138d18", + baseCommit: "826d9ad46a22bef0294998e08daa3c4904fea28f", + patchSha256: "c8a1389e6e296e25d5cb7f9da563d09c5d87560ea2ca1a0aab0d6dc1c9a11968", + version: "1.18.21-agentteams.1", + tag: "v1.18.21-agentteams.1", + bunVersion: "1.3.14", + productionEligible: false, + }) + }) + + test("keeps schema identity constants synchronized with RELEASE", async () => { + const schema = await Bun.file(new URL("../hardened/release-manifest.schema.json", import.meta.url)).json() + for (const [key, value] of Object.entries(RELEASE)) { + expect(schema.properties.release.properties[key].const).toBe(value) + } + expect(schema.properties.release.properties.patchSize.const).toBe(85072) + }) + + test("enforces the manifest schema and rejects malformed assets", async () => { + const asset = { + archive: "opencode-linux-x64.tar.gz", + archiveSha256: "a".repeat(64), + archiveSize: 1, + binaryPath: "opencode", + binarySha256: "b".repeat(64), + binarySize: 1, + platform: "opencode-linux-x64", + os: "linux", + arch: "x64", + signing: { + binaryStatus: "unsigned", + reason: "non-production fork prerelease", + provenanceAction: "actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8", + provenanceStatus: "required-after-manifest", + }, + } + const value = { + schemaVersion: 1, + release: { ...RELEASE, patchSize: 85072 }, + workflow: { + repository: "local", + workflow: "local", + runId: "local", + runAttempt: "local", + actor: "local", + ref: "local", + sha: "local", + }, + assets: Array.from({ length: 5 }, () => ({ ...asset })), + } + await expect(validateReleaseManifest(value)).resolves.toBeUndefined() + await expect( + validateReleaseManifest({ ...value, assets: [{ ...asset, archiveSize: "1" }, ...value.assets.slice(1)] }), + ).rejects.toThrow("$.assets[0].archiveSize must be integer") + await expect( + validateReleaseManifest({ ...value, assets: [{ ...asset, unexpected: true }, ...value.assets.slice(1)] }), + ).rejects.toThrow("$.assets[0].unexpected is not allowed") + }) + + test("publishes only the explicit native verification matrix", () => { + expect(PLATFORMS.map((item) => `${item.os}-${item.arch}`)).toEqual([ + "linux-x64", + "linux-arm64", + "darwin-x64", + "darwin-arm64", + "windows-x64", + ]) + }) + + test("replaces Info-ZIP archives on rerun instead of retaining stale entries", async () => { + const temporary = await mkdtemp(path.join(tmpdir(), "hardened-archive-test-")) + const dist = path.join(temporary, "dist") + const output = path.join(temporary, "output") + const originalPath = process.env.PATH + try { + const commands = path.join(temporary, "commands") + await mkdir(commands) + await writeFile(path.join(commands, "zip"), '#!/bin/sh\nif [ -e "$3" ]; then exit 42; fi\nprintf fresh >"$3"\n', { + mode: 0o755, + }) + process.env.PATH = `${commands}:${originalPath}` + for (const platform of PLATFORMS) { + const binaryDirectory = path.join(dist, platform.name, "bin") + await mkdir(binaryDirectory, { recursive: true }) + await writeFile( + path.join(binaryDirectory, platform.os === "windows" ? "opencode.exe" : "opencode"), + platform.name, + ) + } + await archive(["--dist", dist, "--output", output]) + const target = path.join(output, "opencode-darwin-x64.zip") + await writeFile(target, "stale") + await archive(["--dist", dist, "--output", output]) + expect(await Bun.file(target).text()).toBe("fresh") + } finally { + process.env.PATH = originalPath + await rm(temporary, { recursive: true, force: true }) + } + }) + + test("rejects absolute paths and traversal before extraction", () => { + expect(() => validateArchiveEntry("../opencode")).toThrow("unsafe archive entry") + expect(() => validateArchiveEntry("bin/../../opencode")).toThrow("unsafe archive entry") + expect(() => validateArchiveEntry("/usr/bin/opencode")).toThrow("unsafe archive entry") + expect(() => validateArchiveEntry("opencode")).not.toThrow() + }) + + test("keeps mutations in the protected dispatch-only final job", async () => { + const workflow = await Bun.file(new URL("../workflows/hardened-cli-release.yml", import.meta.url)).text() + const parsed = Bun.YAML.parse(workflow) as any + expect(parsed.jobs["hardened-release"].if).toBe( + "github.event_name == 'workflow_dispatch' && github.repository == '777genius/opencode-anomaly'", + ) + expect(parsed.jobs["hardened-release"].environment).toBe("hardened-release") + expect(parsed.jobs["hardened-release"].permissions).toEqual({ + contents: "write", + "id-token": "write", + attestations: "write", + }) + expect(workflow.match(/persist-credentials: false/g)).toHaveLength(9) + expect(workflow).not.toContain("persist-credentials: true") + expect(workflow).toContain('require_absent "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG"') + expect(workflow).toContain("validate-manifest --manifest release/release-manifest.json") + for (const line of workflow.split("\n").filter((line) => line.trim().startsWith("uses:"))) { + expect(line).toMatch(/@[0-9a-f]{40}(?:\s+#.*)?$/) + } + }) +}) diff --git a/.github/scripts/hardened-artifacts.ts b/.github/scripts/hardened-artifacts.ts new file mode 100644 index 000000000000..cb242b8dd5c8 --- /dev/null +++ b/.github/scripts/hardened-artifacts.ts @@ -0,0 +1,346 @@ +#!/usr/bin/env bun + +import { createHash } from "node:crypto" +import { lstat, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import path from "node:path" + +export const RELEASE = { + sourceCommit: "94540b2cbd116bb5bcd6c9ac8f4734f3df637a2b", + sourceTree: "762a2f4322b572639b12f2ec30a126de72138d18", + baseCommit: "826d9ad46a22bef0294998e08daa3c4904fea28f", + patchSha256: "c8a1389e6e296e25d5cb7f9da563d09c5d87560ea2ca1a0aab0d6dc1c9a11968", + version: "1.18.21-agentteams.1", + tag: "v1.18.21-agentteams.1", + bunVersion: "1.3.14", + productionEligible: false as const, +} as const + +export const PLATFORMS = [ + { name: "opencode-linux-x64", os: "linux", arch: "x64", archive: "tar.gz" }, + { name: "opencode-linux-arm64", os: "linux", arch: "arm64", archive: "tar.gz" }, + { name: "opencode-darwin-x64", os: "darwin", arch: "x64", archive: "zip" }, + { name: "opencode-darwin-arm64", os: "darwin", arch: "arm64", archive: "zip" }, + { name: "opencode-windows-x64", os: "windows", arch: "x64", archive: "zip" }, +] as const + +type Asset = { + archive: string + archiveSha256: string + archiveSize: number + binaryPath: string + binarySha256: string + binarySize: number + platform: string + os: string + arch: string + signing: { + binaryStatus: "unsigned" + reason: string + provenanceAction: string + provenanceStatus: "required-after-manifest" + } +} + +const fail = (message: string): never => { + throw new Error(message) +} + +const sha256 = async (file: string) => + createHash("sha256") + .update(await readFile(file)) + .digest("hex") + +const run = (command: string[], cwd = process.cwd()) => { + const result = Bun.spawnSync(command, { cwd, stdout: "pipe", stderr: "pipe", env: process.env }) + if (result.exitCode !== 0) { + fail(`${command.join(" ")} failed: ${result.stderr.toString().trim()}`) + } + return result.stdout.toString().trim() +} + +const requireValue = (args: string[], flag: string) => { + const index = args.indexOf(flag) + return index === -1 || !args[index + 1] ? fail(`missing ${flag}`) : args[index + 1] +} + +export const validateConstants = () => { + if (RELEASE.productionEligible !== false) fail("productionEligible must remain hardcoded false") + if (RELEASE.tag !== `v${RELEASE.version}`) fail("tag/version mismatch") + for (const key of ["sourceCommit", "sourceTree", "baseCommit"] as const) { + if (!/^[0-9a-f]{40}$/.test(RELEASE[key])) fail(`invalid ${key}`) + } + if (!/^[0-9a-f]{64}$/.test(RELEASE.patchSha256)) fail("invalid patchSha256") + if (new Set(PLATFORMS.map((item) => item.name)).size !== PLATFORMS.length) fail("duplicate platform") +} + +const identity = async (args: string[]) => { + validateConstants() + const patch = path.resolve(requireValue(args, "--patch")) + const repository = path.resolve(requireValue(args, "--repo")) + if ((await sha256(patch)) !== RELEASE.patchSha256) fail("immutable patch SHA-256 mismatch") + if (run(["git", "rev-parse", "HEAD"], repository) !== RELEASE.sourceCommit) + fail("checkout is not exact source commit") + if (run(["git", "merge-base", RELEASE.baseCommit, RELEASE.sourceCommit], repository) !== RELEASE.baseCommit) + fail("source does not descend from exact base commit") + + const temporary = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + try { + run(["git", "worktree", "add", "--detach", temporary, RELEASE.baseCommit], repository) + run(["git", "apply", "--check", patch], temporary) + run(["git", "apply", "--index", patch], temporary) + const patchedTree = run(["git", "write-tree"], temporary) + const sourceTree = run(["git", "rev-parse", `${RELEASE.sourceCommit}^{tree}`], repository) + if (sourceTree !== RELEASE.sourceTree) + fail(`source tree ${sourceTree} differs from frozen tree ${RELEASE.sourceTree}`) + if (patchedTree !== sourceTree) fail(`patch tree ${patchedTree} differs from source tree ${sourceTree}`) + } finally { + Bun.spawnSync(["git", "worktree", "remove", "--force", temporary], { + cwd: repository, + stdout: "ignore", + stderr: "ignore", + }) + } +} + +export const archive = async (args: string[]) => { + validateConstants() + const dist = path.resolve(requireValue(args, "--dist")) + const output = path.resolve(requireValue(args, "--output")) + await mkdir(output, { recursive: true }) + for (const platform of PLATFORMS) { + const binary = path.join(dist, platform.name, "bin", platform.os === "windows" ? "opencode.exe" : "opencode") + if (!(await Bun.file(binary).exists())) fail(`missing binary ${binary}`) + const target = path.join(output, `${platform.name}.${platform.archive}`) + if (platform.archive === "tar.gz") { + run([ + "tar", + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "-czf", + target, + "-C", + path.dirname(binary), + path.basename(binary), + ]) + continue + } + // Info-ZIP omits extra metadata with -X and emits stable timestamps when the input mtime is fixed. + await rm(target, { force: true }) + run(["touch", "-t", "198001010000", binary]) + run(["zip", "-X", "-q", target, path.basename(binary)], path.dirname(binary)) + } +} + +const listFiles = async (directory: string) => + (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort() + +const compare = async (args: string[]) => { + const left = path.resolve(requireValue(args, "--left")) + const right = path.resolve(requireValue(args, "--right")) + const leftFiles = await listFiles(left) + const rightFiles = await listFiles(right) + if (JSON.stringify(leftFiles) !== JSON.stringify(rightFiles)) fail("clean builds produced different asset sets") + for (const file of leftFiles) { + if ((await sha256(path.join(left, file))) !== (await sha256(path.join(right, file)))) { + fail(`non-reproducible archive: ${file}`) + } + } +} + +export const validateArchiveEntry = (entry: string) => { + const normalized = entry.replaceAll("\\", "/") + if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) { + fail(`unsafe archive entry: ${entry}`) + } +} + +const archiveEntries = (archivePath: string) => { + if (archivePath.endsWith(".zip") && process.platform !== "win32") { + return run(["unzip", "-Z1", archivePath]).split("\n") + } + return run(["tar", "-tf", archivePath]).split("\n") +} + +const extractArchive = (archivePath: string, destination: string) => { + if (archivePath.endsWith(".zip") && process.platform !== "win32") { + run(["unzip", "-q", archivePath, "-d", destination]) + return + } + run(["tar", "-xf", archivePath, "-C", destination]) +} + +const extract = async (archivePath: string, destination: string) => { + const entries = archiveEntries(archivePath) + entries.forEach(validateArchiveEntry) + if (entries.length !== 1 || !["opencode", "opencode.exe"].includes(entries[0])) { + fail(`archive must contain exactly one root binary: ${archivePath}`) + } + await mkdir(destination, { recursive: true }) + extractArchive(archivePath, destination) + const binary = path.join(destination, entries[0]) + if (!(await lstat(binary)).isFile()) fail(`archive payload is not a regular file: ${archivePath}`) + return binary +} + +const verify = async (args: string[]) => { + const archivePath = path.resolve(requireValue(args, "--archive")) + const platform = PLATFORMS.find((item) => path.basename(archivePath) === `${item.name}.${item.archive}`) + if (!platform) fail(`archive is not in the published platform allowlist: ${archivePath}`) + const expectedOs = process.platform === "win32" ? "windows" : process.platform + if (platform.os !== expectedOs || platform.arch !== process.arch) { + fail(`native verification mismatch: ${platform.name} on ${expectedOs}-${process.arch}`) + } + const destination = path.join(process.env.RUNNER_TEMP ?? "/tmp", `hardened-verify-${crypto.randomUUID()}`) + try { + const binary = await extract(archivePath, destination) + if (platform.os !== "windows") run(["chmod", "755", binary]) + const output = run([binary, "--version"]) + if (!output.includes(RELEASE.version)) fail(`binary version mismatch: ${output}`) + } finally { + await rm(destination, { recursive: true, force: true }) + } +} + +const manifest = async (args: string[]) => { + validateConstants() + const directory = path.resolve(requireValue(args, "--assets")) + const output = path.resolve(requireValue(args, "--output")) + const patch = path.resolve(requireValue(args, "--patch")) + if ((await sha256(patch)) !== RELEASE.patchSha256) fail("immutable patch SHA-256 mismatch") + const assets: Asset[] = [] + for (const platform of PLATFORMS) { + const archiveName = `${platform.name}.${platform.archive}` + const archivePath = path.join(directory, archiveName) + const extracted = path.join(process.env.RUNNER_TEMP ?? "/tmp", `manifest-${crypto.randomUUID()}`) + try { + const binary = await extract(archivePath, extracted) + assets.push({ + archive: archiveName, + archiveSha256: await sha256(archivePath), + archiveSize: (await stat(archivePath)).size, + binaryPath: path.basename(binary), + binarySha256: await sha256(binary), + binarySize: (await stat(binary)).size, + platform: platform.name, + os: platform.os, + arch: platform.arch, + signing: { + binaryStatus: "unsigned", + reason: "non-production fork prerelease", + provenanceAction: "actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8", + provenanceStatus: "required-after-manifest", + }, + }) + } finally { + await rm(extracted, { recursive: true, force: true }) + } + } + const result = { + schemaVersion: 1, + release: { ...RELEASE, patchSize: (await stat(patch)).size }, + workflow: { + repository: process.env.GITHUB_REPOSITORY ?? "local", + workflow: process.env.GITHUB_WORKFLOW ?? "local", + runId: process.env.GITHUB_RUN_ID ?? "local", + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? "local", + actor: process.env.GITHUB_ACTOR ?? "local", + ref: process.env.GITHUB_REF ?? "local", + sha: process.env.GITHUB_SHA ?? "local", + }, + assets, + } + await validateReleaseManifest( + result, + path.resolve(args.includes("--schema") ? requireValue(args, "--schema") : schemaFile), + ) + await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { flag: "wx" }) +} + +type JsonSchema = { + type?: string + const?: unknown + enum?: unknown[] + required?: string[] + properties?: Record + additionalProperties?: boolean + minItems?: number + maxItems?: number + items?: JsonSchema + pattern?: string + exclusiveMinimum?: number +} + +const schemaFile = new URL("../hardened/release-manifest.schema.json", import.meta.url).pathname + +const valueType = (value: unknown) => { + if (Array.isArray(value)) return "array" + if (value === null) return "null" + if (Number.isInteger(value)) return "integer" + return typeof value +} + +const validateSchemaNode = (schema: JsonSchema, value: unknown, location = "$"): void => { + if (schema.const !== undefined && !Object.is(value, schema.const)) fail(`${location} must equal its schema constant`) + if (schema.enum && !schema.enum.some((item) => Object.is(item, value))) fail(`${location} is not in its schema enum`) + if (schema.type) { + const actual = valueType(value) + if (actual !== schema.type && !(schema.type === "number" && actual === "integer")) { + fail(`${location} must be ${schema.type}, got ${actual}`) + } + } + if (typeof value === "string" && schema.pattern && !new RegExp(schema.pattern).test(value)) { + fail(`${location} does not match ${schema.pattern}`) + } + if (typeof value === "number" && schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) { + fail(`${location} must be greater than ${schema.exclusiveMinimum}`) + } + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) fail(`${location} has too few items`) + if (schema.maxItems !== undefined && value.length > schema.maxItems) fail(`${location} has too many items`) + if (schema.items) value.forEach((item, index) => validateSchemaNode(schema.items!, item, `${location}[${index}]`)) + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record + for (const key of schema.required ?? []) { + if (!Object.hasOwn(record, key)) fail(`${location}.${key} is required`) + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(record)) { + if (!Object.hasOwn(schema.properties ?? {}, key)) fail(`${location}.${key} is not allowed`) + } + } + for (const [key, child] of Object.entries(schema.properties ?? {})) { + if (Object.hasOwn(record, key)) validateSchemaNode(child, record[key], `${location}.${key}`) + } + } +} + +export const validateReleaseManifest = async (value: unknown, schemaPath = schemaFile) => { + const schema = JSON.parse(await readFile(schemaPath, "utf8")) as JsonSchema + validateSchemaNode(schema, value) +} + +const validateManifest = async (args: string[]) => { + const manifestPath = path.resolve(requireValue(args, "--manifest")) + const schemaPath = path.resolve(requireValue(args, "--schema")) + await validateReleaseManifest(JSON.parse(await readFile(manifestPath, "utf8")), schemaPath) +} + +const main = async () => { + const [command, ...args] = process.argv.slice(2) + if (command === "identity") return identity(args) + if (command === "archive") return archive(args) + if (command === "compare") return compare(args) + if (command === "verify") return verify(args) + if (command === "manifest") return manifest(args) + if (command === "validate-manifest") return validateManifest(args) + fail(`unknown command: ${command ?? ""}`) +} + +if (import.meta.main) await main() diff --git a/.github/workflows/hardened-cli-release.yml b/.github/workflows/hardened-cli-release.yml new file mode 100644 index 000000000000..9358638b49cb --- /dev/null +++ b/.github/workflows/hardened-cli-release.yml @@ -0,0 +1,216 @@ +name: hardened CLI prerelease + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/hardened-cli-release.yml + - .github/scripts/hardened-artifacts.ts + - .github/scripts/hardened-artifacts.test.ts + - .github/hardened/** + +concurrency: + group: hardened-cli-v1.18.21-agentteams.1 + cancel-in-progress: false + +permissions: + contents: read + +env: + SOURCE_COMMIT: 94540b2cbd116bb5bcd6c9ac8f4734f3df637a2b + BASE_COMMIT: 826d9ad46a22bef0294998e08daa3c4904fea28f + VERSION: 1.18.21-agentteams.1 + TAG: v1.18.21-agentteams.1 + BUN_VERSION: 1.3.14 + PATCH: .github/hardened/opencode-hosted-approval-v2-r3.patch + +jobs: + identity: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + path: infra + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.SOURCE_COMMIT }} + path: source + fetch-depth: 3 + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - name: Verify immutable source, parent, patch, and resulting tree + run: bun infra/.github/scripts/hardened-artifacts.ts identity --repo source --patch "infra/$PATCH" + - name: Run hardened release contract tests + run: bun test ./.github/scripts/hardened-artifacts.test.ts + working-directory: infra + + build: + needs: identity + strategy: + fail-fast: false + matrix: + pass: [one, two] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + path: infra + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.SOURCE_COMMIT }} + path: source + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: test "$(bun --version)" = "$BUN_VERSION" + - run: bun install --frozen-lockfile + working-directory: source + - name: Clean build + working-directory: source/packages/opencode + env: + OPENCODE_VERSION: ${{ env.VERSION }} + OPENCODE_RELEASE: "" + run: bun script/build.ts + - name: Create deterministic archives + run: bun infra/.github/scripts/hardened-artifacts.ts archive --dist source/packages/opencode/dist --output hardened-assets + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: hardened-build-${{ matrix.pass }} + path: hardened-assets + if-no-files-found: error + retention-days: 1 + + reproducible: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: hardened-build-one + path: build-one + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: hardened-build-two + path: build-two + - run: bun .github/scripts/hardened-artifacts.ts compare --left build-one --right build-two + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: reproducible-assets + path: build-one + if-no-files-found: error + retention-days: 1 + + native-verify: + needs: reproducible + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + asset: opencode-linux-x64.tar.gz + - runner: ubuntu-24.04-arm + asset: opencode-linux-arm64.tar.gz + - runner: macos-15-intel + asset: opencode-darwin-x64.zip + - runner: macos-14 + asset: opencode-darwin-arm64.zip + - runner: windows-2025 + asset: opencode-windows-x64.zip + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-assets + path: assets + - run: bun .github/scripts/hardened-artifacts.ts verify --archive "assets/${{ matrix.asset }}" + + manifest: + needs: native-verify + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-assets + path: release + - run: bun .github/scripts/hardened-artifacts.ts manifest --assets release --patch "$PATCH" --schema .github/hardened/release-manifest.schema.json --output release/release-manifest.json + - run: bun .github/scripts/hardened-artifacts.ts validate-manifest --manifest release/release-manifest.json --schema .github/hardened/release-manifest.schema.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: hardened-release-candidate + path: release + if-no-files-found: error + retention-days: 7 + + hardened-release: + needs: manifest + if: github.event_name == 'workflow_dispatch' && github.repository == '777genius/opencode-anomaly' + runs-on: ubuntu-24.04 + environment: hardened-release + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.SOURCE_COMMIT }} + path: source + fetch-depth: 2 + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: hardened-release-candidate + path: release + - name: Refuse every existing tag, release, or asset collision + env: + GH_TOKEN: ${{ github.token }} + run: | + test "$(git -C source rev-parse HEAD)" = "$SOURCE_COMMIT" + require_absent() { + response="$(mktemp)" + if gh api --include "$1" >"$response" 2>&1; then + cat "$response" + return 1 + fi + if ! grep -Eq '^HTTP/[0-9.]+ 404' "$response"; then + cat "$response" + return 1 + fi + } + require_absent "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" + require_absent "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" + test "$(find release -maxdepth 1 -type f | wc -l | tr -d ' ')" = 6 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: release/* + - name: Create exact-source draft prerelease without clobbering + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$TAG" release/* --draft --prerelease --target "$SOURCE_COMMIT" --title "$TAG" --notes "Hardened non-production prerelease. productionEligible=false."