Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 26 additions & 2 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,27 @@ async function req<T>(
* exchanges and covers the request body too, so a call that uploads
* megabytes (a skill bundle) needs its own. */
timeoutMs?: number
/** Act as THIS credential rather than resolving the ambient one.
*
* `creds()` reads the credentials afresh on every call, so a caller that
* needs its request and its own bookkeeping to be about the same principal
* cannot get that by reading them itself — the request would resolve them
* again, and an account switch in between makes the two disagree. Comparing
* before and after does not close it either: A→B→A passes the comparison
* while the request was served as B. Passing the captured credential is the
* only form that cannot drift.
*
* Callers that pass this have already read the credential, so the
* `isConfigured()` gate inside `creds()` — a file-existence check on the
* same file they just read — is skipped. The one behavioural difference:
* deleting the credentials file mid-flight no longer aborts THIS request.
* It still completes as the principal it captured, and the next call fails
* at its own credential read. */
actAs?: { url: string; instance: string; apiKey: string }
} = {},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS
const { url, instance, apiKey } = await creds()
const { url, instance, apiKey } = opts.actAs ?? (await creds())
const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : ""
const basePath = opts.base ?? "/datamate-project-bindings"
const target = `${url}${basePath}${subpath}${qs}`
Expand Down Expand Up @@ -530,14 +547,21 @@ export namespace WorkspaceApi {
* gets. (M5) Filters out non-integer / non-positive ids so a corrupt row
* doesn't reach the picker as a "NaN" label that the caller then binds
* against. */
export async function listDatamates(): Promise<DatamateRef[]> {
/** `actAs` pins the request to a specific credential — see `req`'s `actAs`. Omitted, this
* resolves the ambient credential as every other call does. */
export async function listDatamates(actAs?: {
url: string
instance: string
apiKey: string
}): Promise<DatamateRef[]> {
// Accept THREE response envelopes — today's ``{datamates: [...]}``, a
// bare ``[...]``, and a generic ``{data: [...]}`` — so a backend
// contract change (or compat layer) doesn't silently empty the picker.
// (cubic-dev-ai round 3.)
type Row = { id: number | string; name: string; memory_enabled?: boolean; user_id?: number }
const body = await req<Row[] | { datamates?: Row[]; data?: Row[] }>("GET", "/", {
base: "/datamates",
...(actAs ? { actAs } : {}),
})
let rows: Row[]
if (Array.isArray(body)) {
Expand Down
138 changes: 138 additions & 0 deletions packages/opencode/src/altimate/workspace/pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// altimate_change - new file
//
// The IDE extension's workspace pin.
//
// `altimate-code serve` is launched by the VS Code / Cursor extension, which already knows which
// datamate the user picked in its panel. That selection is what should govern the session's skills
// and memory — not whatever binding this project happens to carry on the backend. The extension
// hands it over in the child's environment and this module turns it back into a `CachedBinding`
// for `state.ts`'s `resolveBindingOutcome` to return.
//
// Why the environment, and not a `serve` argument: `resolveBindingOutcome` is reached from
// per-turn prompt assembly and from every memory write, neither of which has a path back to the
// command's parsed `args`. `session-context.ts` and `serve.ts`'s `ALTIMATE_CODE_SERVE` both made
// the same call, for the same reason — it has to be readable from every module realm. A `serve`
// flag can still be added as sugar, so long as its handler writes these vars before anything else
// runs.
//
// Why NOT the existing `ALTIMATE_RESOLVED_WORKSPACE_*` namespace, which looks like the obvious
// home: `launch-resolve.ts` sets `ALTIMATE_RESOLVED_WORKSPACE_ID` **alone** for the TUI's
// `--workspace <name>` flag — no name, no root. Reusing that namespace would make every such TUI
// session look like a half-populated pin, and the fail-closed rule below would then break
// `--workspace` outright. The two mechanisms are kept apart deliberately, and `readPin` additionally
// stands down outside `serve`.
import { realpathSync } from "node:fs"
import path from "node:path"
import { Filesystem } from "@/util/filesystem"
import { Log } from "@/altimate/util/log"

const log = Log.create({ service: "workspace-pin" })

const ENV_ID = "ALTIMATE_PINNED_WORKSPACE_ID"
const ENV_NAME = "ALTIMATE_PINNED_WORKSPACE_NAME"
const ENV_ROOT = "ALTIMATE_PINNED_WORKSPACE_ROOT"

export interface ValidPin {
kind: "valid"
datamateId: number
datamateName: string
/** The directory `serve` was launched for. The pin applies to this tree and nothing else. */
root: string
}

/**
* `absent` and `invalid` are deliberately NOT the same answer.
*
* Collapsing them — the shape `getResolvedWorkspaceId` uses, where anything unparseable returns
* `null` — would make a malformed pin fall through to ordinary cache/server resolution, which can
* legitimately return a DIFFERENT workspace. Silently doing work against a workspace the user did
* not pick is the one outcome this feature must never produce, so a pin that is present but broken
* fails closed instead.
*/
export type PinState = { kind: "absent" } | { kind: "invalid"; reason: string } | ValidPin

/**
* Whether `directory` is the pinned root or lives underneath it.
*
* Delegates to `Filesystem.containsReal`, which resolves symlinks and — critically — walks up to
* the nearest existing ancestor when the path itself does not exist yet, rejecting `..` segments
* along the way. An earlier version here compared `realpathSync` output with a LEXICAL fallback
* when resolution failed, which a not-yet-created path under a symlinked ancestor defeated:
* `<root>/link/new`, with `link -> /outside`, resolved to nothing, fell back to the literal string,
* and passed the prefix test. Since the directory arrives from the caller-supplied
* `x-opencode-directory` header on an unsecured server, that was enough to attribute an outside
* project's skills and memory to the pinned workspace.
*/
export function withinRoot(directory: string, root: string): boolean {
return resolveWithinRoot(directory, root) !== null
}

/**
* `withinRoot`, but returning the CANONICAL directory it validated — or `null` when the directory
* is not contained.
*
* Exists because containment is checked once, early, and the caller then does async work
* (credentials, a network round trip) before it needs the directory again. Re-deriving it from the
* caller-supplied string at that point re-opens the window: a symlink swapped in between would be
* resolved the second time and not the first, so the path that was authorised and the path that is
* used need not be the same one. Callers keep this value and use it instead of the raw argument.
*
* The canonical form is the one `resolveProjectIdentifier` would compute — `realpath` where it
* resolves, the normalised absolute path otherwise, so a directory that does not exist yet (which
* `containsReal` accepts, having walked to its nearest existing ancestor) still yields something
* stable to carry forward.
*/
export function resolveWithinRoot(directory: string, root: string): string | null {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: resolveWithinRoot re-implements the realpath-with-lexical-fallback pattern that resolveProjectIdentifier already encapsulates (detect.ts:44-51), and the new docstring even notes the duplication: "The canonical form is the one resolveProjectIdentifier would compute". Two copies of try { realpathSync(...) } catch { path.resolve(...) } now define the canonical form of a directory, so a future change to one (e.g. narrowing the fallback) silently diverges from the other. Extract a shared helper and have both callers use it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/pin.ts, line 85:

<comment>`resolveWithinRoot` re-implements the realpath-with-lexical-fallback pattern that `resolveProjectIdentifier` already encapsulates (detect.ts:44-51), and the new docstring even notes the duplication: "The canonical form is the one `resolveProjectIdentifier` would compute". Two copies of `try { realpathSync(...) } catch { path.resolve(...) }` now define the canonical form of a directory, so a future change to one (e.g. narrowing the fallback) silently diverges from the other. Extract a shared helper and have both callers use it.</comment>

<file context>
@@ -63,7 +64,31 @@ export type PinState = { kind: "absent" } | { kind: "invalid"; reason: string }
+ * `containsReal` accepts, having walked to its nearest existing ancestor) still yields something
+ * stable to carry forward.
+ */
+export function resolveWithinRoot(directory: string, root: string): string | null {
+  if (!Filesystem.containsReal(root, directory)) return null
+  try {
</file context>

if (!Filesystem.containsReal(root, directory)) return null
try {
return realpathSync(directory)
} catch {
return path.resolve(directory)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Nonexistent paths still reopen the containment race

When directory does not exist, this returns its unresolved lexical path after containsReal validates only the nearest existing ancestor. During the credential/network awaits in resolvePinnedBinding, a caller can create one of those missing components as a symlink outside the pinned root; cachedProjectIdentifier then passes this same string to spawnSync/realpathSync, which follows the new symlink and attributes the outside project's identity to the pinned workspace. The value carried forward must not contain unresolved path components (or nonexistent directories must fail closed), otherwise this fixes the race only for directories that already existed at validation time.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

}
}

/**
* Read the pin out of the environment.
*
* Returns `absent` outside `serve`: the pin is the extension's channel, and the TUI has its own
* (`--workspace`, via `launch-resolve.ts`). Keeping them from ever being live in the same process
* is cheaper than reasoning about what should win.
*/
export function readPin(env: NodeJS.ProcessEnv = process.env): PinState {
if (env["ALTIMATE_CODE_SERVE"] !== "1") return { kind: "absent" }

const rawId = env[ENV_ID]
const name = env[ENV_NAME]
const root = env[ENV_ROOT]

// `absent` means the extension set NOTHING. Tested on key presence, not truthiness: three
// present-but-empty variables are a broken pin, not the absence of one, and collapsing them into
// `absent` let a malformed pin fall through to ordinary cache/server resolution — the exact
// fall-open this function exists to prevent.
if (rawId === undefined && name === undefined && root === undefined) {
return { kind: "absent" }
}

// Partial or empty is invalid, never "good enough". The extension sets all three or none;
// anything else means something rewrote the environment and we no longer know what was intended.
if (!rawId || !name || !root) {
return { kind: "invalid", reason: "pin is partially set or empty" }
}

const datamateId = Number(rawId)
if (!Number.isSafeInteger(datamateId) || datamateId <= 0) {
return { kind: "invalid", reason: `datamate id ${JSON.stringify(rawId)} is not a positive integer` }
}
if (!path.isAbsolute(root)) {
return { kind: "invalid", reason: "pinned root is not an absolute path" }
}

return { kind: "valid", datamateId, datamateName: name, root }
}

/** `readPin`, with the refusal logged once at the point it is taken. */
export function readPinLogged(env: NodeJS.ProcessEnv = process.env): PinState {
const pin = readPin(env)
if (pin.kind === "invalid") log.warn("ignoring workspace pin and failing closed", { reason: pin.reason })
return pin
}
Loading
Loading