Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
87e750c
feat(workspace): mirror a bound workspace's custom skills onto disk
sahrizvi Aug 26, 2026
6384fd2
fix(workspace): align skill sync with the real backend contract
sahrizvi Aug 26, 2026
723d37d
feat(workspace): sync workspace skills on bind and at session start
sahrizvi Aug 26, 2026
c44b163
perf(workspace): pull workspace skills once per process, not per message
sahrizvi Aug 26, 2026
9755fc1
fix(workspace): correct two skill API contract errors found by E2E
sahrizvi Aug 26, 2026
f5075f2
revert(workspace): drop the mid-session skill-registry refresh
sahrizvi Aug 26, 2026
501a8b8
fix(workspace): resolve a project's binding from the server, not just…
sahrizvi Aug 27, 2026
80c6851
feat(workspace): pick up SaaS-side skill changes in an open session
sahrizvi Aug 27, 2026
37ddab7
fix(workspace): resolve the memory mirror's binding from the server too
sahrizvi Aug 27, 2026
14ea2fe
fix(workspace): close eight defects an adversarial audit found in ski…
sahrizvi Aug 28, 2026
66a7012
fix(workspace): atomic snapshot swap, page sanity, and bundle ceilings
sahrizvi Aug 28, 2026
ee0f372
fix(workspace): take skills out of service on disconnect or opt-out
sahrizvi Aug 28, 2026
a036159
fix(workspace): address the consensus review's blocking findings
sahrizvi Aug 28, 2026
c1e12a9
fix(workspace): mark adopted bindings, and state the memory trade hon…
sahrizvi Aug 28, 2026
958db8c
fix(workspace): satisfy the marker guard, and skip the hook entirely …
sahrizvi Aug 28, 2026
64a9da5
fix(workspace): address the bot review findings
sahrizvi Aug 28, 2026
aac751d
fix(workspace): revalidate cached bindings against the server
sahrizvi Aug 28, 2026
7b7c3a8
fix(workspace): stop the skill hook perturbing turns for users who ne…
sahrizvi Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ async function req<T>(
opts: {
body?: unknown
query?: Record<string, string>
/** Cap the response body. Off by default because this helper is shared and
* some endpoints legitimately return large payloads (memory ``/list``
* embeds block content and is not capped server-side). Set it where the
* body size is attacker- or accident-controlled, as skill file downloads
* are. */
boundResponse?: boolean
/** Override the base path prefix. Defaults to
* ``/datamate-project-bindings`` (this module's namespace). Pass e.g.
* ``/datamates`` to hit the sibling datamates_router through the same
Expand Down Expand Up @@ -176,7 +182,21 @@ async function req<T>(
// swallows the AbortError from the timeout firing during the body read
// and turns a stalled response into a false "empty body". Rejection
// rethrows into the outer catch and is classified there. (cubic round 3.)
text = await res.text()
// Bound the body before buffering it. `res.text()` reads to completion, so
// a response far larger than advertised is an out-of-memory crash before
// any size check downstream can reject it. Content-Length is a hint, not a
// guarantee, so the stream is also cut off at the cap.
if (opts.boundResponse) {
const declared = Number(res.headers.get("content-length") ?? Number.NaN)
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES} limit`,
)
}
text = await readBounded(res, target)
} else {
text = await res.text()
}
} catch (err) {
// Distinguish "we hit our 15s abort" from "network stack failed" so the
// caller can decide differently (retry, longer timeout, offline banner).
Expand Down Expand Up @@ -246,6 +266,51 @@ async function req<T>(
* not duplicate any of it — see ./memory-api.ts, which drives
* ``/datamates/memory/*`` through this exact path. Always pass an explicit
* ``base``; the default is this module's own namespace. */
/** Ceiling on a single response body, applied ONLY where a caller opts in.
*
* Nothing upstream bounds what a workspace can hold and the body is buffered
* whole, so an oversized response is a process crash rather than a failed
* request. But this helper is shared: memory `/list` embeds block content and is
* deliberately not capped server-side, so a blanket limit would fail requests
* that work today. Skill file downloads opt in; everything else is unchanged. */
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024

/** Read a response body, refusing to buffer past the cap. */
async function readBounded(res: Response, target: string): Promise<string> {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// No stream to meter (a mocked or bodyless response): fall back to the
// unbounded read, then enforce the cap on what actually arrived so this
// branch cannot be used to bypass it.
if (!res.body) {
const whole = await res.text()
if (Buffer.byteLength(whole, "utf8") > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`,
)
}
return whole
}
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`,
)
}
chunks.push(value)
}
} finally {
reader.cancel().catch(() => {})
}
return new TextDecoder().decode(Buffer.concat(chunks))
}

export { req as altimateRequest }

export namespace WorkspaceApi {
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { Instance } from "@/project/instance"
import { Log } from "@/altimate/util/log"
import type { MemoryBlock } from "@/memory/types"
import { TRAINING_META_COMMENT } from "@/altimate/training/types"
import { readLocalBinding, type CachedBinding } from "./state"
// Aliased: `syncInternals.resolveBinding` below is an unrelated test seam.
import { resolveBinding as resolveProjectBinding, type CachedBinding } from "./state"
import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index"
import { WorkspaceApi } from "./api-client"
import {
Expand Down Expand Up @@ -133,7 +134,12 @@ async function currentBinding(directory?: string): Promise<CachedBinding | null>
directory = directory ?? currentDirectory() ?? undefined
if (!directory) return null
try {
return await readLocalBinding(directory)
// Server fallback, not just the local cache: that cache is written only by
// an explicit link, so a directory holding a repo that IS bound — a git
// worktree, a second clone, a teammate's checkout, a new machine — would
// mirror nothing at all, silently. See `resolveBinding` for why adopting a
// binding here does not also seed the workspace.
return await resolveProjectBinding(directory)
} catch (err) {
log.warn("could not resolve binding for memory mirror", { err: String(err) })
return null
Expand Down
Loading
Loading