Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e74fcf4
feat(workspace): mirror a bound workspace's custom skills onto disk
sahrizvi Aug 26, 2026
618255d
fix(workspace): align skill sync with the real backend contract
sahrizvi Aug 26, 2026
4fc9724
feat(workspace): sync workspace skills on bind and at session start
sahrizvi Aug 26, 2026
121114f
perf(workspace): pull workspace skills once per process, not per message
sahrizvi Aug 26, 2026
2e3dde4
fix(workspace): correct two skill API contract errors found by E2E
sahrizvi Aug 26, 2026
0ab6b82
revert(workspace): drop the mid-session skill-registry refresh
sahrizvi Aug 26, 2026
fb2dcd4
fix(workspace): resolve a project's binding from the server, not just…
sahrizvi Aug 27, 2026
a5b8de5
feat(workspace): pick up SaaS-side skill changes in an open session
sahrizvi Aug 27, 2026
c7777a7
fix(workspace): resolve the memory mirror's binding from the server too
sahrizvi Aug 27, 2026
0419df9
fix(workspace): close eight defects an adversarial audit found in ski…
sahrizvi Aug 28, 2026
4f9b3f4
fix(workspace): atomic snapshot swap, page sanity, and bundle ceilings
sahrizvi Aug 28, 2026
8738355
fix(workspace): take skills out of service on disconnect or opt-out
sahrizvi Aug 28, 2026
fa1563a
fix(workspace): address the consensus review's blocking findings
sahrizvi Aug 28, 2026
aca855a
fix(workspace): mark adopted bindings, and state the memory trade hon…
sahrizvi Aug 28, 2026
f1f8241
fix(workspace): satisfy the marker guard, and skip the hook entirely …
sahrizvi Aug 28, 2026
73507fe
fix(workspace): address the bot review findings
sahrizvi Aug 28, 2026
2b4445c
fix(workspace): revalidate cached bindings against the server
sahrizvi Aug 28, 2026
6c343cd
fix(workspace): stop the skill hook perturbing turns for users who ne…
sahrizvi Aug 29, 2026
b088fee
fix(workspace): make the mid-session skill refresh actually fire
sahrizvi Aug 30, 2026
1147dc7
fix(workspace): close the bot review's opt-out and binding findings
sahrizvi Aug 30, 2026
017675e
test(workspace): make the warm-bind mock answer binding lookups
sahrizvi Aug 30, 2026
6a47be5
fix(skill): restore the altimate_change marker pairing
sahrizvi Aug 30, 2026
e6d5756
fix(workspace): symlink-guard the no-credentials purge
sahrizvi Aug 30, 2026
cfa54fb
fix(workspace): let a one-shot run finish the sync it started
sahrizvi Aug 30, 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