Skip to content

Commit 9713c92

Browse files
fix(workspace): run a link's list, owner lookup and bind as one credential
A digest check before the bind left a window where the request still ran as whatever account was configured. The CLI and TUI link flows now capture the credential once and pass it to the list, `whoami`, bind and rebind, and pin the record and seed to it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_0172qrhMa5TQgETASi5hxMqD
1 parent cb26ed1 commit 9713c92

5 files changed

Lines changed: 126 additions & 50 deletions

File tree

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export class WorkspaceApiError extends Error {
149149
}
150150
}
151151

152+
/** A credential captured once and passed to every request of one flow, so the flow cannot
153+
* drift onto another account part-way: workspace and user ids are per tenant. */
154+
export interface ActAs {
155+
url: string
156+
instance: string
157+
apiKey: string
158+
}
159+
152160
async function creds(): Promise<{ url: string; instance: string; apiKey: string }> {
153161
if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError()
154162
const c = await AltimateApi.getCredentials()
@@ -511,23 +519,31 @@ export namespace WorkspaceApi {
511519
return { id, name: input.name }
512520
}
513521

522+
/** The ambient credential as an `ActAs`, or null when none is configured. */
523+
export async function captureCredentials(): Promise<ActAs | null> {
524+
return creds().catch(() => null)
525+
}
526+
514527
export async function bindExisting(
515528
datamateId: number,
516529
identifier: ProjectIdentifier,
530+
actAs?: ActAs,
517531
): Promise<BindingResponse> {
518532
return req<BindingResponse>("POST", "/bind", {
519533
body: {
520534
datamate_id: datamateId,
521535
repo_remote: identifier.repoRemote ?? null,
522536
project_path: identifier.projectPath ?? null,
523537
},
538+
...(actAs ? { actAs } : {}),
524539
})
525540
}
526541

527542
export async function rebindByRemote(input: {
528543
remote: string
529544
targetDatamateId: number
530545
expectedCurrentDatamateId?: number
546+
actAs?: ActAs
531547
}): Promise<BindingResponse> {
532548
return req<BindingResponse>("PUT", "/by-remote", {
533549
body: {
@@ -537,6 +553,7 @@ export namespace WorkspaceApi {
537553
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
538554
: {}),
539555
},
556+
...(input.actAs ? { actAs: input.actAs } : {}),
540557
})
541558
}
542559

@@ -546,6 +563,7 @@ export namespace WorkspaceApi {
546563
projectPath: string
547564
targetDatamateId: number
548565
expectedCurrentDatamateId?: number
566+
actAs?: ActAs
549567
}): Promise<BindingResponse> {
550568
return req<BindingResponse>("PUT", "/by-path", {
551569
body: {
@@ -555,6 +573,7 @@ export namespace WorkspaceApi {
555573
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
556574
: {}),
557575
},
576+
...(input.actAs ? { actAs: input.actAs } : {}),
558577
})
559578
}
560579

