Compare commits

...

3 Commits

Author SHA1 Message Date
James Long b50cd706ce fix(core): gate durable event persistence 2026-08-04 23:35:14 +00:00
James Murdza f0afb6750e fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James Murdza 703d09f306 fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
7 changed files with 69 additions and 18 deletions
+21 -15
View File
@@ -165,6 +165,7 @@ export const allBounded = (events: Interface, capacity: number) =>
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
readonly persistDurableEvents?: boolean
}
export const layerWith = (options?: LayerOptions) =>
@@ -180,6 +181,7 @@ export const layerWith = (options?: LayerOptions) =>
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const persistDurableEvents = options?.persistDurableEvents ?? true
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
@@ -333,19 +335,20 @@ export const layerWith = (options?: LayerOptions) =>
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
if (persistDurableEvents)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
@@ -634,5 +637,8 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
export const nodeWith = (options?: LayerOptions) =>
makeGlobalNode({ service: Service, layer: layerWith(options), deps: [Database.node] })
export const node = nodeWith()
export const sequenceOnlyNode = nodeWith({ persistDurableEvents: false })
@@ -51,6 +51,7 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilderV1 } from "./app-node-builder-v1"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -106,6 +107,7 @@ export const AppLayer = AppNodeBuilderV1.build(
ShareNext.node,
SessionShare.node,
]),
[[EventV2.node, EventV2.sequenceOnlyNode]],
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
@@ -97,6 +97,29 @@ export function http(
headers.delete("content-encoding")
headers.delete("content-length")
// An upstream 5xx from a remote workspace sandbox arrives here as an opaque
// status — its real cause (and log line) live only inside the sandbox. Buffer
// the small error body, log it locally so it shows up in the host's log, and
// forward it unchanged (preserving content-type so the client can still parse
// the structured error, e.g. its `ref`).
if (response.status >= 500) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
const contentType = response.headers["content-type"] ?? "application/json"
headers.delete("content-type")
yield* Effect.logError("workspace proxy upstream error", {
url: url.toString(),
method: request.method,
status: response.status,
body: body.slice(0, 2000),
})
return HttpServerResponse.text(body, {
status: response.status,
statusText: statusText(response),
headers,
contentType,
})
}
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
statusText: statusText(response),
@@ -270,8 +270,10 @@ const app = LayerNode.group([
export function createRoutes(
corsOptions?: CorsOptions,
persistDurableEvents = false,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
const locationServiceMapV2 = buildLocationServiceMap()
const eventNode = persistDurableEvents ? EventV2.node : EventV2.sequenceOnlyNode
return Layer.mergeAll(
rootApiRoutes,
@@ -288,7 +290,10 @@ export function createRoutes(
corsVaryFix,
fenceLayer,
cors(corsOptions),
AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
AppNodeBuilderV1.build(MoveSession.node, [
[LocationServiceMap.node, locationServiceMapV2],
[EventV2.node, eventNode],
]),
HttpServer.layerServices,
]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
@@ -299,11 +304,12 @@ export function createRoutes(
AppNodeBuilderV1.build(SessionV2.node, [
[LocationServiceMap.node, locationServiceMapV2],
[SessionExecution.node, SessionExecutionLocal.node],
[EventV2.node, eventNode],
]),
),
Layer.provide(locationServiceMapV2),
Layer.provide(AppNodeBuilderV1.build(app)),
Layer.provide(AppNodeBuilderV1.build(app, [[EventV2.node, eventNode]])),
// Must stay last: layers provided later in this pipe build beneath earlier ones,
// so Observability must come after every service graph. Otherwise eagerly forked
// fibers (e.g. the ModelsDev background refresh) capture Effect's default stdout
@@ -34,5 +34,12 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) {
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
// The `directory` param is the *host's* working directory (e.g. a Windows
// path like `F:\proj`). It is meaningless — and dangerous — on the remote:
// the sandbox would `path.resolve` it against its own cwd, producing a bogus
// path like `/home/daytona/workspace/repo/F:\proj` that does not exist and
// crashes prompt handling. Drop it so the remote falls back to its own
// project root. This mirrors ProxyUtil.headers stripping `x-opencode-directory`.
proxyURL.searchParams.delete("directory")
return proxyURL
}
@@ -48,7 +48,7 @@ const appLayer = AppNodeBuilder.build(
[[InstanceStore.bootstrapNode, noopBootstrapLayer]],
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
HttpApiApp.createRoutes(undefined, true),
{
disableListenLog: true,
disableLogger: true,
@@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => {
expect(result.searchParams.get("keep")).toBe("yes")
})
test("strips the host directory param so the remote resolves its own root", () => {
const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes")
const result = workspaceProxyURL("http://remote:8080/base", url)
expect(result.searchParams.get("directory")).toBeNull()
expect(result.searchParams.get("keep")).toBe("yes")
})
test("preserves hash from request", () => {
const url = new URL("http://localhost/page#section")
const result = workspaceProxyURL("http://remote:8080", url)