Skip to content
Open
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,23 @@ The shipped `server.js`/`tui.js` forwarders support registering the installed pa
}
```

The directory example follows the shipped forwarders, not a verified GA npm-installer command. The pinned harness uses an instrumented wrapper directory; direct npm-spec registration/installer syntax is not established here. Consult the matching host's install/config documentation rather than assuming V1's `opencode plugin` or `tui.json` behavior. Do not copy the harness's synthetic Google API key or mock `baseURL` into a real config. The harness's default fixture config has no user model map; no copied model-map requirement is established. Models are served under `google/…`, and the package supplies the Antigravity catalog.
The package attaches its Antigravity catalog to the host's `google` provider, so the host must expose one. OpenCode 2.0.22 shipped a `google` provider by default; hosts that do not (for example later 2.x builds whose catalog omits it until configured) register nothing until `providers.google` exists. Declare it alongside the plugin:

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: This says the plugin registers nothing without providers.google, but setup still registers the RPC, bridge, hooks, and /antigravity command; only the catalog update is skipped. Say it registers no Antigravity models.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At README.md, line 83:

<comment>This says the plugin registers nothing without `providers.google`, but setup still registers the RPC, bridge, hooks, and `/antigravity` command; only the catalog update is skipped. Say it registers no Antigravity models.</comment>

<file context>
@@ -80,7 +80,23 @@ The shipped `server.js`/`tui.js` forwarders support registering the installed pa

-The directory example follows the shipped forwarders, not a verified GA npm-installer command. The pinned harness uses an instrumented wrapper directory; direct npm-spec registration/installer syntax is not established here. Consult the matching host's install/config documentation rather than assuming V1's opencode plugin or tui.json behavior. Do not copy the harness's synthetic Google API key or mock baseURL into a real config. The harness's default fixture config has no user model map; no copied model-map requirement is established. Models are served under google/…, and the package supplies the Antigravity catalog.
+The package attaches its Antigravity catalog to the host's google provider, so the host must expose one. OpenCode 2.0.22 shipped a google provider by default; hosts that do not (for example later 2.x builds whose catalog omits it until configured) register nothing until providers.google exists. Declare it alongside the plugin:
+
+```jsonc
</file context>


</details>

