Skip to content

Commit 7d635d7

Browse files
committed
fix(opencode): persist ineligible account state
1 parent 0327ecd commit 7d635d7

10 files changed

Lines changed: 754 additions & 108 deletions

packages/opencode/src/plugin.ts

Lines changed: 252 additions & 84 deletions
Large diffs are not rendered by default.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { mkdtemp, readFile, rm, stat } from "node:fs/promises"
2+
import { tmpdir } from "node:os"
3+
import { join } from "node:path"
4+
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
6+
7+
import { loadAccounts, saveAccountsReplace } from "./storage"
8+
9+
let configDir = ""
10+
let previousConfigDir: string | undefined
11+
12+
beforeEach(async () => {
13+
previousConfigDir = process.env.OPENCODE_CONFIG_DIR
14+
configDir = await mkdtemp(join(tmpdir(), "antigravity-ineligible-"))
15+
process.env.OPENCODE_CONFIG_DIR = configDir
16+
})
17+
18+
afterEach(async () => {
19+
if (previousConfigDir === undefined) {
20+
delete process.env.OPENCODE_CONFIG_DIR
21+
} else {
22+
process.env.OPENCODE_CONFIG_DIR = previousConfigDir
23+
}
24+
await rm(configDir, { recursive: true, force: true })
25+
})
26+
27+
describe("account ineligibility disk persistence", () => {
28+
it("round-trips the disabled state and eligibility metadata in the real account file", async () => {
29+
await saveAccountsReplace({
30+
version: 4,
31+
accounts: [
32+
{
33+
email: "blocked@example.com",
34+
refreshToken: "refresh-token",
35+
addedAt: 1,
36+
lastUsed: 2,
37+
enabled: false,
38+
accountIneligible: true,
39+
accountIneligibleAt: 100,
40+
accountIneligibleReason: "ACCOUNT_INELIGIBLE",
41+
eligibilityStateUpdatedAt: 100,
42+
},
43+
],
44+
activeIndex: 0,
45+
})
46+
47+
const storagePath = join(configDir, "antigravity-accounts.json")
48+
const raw = JSON.parse(await readFile(storagePath, "utf8")) as {
49+
accounts: Array<Record<string, unknown>>
50+
}
51+
expect(raw.accounts[0]).toMatchObject({
52+
enabled: false,
53+
accountIneligible: true,
54+
accountIneligibleAt: 100,
55+
accountIneligibleReason: "ACCOUNT_INELIGIBLE",
56+
eligibilityStateUpdatedAt: 100,
57+
})
58+
expect((await stat(storagePath)).mode & 0o777).toBe(0o600)
59+
60+
await expect(loadAccounts()).resolves.toMatchObject({
61+
accounts: [expect.objectContaining({
62+
enabled: false,
63+
accountIneligible: true,
64+
accountIneligibleReason: "ACCOUNT_INELIGIBLE",
65+
eligibilityStateUpdatedAt: 100,
66+
})],
67+
})
68+
})
69+
})
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { beforeAll, describe, expect, it, vi } from "vitest"
2+
3+
type ExtractAccessBlock = (body: string) => {
4+
validationRequired: boolean
5+
accountIneligible: boolean
6+
message?: string
7+
verifyUrl?: string
8+
}
9+
10+
let extractAccessBlock: ExtractAccessBlock | undefined
11+
let buildProbeRequest: ((projectId: string) => Record<string, unknown>) | undefined
12+
let interpretProbeResponse: ((response: Response) => Promise<{
13+
status: "ok" | "verification-required" | "ineligible" | "error"
14+
message: string
15+
}>) | undefined
16+
17+
beforeAll(async () => {
18+
vi.mock("@opencode-ai/plugin", () => ({ tool: vi.fn() }))
19+
const { __testExports } = await import("../plugin")
20+
const exports = __testExports as {
21+
buildAccountAccessProbeRequest?: (projectId: string) => Record<string, unknown>
22+
extractAccountAccessErrorDetails?: ExtractAccessBlock
23+
interpretAccountAccessProbeResponse?: (response: Response) => Promise<{
24+
status: "ok" | "verification-required" | "ineligible" | "error"
25+
message: string
26+
}>
27+
}
28+
buildProbeRequest = exports.buildAccountAccessProbeRequest
29+
extractAccessBlock = exports.extractAccountAccessErrorDetails
30+
interpretProbeResponse = exports.interpretAccountAccessProbeResponse
31+
})
32+
33+
describe("account eligibility recovery", () => {
34+
it("finishes a successful probe without waiting for an open SSE body", async () => {
35+
let cancelled = false
36+
const body = new ReadableStream({
37+
pull(controller) {
38+
controller.enqueue(new TextEncoder().encode("data: still-open\n\n"))
39+
},
40+
cancel() {
41+
cancelled = true
42+
},
43+
})
44+
45+
await expect(interpretProbeResponse?.(new Response(body, { status: 200 }))).resolves.toMatchObject({
46+
status: "ok",
47+
})
48+
expect(cancelled).toBe(true)
49+
})
50+
51+
it("classifies ineligibility only on HTTP 403", async () => {
52+
const body = JSON.stringify({ error: { reason: "ACCOUNT_INELIGIBLE" } })
53+
54+
await expect(interpretProbeResponse?.(new Response(body, { status: 403 }))).resolves.toMatchObject({
55+
status: "ineligible",
56+
})
57+
await expect(interpretProbeResponse?.(new Response(body, { status: 500 }))).resolves.toMatchObject({
58+
status: "error",
59+
})
60+
})
61+
62+
it("uses the current AGY request metadata contract for access probes", () => {
63+
const body = buildProbeRequest?.("project-a") as {
64+
project: string
65+
requestId: string
66+
model: string
67+
request: {
68+
sessionId: string
69+
labels: Record<string, string>
70+
contents: unknown[]
71+
}
72+
}
73+
74+
expect(body).toMatchObject({
75+
project: "project-a",
76+
model: "gemini-3.5-flash-low",
77+
request: {
78+
sessionId: "-3750763034362895579",
79+
labels: {
80+
model_enum: "MODEL_PLACEHOLDER_M20",
81+
last_step_index: "1",
82+
},
83+
},
84+
})
85+
expect(body.requestId).toMatch(/^agent\/[0-9a-f-]+\/\d+\/[0-9a-f-]+\/2$/)
86+
})
87+
})
88+
89+
describe("account ineligibility classification", () => {
90+
it("recognizes the exact structured ACCOUNT_INELIGIBLE reason", () => {
91+
const result = extractAccessBlock?.(JSON.stringify({
92+
error: {
93+
code: 403,
94+
status: "PERMISSION_DENIED",
95+
message: "This account cannot use Antigravity.",
96+
details: [{ reason: "ACCOUNT_INELIGIBLE" }],
97+
},
98+
}))
99+
100+
expect(result).toMatchObject({
101+
accountIneligible: true,
102+
validationRequired: false,
103+
message: "This account cannot use Antigravity.",
104+
})
105+
})
106+
107+
it("recognizes ACCOUNT_INELIGIBLE inside an SSE error frame", () => {
108+
const result = extractAccessBlock?.(
109+
'data: {"error":{"message":"Not eligible","metadata":{"reason":"ACCOUNT_INELIGIBLE"}}}\n\n',
110+
)
111+
112+
expect(result?.accountIneligible).toBe(true)
113+
expect(result?.message).toBe("Not eligible")
114+
})
115+
116+
it("does not disable accounts for generic access-denied text", () => {
117+
for (const message of [
118+
"Access denied",
119+
"Permission denied",
120+
"Your account is not eligible for this feature",
121+
"An upstream service denied access",
122+
"ACCOUNT_INELIGIBLE_TEMPORARY",
123+
]) {
124+
const result = extractAccessBlock?.(JSON.stringify({ error: { code: 403, message } }))
125+
expect(result?.accountIneligible, message).toBe(false)
126+
}
127+
})
128+
129+
it("keeps VALIDATION_REQUIRED separate from account ineligibility", () => {
130+
const result = extractAccessBlock?.(JSON.stringify({
131+
error: {
132+
code: 403,
133+
message: "Verify your account",
134+
details: [{ reason: "VALIDATION_REQUIRED" }],
135+
},
136+
}))
137+
138+
expect(result?.validationRequired).toBe(true)
139+
expect(result?.accountIneligible).toBe(false)
140+
})
141+
})