@@ -617,8 +636,8 @@ export namespace WorkspaceApi {
617636
/** The caller's own user id, from ``GET /users/me``. Needed wherever the
618637
* client must compare ownership — skill attachment requires the caller to
619638
* OWN the workspace, and the credentials carry no user id of their own. */
620-
export async function whoami(): Promise<number> {
621-
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users" })
639+
export async function whoami(actAs?: ActAs): Promise<number> {
640+
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users", ...(actAs ? { actAs } : {}) })
622641
const id = Number(me?.id)
623642
if (!Number.isInteger(id) || id <= 0) throw new WorkspaceApiError("The server did not say who this account is.")
624643
return id

‎packages/opencode/src/altimate/workspace/state.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,13 @@ export async function currentScope(): Promise<{ tenant: string; apiUrl: string }
10951095
export async function accountDigest(): Promise<string | null> {
10961096
const c = await AltimateApi.getCredentials().catch(() => null)
10971097
if (!c?.altimateApiKey || !c.altimateInstanceName || !c.altimateUrl) return null
1098-
return createHash("sha256").update(`${c.altimateUrl}|${c.altimateInstanceName}|${c.altimateApiKey}`).digest("hex")
1098+
return digestOf({ url: c.altimateUrl, instance: c.altimateInstanceName, apiKey: c.altimateApiKey })
1099+
}
1100+
1101+
/** `accountDigest` for a credential already captured, so a flow can pin its record and seed
1102+
* to the account its requests ran as. */
1103+
export function digestOf(c: { url: string; instance: string; apiKey: string }): string {
1104+
return createHash("sha256").update(`${c.url}|${c.instance}|${c.apiKey}`).digest("hex")
10991105
}
11001106

11011107
export async function recordApprovedBinding(

‎packages/opencode/src/cli/cmd/link.ts‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
NotConfiguredError,
2525
NotFoundError,
2626
PreconditionFailedError,
27+
type ActAs,
2728
type Binding,
2829
type DatamateRef,
2930
type MatchedIdentifier,
@@ -41,7 +42,7 @@ import {
4142
resolveWorkspaceWebUrl,
4243
type HandoffResult,
4344
} from "@/altimate/workspace/browser-handoff"
44-
import { accountDigest, recordApprovedBinding } from "@/altimate/workspace/state"
45+
import { accountDigest, digestOf, recordApprovedBinding } from "@/altimate/workspace/state"
4546
import type { SeedOutcome } from "@/altimate/workspace/memory-backfill"
4647
import {
4748
confirmsNamesake,
@@ -243,11 +244,19 @@ export const LinkCommand = cmd({
243244
)
244245
}
245246

247+
// One credential for the list, the owner lookup and the bind: workspace and user ids are
248+
// per tenant, so a switch while the picker is open must not mix two accounts.
249+
const actAs = await WorkspaceApi.captureCredentials()
250+
if (!actAs) {
251+
prompts.log.error("Could not read your Altimate credentials. Check /connect and try again.")
252+
process.exitCode = 1
253+
return
254+
}
246255
const spin = prompts.spinner()
247256
spin.start("Loading workspaces...")
248257
let list: DatamateRef[]
249258
try {
250-
list = await WorkspaceApi.listDatamates()
259+
list = await WorkspaceApi.listDatamates(actAs)
251260
} catch (err) {
252261
spin.stop("Could not load workspaces.", 1)
253262
prompts.log.error(err instanceof Error ? err.message : String(err))
@@ -263,7 +272,7 @@ export const LinkCommand = cmd({
263272
// Listed workspaces already named what a quick create would use. The picker opens on
264273
// the caller's own (never a colleague's), and creating another namesake is confirmed.
265274
// Without a user id nothing is preselected; the confirmation still applies.
266-
const userId = await WorkspaceApi.whoami().catch(() => undefined)
275+
const userId = await WorkspaceApi.whoami(actAs).catch(() => undefined)
267276
const namesakes = findNamesakes(list, autoName, userId)
268277
// Sanitized once here so every downstream display (the picker message,
269278
// the "Kept" outro, hyperlink()'s own text) is covered — hyperlink()
@@ -381,7 +390,7 @@ export const LinkCommand = cmd({
381390
return
382391
}
383392

384-
await bindOrRebind(identifier, targetId, existing, preCheckOk, args.directory)
393+
await bindOrRebind(identifier, targetId, existing, preCheckOk, args.directory, actAs)
385394
},
386395
})
387396

@@ -712,12 +721,13 @@ async function bindOrRebind(
712721
existing: ProjectBindingLookup | null,
713722
preCheckOk: boolean,
714723
directory: string,
724+
/** The credential the picker's list was read as. The bind runs as it, and the seed refuses
725+
* (account-changed) if the configured account switches mid-way. */
726+
actAs: ActAs,
715727
): Promise<void> {
716-
// The account this bind acts as; the seed refuses (account-changed) if it switches mid-way.
717-
// Unreadable credentials cannot link anyway, and must not leave the bind unguarded.
718-
const linkAccount = await accountDigest()
719-
if (linkAccount === null) {
720-
prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.")
728+
const linkAccount = digestOf(actAs)
729+
if ((await accountDigest()) !== linkAccount) {
730+
prompts.log.error("Your Altimate account changed since the list was loaded, so nothing was linked. Re-run `altimate-code link`.")
721731
process.exitCode = 1
722732
return
723733
}
@@ -732,13 +742,14 @@ async function bindOrRebind(
732742
targetDatamateId,
733743
expectedCurrentDatamateId: existing.datamate.id,
734744
matchedBy: existing.matchedBy,
745+
actAs,
735746
})
736747
} else {
737748
// No known binding OR pre-check failed. Try bindExisting first — if the
738749
// pre-check missed a real binding, the server will 409, and we retry as
739750
// rebind when we're allowed to. (m10)
740751
try {
741-
res = await WorkspaceApi.bindExisting(targetDatamateId, identifier)
752+
res = await WorkspaceApi.bindExisting(targetDatamateId, identifier, actAs)
742753
} catch (err) {
743754
// A teammate's private workspace is not a pre-check race: rebinding it only fails
744755
// again (forbidden), and would hide the explanation the outer handler gives.
@@ -767,11 +778,13 @@ async function bindOrRebind(
767778
res = await WorkspaceApi.rebindByPath({
768779
projectPath: conflictPath,
769780
targetDatamateId,
781+
actAs,
770782
})
771783
} else if (conflictRemote) {
772784
res = await WorkspaceApi.rebindByRemote({
773785
remote: conflictRemote,
774786
targetDatamateId,
787+
actAs,
775788
})
776789
} else {
777790
// Server didn't tell us which identifier owned the conflict —
@@ -781,10 +794,12 @@ async function bindOrRebind(
781794
? await WorkspaceApi.rebindByRemote({
782795
remote: identifier.repoRemote,
783796
targetDatamateId,
797+
actAs,
784798
})
785799
: await WorkspaceApi.rebindByPath({
786800
projectPath: identifier.projectPath!,
787801
targetDatamateId,
802+
actAs,
788803
})
789804
}
790805
rebindSpin.stop(`Re-linked to "${stripControlChars(res.binding.datamate_name)}".`)
@@ -871,19 +886,22 @@ async function rebindByMatchedIdentifier(input: {
871886
targetDatamateId: number
872887
expectedCurrentDatamateId: number
873888
matchedBy: MatchedIdentifier
889+
actAs?: ActAs
874890
}) {
875891
if (input.matchedBy === "remote" && input.identifier.repoRemote) {
876892
return WorkspaceApi.rebindByRemote({
877893
remote: input.identifier.repoRemote,
878894
targetDatamateId: input.targetDatamateId,
879895
expectedCurrentDatamateId: input.expectedCurrentDatamateId,
896+
actAs: input.actAs,
880897
})
881898
}
882899
if (input.matchedBy === "path" && input.identifier.projectPath) {
883900
return WorkspaceApi.rebindByPath({
884901
projectPath: input.identifier.projectPath,
885902
targetDatamateId: input.targetDatamateId,
886903
expectedCurrentDatamateId: input.expectedCurrentDatamateId,
904+
actAs: input.actAs,
887905
})
888906
}
889907
throw new Error(

0 commit comments

Comments
 (0)