```suggestion
The package attaches its Antigravity catalog to the host's `google` provider, so the host must expose one. OpenCode 2.0.22 shipped a `google` provider by default; hosts that do not (for example later 2.x builds whose catalog omits it until configured) register no Antigravity models until `providers.google` exists. Declare it alongside the plugin:


```jsonc
{
"plugins": ["/absolute/path/to/node_modules/@cortexkit/opencode-antigravity-auth"],
"providers": {
"google": {
"settings": {
"baseURL": "https://generativelanguage.googleapis.com/v1beta",
"apiKey": "cortexkit-antigravity-local"
}
}
}
}
```

The `apiKey` and `baseURL` are placeholders: the adapter rewrites and authenticates every request for its models on the loopback bridge before egress. Do not reuse the harness's synthetic key or mock `baseURL`. The directory example follows the shipped forwarders, not a verified GA npm-installer command. The pinned harness uses an instrumented wrapper directory; direct npm-spec registration/installer syntax is not established here. Consult the matching host's install/config documentation rather than assuming V1's `opencode plugin` or `tui.json` behavior. Do not copy the harness's synthetic Google API key or mock `baseURL` into a real config. The harness's default fixture config has no user model map; no copied model-map requirement is established. Models are served under `google/…`, and the package supplies the Antigravity catalog.

### OpenCode from this checkout (contributors)

Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/account-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3463,13 +3463,17 @@ class StoreAccountRepository implements AccountRepository {
await handle.close()
}
// Persist both the transfer entry and its containing directory before
// publishing a journal that requires the transfer to recover.
for (const path of [this.paths.transfersDir, this.paths.storeDir]) {
const directory = await open(path, 'r')
try {
await directory.sync()
} finally {
await directory.close()
// publishing a journal that requires the transfer to recover. Windows
// rejects fsync on a directory handle, so directory durability is a
// POSIX-only guarantee (see accountMigrationDurability).
if (process.platform !== 'win32') {
for (const path of [this.paths.transfersDir, this.paths.storeDir]) {
const directory = await open(path, 'r')
try {
await directory.sync()
} finally {
await directory.close()
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed

- OpenCode 2.0.29 compatibility for the GA adapter. The `antigravity-auth` RPC registers a JSON Schema for each method's wire `output` (and the `changed` event payload) instead of the strict Standard Schema, which the newer host cannot serialize, and every answer is stripped of `undefined`-valued keys. Previously each `state`/`apply` call failed with `InvalidRequestError: Expected JSON value at ["output"]` or `rpc.invalid_output: Expected JSON value at ["accounts"]`, which broke the `/antigravity` menu and sidebar. The strict contract still runs inside the handler and in the client's own parse.

## [2.3.0] - 2026-09-17

### Added
Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/scripts/build-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
realpath,
rm,
} from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { build as bundle } from 'esbuild'
import { parse } from 'jsonc-parser'
Expand Down Expand Up @@ -360,7 +360,9 @@ export async function verifyPrerequisites(): Promise<void> {
'package.json',
)
const actual = realpathSync(path)
if (!actual.startsWith(`${realpathSync(TOOL_ROOT)}/node_modules/`))
if (
!actual.startsWith(`${realpathSync(TOOL_ROOT)}${sep}node_modules${sep}`)
)
throw new Error(`build.prerequisite_order: private root escaped ${name}`)
const pkg = JSON.parse(await readFile(actual, 'utf8'))
const expected =
Expand Down Expand Up @@ -701,7 +703,8 @@ export async function buildLibrary(packageRoot = PACKAGE_ROOT) {
`${JSON.stringify(result.metafile, null, 2)}\n`,
)
}
run('chmod', ['0755', 'dist/cli.js'], packageRoot)
if (process.platform !== 'win32')
run('chmod', ['0755', 'dist/cli.js'], packageRoot)
await buildTuiRoots({ packageRoot })
console.log(
'Product build: tsc, canonical copy, 2 audited bundles, raw/runtime TUI for OpenCode 1 and GA',
Expand Down
59 changes: 55 additions & 4 deletions packages/opencode/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type OAuthLoginRequest,
performOAuthLogin,
} from './plugin/oauth-login'
import { persistAccountPool } from './plugin/persist-account-pool'
import { commitLogins, persistAccountPool } from './plugin/persist-account-pool'
import { checkAccountsQuotaStandalone } from './plugin/quota'
import { startOAuthListener } from './plugin/server'
import { getStoragePath, loadAccounts } from './plugin/storage'
Expand Down Expand Up @@ -400,7 +400,12 @@ const execFileAsync = promisify(execFile)

async function openBrowserDefault(url: string): Promise<void> {
if (process.platform === 'win32') {
await execFileAsync('cmd', ['/c', 'start', '', url])
// `cmd.exe` treats `&` in the URL as a command separator, so the URL must be
// passed as a single quoted token or everything after the first `&` (for
// example `response_type`) is lost.
await execFileAsync('cmd', ['/c', 'start', '""', `"${url}"`], {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Quoting does not disable cmd.exe’s %...% expansion, so the percent-encoded OAuth redirect_uri can be consumed and corrupted. Launch the URL without cmd.exe.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/cli.ts, line 406:

<comment>Quoting does not disable `cmd.exe`’s `%...%` expansion, so the percent-encoded OAuth `redirect_uri` can be consumed and corrupted. Launch the URL without `cmd.exe`.</comment>

<file context>
@@ -400,7 +400,12 @@ const execFileAsync = promisify(execFile)
+    // `cmd.exe` treats `&` in the URL as a command separator, so the URL must be
+    // passed as a single quoted token or everything after the first `&` (for
+    // example `response_type`) is lost.
+    await execFileAsync('cmd', ['/c', 'start', '""', `"${url}"`], {
+      windowsVerbatimArguments: true,
+    })
</file context>

windowsVerbatimArguments: true,
})
return
}
await execFileAsync(process.platform === 'darwin' ? 'open' : 'xdg-open', [
Expand Down Expand Up @@ -437,9 +442,13 @@ export function createDefaultCliDependencies(): CliDependencies {
exchange: exchangeAntigravity,
startListener: startOAuthListener,
openBrowser,
announce: (url) => {
process.stdout.write(`Open this URL to sign in:\n${url}\n`)
},
upsert: async (result) => {
await persistAccountPool(
[result],
await persistCliLogin(
resolve(getStoragePath()),
result,
request.startFresh && request.accounts.length === 0,
)
},
Expand Down Expand Up @@ -548,6 +557,48 @@ async function readAccountStore(legacyPath: string): Promise<CliAccountsRead> {
}
}

/**
* Persist one successful OAuth login to the active account source. A migrated
* account store owns the retired pool file and refuses the pre-store writer,
* so a login must go through the store repository; an uninitialized or legacy
* layout still writes the pre-store pool file.
*
* The store path uses `commitLogins` (the same add/upsert the OpenCode sign-in
* uses) rather than a pool replacement: a replacement leaves a resumable
* management journal that does not complete reliably on Windows, and a single
* interactive login is an add, not a wipe.
*/
async function persistCliLogin(
legacyPath: string,
result: AntigravityTokenExchangeSuccess,
replaceAll: boolean,
): Promise<void> {
const modules = await loadCommonAuthStoreModules()
const admission = await readAccountStoreBinding(legacyPath, modules, Date.now)
if (admission.status !== 'bound') {
await persistAccountPool([result], replaceAll)
return
}
const repository = createAccountRepositoryFactory(modules)({
paths: admission.paths,
now: Date.now,
exchange: async ({ refreshToken }) => {
const refreshed = await refreshAntigravityToken(refreshToken)
return {
accessToken: refreshed.access,
refreshToken: refreshed.refresh,
expiresAt: refreshed.expires,
}
},
})
try {
const [committed] = await commitLogins(repository, [result])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A repeat standalone login cannot update an existing account in a migrated store when OAuth returns a new refresh token. commitLogins rejects that same-email/different-credential case as duplicate-identity, so route this flow through an explicit reauthentication/replacement path or preserve the CLI’s previous fresh-login behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/cli.ts, line 595:

<comment>A repeat standalone `login` cannot update an existing account in a migrated store when OAuth returns a new refresh token. `commitLogins` rejects that same-email/different-credential case as `duplicate-identity`, so route this flow through an explicit reauthentication/replacement path or preserve the CLI’s previous fresh-login behavior.</comment>

<file context>
@@ -548,6 +557,48 @@ async function readAccountStore(legacyPath: string): Promise<CliAccountsRead> {
+    },
+  })
+  try {
+    const [committed] = await commitLogins(repository, [result])
+    if (committed?.status === 'refused') throw new Error(committed.message)
+  } finally {
</file context>

if (committed?.status === 'refused') throw new Error(committed.message)
} finally {
await repository.dispose()
}
}

/**
* The account-store operations over the embedded common-auth `./store` and
* `./fs` modules. The operator's `--offline` flag and confirmation are what
Expand Down
19 changes: 13 additions & 6 deletions packages/opencode/src/ga/rpc/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,25 @@ describe('antigravity-auth RPC identity', () => {
expect(ANTIGRAVITY_MENU_COMMAND_NAME).toBe(ANTIGRAVITY_MENU_COMMAND)
})

it('every definition schema is a Standard Schema V1 object', () => {
const schemas = [
it('uses Standard Schema V1 for inputs and a JSON Schema for wire outputs', () => {
// The host must be able to serialize a method answer, which it does
// through the registered wire schema; a Standard Schema decodes but does
// not encode, so outputs (and the event payload) are JSON Schema while the
// inputs keep the strict Standard Schema validator.
const inputs = [
ANTIGRAVITY_RPC_DEFINITION.methods.state.input,
ANTIGRAVITY_RPC_DEFINITION.methods.state.output,
ANTIGRAVITY_RPC_DEFINITION.methods.apply.input,
ANTIGRAVITY_RPC_DEFINITION.methods.apply.output,
ANTIGRAVITY_RPC_DEFINITION.events.changed.schema,
]
for (const schema of schemas) {
for (const schema of inputs) {
expect(schema['~standard'].version).toBe(1)
expect(schema['~standard'].vendor).toBe('cortexkit.antigravity-auth')
}
for (const schema of [
ANTIGRAVITY_RPC_DEFINITION.methods.state.output,
ANTIGRAVITY_RPC_DEFINITION.methods.apply.output,
ANTIGRAVITY_RPC_DEFINITION.events.changed.schema,
])
expect(schema.type).toBe('object')
expect(
ANTIGRAVITY_RPC_DEFINITION.methods.state.input['~standard'].validate(
STATE_INPUT,
Expand Down
25 changes: 21 additions & 4 deletions packages/opencode/src/ga/rpc/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1743,9 +1743,26 @@ export const AntigravityNotificationPayloadSchema =

// Definition

/**
* OpenCode 2 encodes an RPC method's `output`, and encodes an event's `data`,
* through the registered wire schema. The adapter's strict contract schemas
* (`Antigravity*Schema`) are Standard Schema validators used for decoding and
* for the handler's own checks, but the host does not serialize a value
* through a Standard Schema on the response path: registering them as the
* wire schema made every `state`/`apply` answer fail the host's body encoder
* with `InvalidRequestError: Expected JSON value at ["output"]` (OpenCode
* 2.0.29). A permissive JSON Schema keeps the host able to encode the
* already-validated value; the strict contract still runs inside the handler
* and in the client's own parse.
*/
const RPC_WIRE_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
} as const

/**
* The definition the server registers and clients call. Its shape is the one
* the OpenCode 2.0.22 RPC registry reads: `id`, `methods.<name>.{input,
* the OpenCode 2.0.22+ RPC registry reads: `id`, `methods.<name>.{input,
* output}` and `events.<name>.schema`. This module imports nothing from the
* host SDK, so the check against the SDK's `Rpc.PortableDefinition` type
* happens where the server passes this object to `rpc.register`.
Expand All @@ -1755,15 +1772,15 @@ export const ANTIGRAVITY_RPC_DEFINITION = {
methods: {
state: {
input: AntigravityStateInputSchema,
output: AntigravityStateOutputSchema,
output: RPC_WIRE_JSON_SCHEMA,
},
apply: {
input: AntigravityApplyInputSchema,
output: AntigravityApplyOutputSchema,
output: RPC_WIRE_JSON_SCHEMA,
},
},
events: {
changed: { schema: AntigravityChangedEventSchema },
changed: { schema: RPC_WIRE_JSON_SCHEMA },
},
} as const

Expand Down
40 changes: 35 additions & 5 deletions packages/opencode/src/ga/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,32 @@ function stderrLogSink(record: {
}
}

/**
* OpenCode validates an RPC method's answer as a JSON value before it is
* serialized. The snapshot builders set some optional keys to `undefined`
* (for example an account's `cooldownUntil` when it is not cooling down), and
* a strict JSON-value check rejects those, surfacing as
* `rpc.invalid_output: Expected JSON value at ["accounts"]`. Drop
* `undefined`-valued keys recursively so the answer is always a JSON value;
* absent optional keys are already treated as `undefined` by the client's
* parsers. Arrays and primitives pass through unchanged, and `null` is kept.
*/
export function toRpcJson<T>(value: T): T {
if (Array.isArray(value)) return value.map((entry) => toRpcJson(entry)) as T
if (value !== null && typeof value === 'object') {
const prototype = Object.getPrototypeOf(value)
if (prototype === Object.prototype || prototype === null) {
const result: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined) continue
result[key] = toRpcJson(entry)
}
return result as T
}
}
return value
}

