diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index abe048ca7..3e8b525c7 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -772,11 +772,22 @@ async function hasManagedSnapshot(directory: string): Promise { } } -/** Whether local state says this project has a workspace: a snapshot a sync - * once published, or a cached binding. Decides if a failed binding lookup is - * worth telling the user about. */ +/** Whether this project is known to have a workspace. Decides if a failed + * binding lookup is worth telling the user about. */ async function hasBindingEvidence(directory: string): Promise { - if (await hasManagedSnapshot(directory)) return true + // The IDE's pin is a workspace the user chose for this tree, and it is never + // written to disk — an inaccessible pinned workspace would otherwise be + // silent. A malformed pin counts as well: the extension set one, and + // resolution fails closed on it for every directory. Scoped with the same + // `resolveWithinRoot` as the pin purge in `syncSkills`, so the purge and the + // warning always agree on which folders a pin speaks for — and the warning + // still fires after that purge has removed the manifest. + const pin = readPin() + if (pin.kind === "invalid") return true + if (pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null) return true + // This client's manifest, not merely a `_workspace` folder: a tree the user + // or another tool created says nothing about a binding. + if ((await readManifest(directory).catch(() => null)) !== null) return true return (await readLocalBinding(directory).catch(() => null)) !== null } @@ -930,8 +941,9 @@ export async function syncSkills(directory: string): Promise { // workspace. With a local binding row, offline resolves to a stale "bound" // and fails later at the list; without one, the server is always asked, // so offline lands here for EVERY opted-in project, including ones never - // linked. A snapshot on disk (a server-side binding that synced before) - // or a local row is that evidence; without either, stay quiet. + // linked. An IDE pin for this tree, this client's manifest (a server-side + // binding that synced before) or a local row is that evidence — see + // `hasBindingEvidence`; without any of them, stay quiet. if (outcome.status === "unknown" && (await hasBindingEvidence(canon))) syncError = "could not confirm this project's workspace (offline, or no access to it)" return diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index 95f21c094..d0a54819a 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -388,6 +388,87 @@ describe("workspace skill sync", () => { expect(result.skipped).toEqual([]) }) + // What counts as "this project has a workspace" when the lookup fails. + const offline = () => { + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + } + const PIN_KEYS = [ + "ALTIMATE_CODE_SERVE", + "ALTIMATE_PINNED_WORKSPACE_ID", + "ALTIMATE_PINNED_WORKSPACE_NAME", + "ALTIMATE_PINNED_WORKSPACE_ROOT", + ] as const + async function withEnv(env: Partial>, fn: () => Promise) { + const saved = Object.fromEntries(PIN_KEYS.map((k) => [k, process.env[k]])) + for (const k of PIN_KEYS) delete process.env[k] + Object.assign(process.env, env) + try { + return await fn() + } finally { + for (const k of PIN_KEYS) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + } + } + + test("a _workspace folder this client did not write is not evidence of a workspace", async () => { + mkdirSync(path.join(project, MANAGED), { recursive: true }) + writeFileSync(path.join(project, MANAGED, "mine.md"), "the user's own file") + unbind() + offline() + const result = await syncSkills(project) + expect(result.error).toBeUndefined() + }) + + test("an IDE pin for this project is evidence, so an unconfirmable pinned workspace is reported", async () => { + unbind() + offline() + const result = await withEnv( + { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "4242", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + }, + () => syncSkills(project), + ) + expect(result.error).toBe("could not confirm this project's workspace (offline, or no access to it)") + }) + + test("an unhonourable pin removes the skills AND says why", async () => { + // The pin purge removes the manifest before the evidence check runs; the + // pin itself must still count, or the purge would be silent. + serve({ "pub-1": { "SKILL.md": "one" } }) + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + + unbind() + offline() + const result = await withEnv( + { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "4243", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + }, + () => syncSkills(project), + ) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + expect(result.error).toBe("could not confirm this project's workspace (offline, or no access to it)") + }) + + test("a malformed pin is evidence too: the extension set one", async () => { + unbind() + offline() + const result = await withEnv({ ALTIMATE_CODE_SERVE: "1", ALTIMATE_PINNED_WORKSPACE_ID: "4242" }, () => + syncSkills(project), + ) + expect(result.error).toBe("could not confirm this project's workspace (offline, or no access to it)") + }) + test("an unusable remote id is shown sanitised and bounded, never raw", async () => { const esc = String.fromCharCode(27) const raw = `../evil${esc}[31m${String.fromCharCode(10)}${String.fromCharCode(0x2028)}${"x".repeat(100)}`