packages/opencode/src/plugin/accounts.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ vi.mock("./storage", async (importOriginal) => {
1616

1717
describe("AccountManager", () => {
1818
beforeEach(() => {
19+
vi.clearAllMocks();
1920
vi.useRealTimers();
2021
vi.stubGlobal("process", { ...process, pid: 0 });
2122
});
@@ -38,6 +39,88 @@ describe("AccountManager", () => {
3839
expect(manager.getAccountCount()).toBe(0);
3940
});
4041

42+
it("persists explicit ineligibility, rejects manual enable, and recovers only after a successful recheck", async () => {
43+
vi.useFakeTimers();
44+
vi.setSystemTime(1_000);
45+
const stored: AccountStorageV4 = {
46+
version: 4,
47+
accounts: [
48+
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
49+
],
50+
activeIndex: 0,
51+
};
52+
const manager = new AccountManager(undefined, stored);
53+
54+
expect(manager.markAccountIneligible(0, "ACCOUNT_INELIGIBLE")).toBe(true);
55+
expect(manager.getAccountsSnapshot()[0]).toMatchObject({
56+
enabled: false,
57+
accountIneligible: true,
58+
accountIneligibleAt: 1_000,
59+
accountIneligibleReason: "ACCOUNT_INELIGIBLE",
60+
eligibilityStateUpdatedAt: 1_000,
61+
});
62+
expect(manager.setAccountEnabled(0, true)).toBe(false);
63+
64+
await manager.saveToDisk();
65+
expect(vi.mocked(saveAccounts)).toHaveBeenCalledWith(expect.objectContaining({
66+
accounts: [expect.objectContaining({
67+
enabled: false,
68+
accountIneligible: true,
69+
eligibilityStateUpdatedAt: 1_000,
70+
})],
71+
}));
72+
73+
vi.setSystemTime(2_000);
74+
expect(manager.clearAccountAccessBlocks(0, true)).toBe(true);
75+
expect(manager.getAccountsSnapshot()[0]).toMatchObject({
76+
enabled: true,
77+
accountIneligible: false,
78+
eligibilityStateUpdatedAt: 2_000,
79+
});
80+
await manager.saveToDisk();
81+
vi.clearAllTimers();
82+
});
83+
84+
it("keeps ineligible and verification-required states mutually exclusive", () => {
85+
vi.useFakeTimers();
86+
vi.setSystemTime(1_000);
87+
const manager = new AccountManager(undefined, {
88+
version: 4,
89+
accounts: [
90+
{
91+
refreshToken: "r1",
92+
projectId: "p1",
93+
addedAt: 1,
94+
lastUsed: 0,
95+
verificationRequired: true,
96+
verificationRequiredAt: 500,
97+
verificationRequiredReason: "verify",
98+
verificationUrl: "https://example.com/verify",
99+
},
100+
],
101+
activeIndex: 0,
102+
});
103+
104+
manager.markAccountIneligible(0, "ACCOUNT_INELIGIBLE");
105+
expect(manager.getAccountsSnapshot()[0]).toMatchObject({
106+
verificationRequired: false,
107+
accountIneligible: true,
108+
});
109+
expect(manager.getAccountsSnapshot()[0]?.verificationUrl).toBeUndefined();
110+
111+
vi.setSystemTime(2_000);
112+
manager.markAccountVerificationRequired(0, "Verify again", "https://example.com/new");
113+
expect(manager.getAccountsSnapshot()[0]).toMatchObject({
114+
enabled: false,
115+
verificationRequired: true,
116+
verificationRequiredReason: "Verify again",
117+
accountIneligible: false,
118+
eligibilityStateUpdatedAt: 2_000,
119+
});
120+
expect(manager.getAccountsSnapshot()[0]?.accountIneligibleReason).toBeUndefined();
121+
vi.clearAllTimers();
122+
});
123+
41124
it("returns current account when not rate-limited for family", () => {
42125
const stored: AccountStorageV4 = {
43126
version: 4,

0 commit comments

Comments
 (0)