function headerValue(
request: Request,
names: readonly string[],
Expand Down Expand Up @@ -1640,9 +1666,11 @@ export async function setupGaActivation(
},
parseApplyRequest: commandsModule.parseApplyRequest,
onChanged: (event) => {
void rpcRegistration?.events.emit('changed', event).catch(() => {
// A missed changed notification delays the client's next state request.
})
void rpcRegistration?.events
.emit('changed', toRpcJson(event))
.catch(() => {
// A missed changed notification delays the client's next state request.
})
},
})

Expand All @@ -1666,8 +1694,10 @@ export async function setupGaActivation(
runtime.scope.add(activation, 'producer')

const handlers: RpcHandlers<typeof ANTIGRAVITY_RPC_DEFINITION> = {
state: (input, call) => activation.handlers.state(input, call.signal),
apply: (input, call) => activation.handlers.apply(input, call.signal),
state: async (input, call) =>
toRpcJson(await activation.handlers.state(input, call.signal)),
apply: async (input, call) =>
toRpcJson(await activation.handlers.apply(input, call.signal)),
}
rpcRegistration = await context.rpc.register(
ANTIGRAVITY_RPC_DEFINITION,
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/src/ga/server/rpc-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'bun:test'

import { toRpcJson } from './index.ts'

describe('toRpcJson', () => {
it('drops undefined-valued keys so the answer is a JSON value', () => {
const value = toRpcJson({
id: 'acct-0',
cooldownUntil: undefined,
quota: { gemini: undefined, 'non-gemini': { remainingPercent: 100 } },
nested: [{ keep: 1, drop: undefined }],
})
expect(value).toEqual({
id: 'acct-0',
quota: { 'non-gemini': { remainingPercent: 100 } },
nested: [{ keep: 1 }],
})
expect(JSON.stringify(value)).toBe(
'{"id":"acct-0","quota":{"non-gemini":{"remainingPercent":100}},"nested":[{"keep":1}]}',
)
})

it('keeps null, false, zero and empty strings, and returns primitives unchanged', () => {
expect(toRpcJson(null)).toBeNull()
expect(toRpcJson(false)).toBe(false)
expect(toRpcJson(0)).toBe(0)
expect(toRpcJson('')).toBe('')
expect(toRpcJson({ a: null, b: false, c: 0, d: '' })).toEqual({
a: null,
b: false,
c: 0,
d: '',
})
})

it('preserves non-plain objects instead of inspecting their internals', () => {
const date = new Date(0)
expect(toRpcJson(date)).toBe(date)
const array = [date]
expect(toRpcJson(array)[0]).toBe(date)
})
})
4 changes: 4 additions & 0 deletions packages/opencode/src/plugin/oauth-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface OAuthLoginDependencies {
exchange: typeof exchangeAntigravity
startListener: typeof startOAuthListener
openBrowser(url: string): Promise<void>
/** Reports the authorization URL when the browser is not opened (headless / --no-browser). */
announce?(url: string): void
upsert(result: AntigravityTokenExchangeSuccess): Promise<void>
}

Expand Down Expand Up @@ -56,6 +58,8 @@ export async function performOAuthLogin(
const authorization = await deps.authorize(request.projectId ?? '')
if (!request.noBrowser && !request.isHeadless) {
await deps.openBrowser(authorization.url)
} else {
deps.announce?.(authorization.url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This can leave OpenCode auth login waiting for a callback without showing the authorization URL: its performOAuthLogin caller omits announce, and OPENCODE_HEADLESS=1 can reach this branch when shouldSkipLocalServer() is false. Wire an announcement callback at that call site or route headless logins through the manual URL prompt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/plugin/oauth-login.ts, line 62:

<comment>This can leave OpenCode `auth login` waiting for a callback without showing the authorization URL: its `performOAuthLogin` caller omits `announce`, and `OPENCODE_HEADLESS=1` can reach this branch when `shouldSkipLocalServer()` is false. Wire an announcement callback at that call site or route headless logins through the manual URL prompt.</comment>

<file context>
@@ -56,6 +58,8 @@ export async function performOAuthLogin(
     if (!request.noBrowser && !request.isHeadless) {
       await deps.openBrowser(authorization.url)
+    } else {
+      deps.announce?.(authorization.url)
     }
 
</file context>

}

const params = callbackParams(
Expand Down
2 changes: 1 addition & 1 deletion tools/common-auth-build/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function verifiedCompiler() {
const installed = await realpath(
resolve(toolRoot, 'node_modules', name, 'package.json'),
)
if (!installed.startsWith(`${toolRoot}/node_modules/`))
if (!installed.startsWith(`${toolRoot}${sep}node_modules${sep}`))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: On Windows, realpath() can return canonical casing that differs from toolRoot, but startsWith() compares strings case-sensitively. A checkout opened through a differently cased path is therefore rejected as escaped; compare against a canonicalized node_modules root or use Windows-aware path containment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tools/common-auth-build/build.mjs, line 41:

<comment>On Windows, `realpath()` can return canonical casing that differs from `toolRoot`, but `startsWith()` compares strings case-sensitively. A checkout opened through a differently cased path is therefore rejected as escaped; compare against a canonicalized `node_modules` root or use Windows-aware path containment.</comment>

<file context>
@@ -38,7 +38,7 @@ async function verifiedCompiler() {
       resolve(toolRoot, 'node_modules', name, 'package.json'),
     )
-    if (!installed.startsWith(`${toolRoot}/node_modules/`))
+    if (!installed.startsWith(`${toolRoot}${sep}node_modules${sep}`))
       throw new Error(`Private tool root escaped: ${name}`)
     if (JSON.parse(await readFile(installed, 'utf8')).version !== version)
</file context>

throw new Error(`Private tool root escaped: ${name}`)
if (JSON.parse(await readFile(installed, 'utf8')).version !== version)
throw new Error(`Private tool version mismatch: ${name}`)
Expand Down