diff --git a/PATCH.md b/PATCH.md index 6d6d3109a..52ffb4dad 100644 --- a/PATCH.md +++ b/PATCH.md @@ -57,6 +57,19 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera activities and the continually repainting text shimmer are not imported. The fork's unified image/file/pdf/video upload queue replaces upstream's separate `files` array; per-attachment progress, retry and video playback are carried through it. +- Antigravity is ported from upstream `06336460c9988f29c71e839c4c9c840c4552e077` + and its follow-ups through `d29c56a5c404cb0f58d3b2ac41762fa0d0ac28d4` onto the fork's + shared ACP V2 adapter. Managed installation, isolated per-account Google authentication, + account model catalogs, native permissions/questions, attachments and workspace skills + are available in web and native Swift settings/composers. `/logout` closes the configured + account's V2 sessions and completes as a local command; it never starts a new agent turn. + Antigravity subagent batches remain V2 tool items, active until the parent turn settles; + the native protocol supplies no individual child IDs or models, so no child threads are + invented. Commands that outlive the parent retain V2 background ownership. The fork's + process-tree supervision, cancellation quarantine and response receipts remain intact. + No V1 adapter, task bridge, pagination or SQLite migration is imported. Expo receives + only question-wire compatibility, preserving exact option IDs and custom-answer limits. + - Sidebar file drops are ported to both web sidebar layouts and search results using the fork's unified attachment queue. Deferred drops are scoped by environment and thread, survive repeated drops, and are cleared individually on navigation failure or when a diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index e67e16b32..7de775557 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -269,12 +269,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.options.map((option) => { - const selected = isPendingUserInputOptionSelected(draft, option.label); + const selected = isPendingUserInputOptionSelected( + draft, + option.value ?? option.label, + option.value !== undefined, + ); const description = option.description !== option.label ? option.description : undefined; return ( @@ -311,17 +315,19 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ); })} - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - onFocus={() => props.onInputFocusChange?.(true)} - onBlur={() => props.onInputFocusChange?.(false)} - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> + {question.allowCustomAnswer !== false && ( + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> + )} ); })} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 845ea93ce..3db4ed2d7 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -47,6 +47,25 @@ const multiSelectQuestion = { } as const; describe("pending user input answers", () => { + it("preserves opaque values without matching other choices by trimmed labels", () => { + const question = { + ...singleSelectQuestion, + allowCustomAnswer: false, + options: [ + { label: "Choice", description: "", value: " choice: opaque " }, + { label: "Choice", description: "", value: "choice: opaque" }, + ], + }; + const draft = togglePendingUserInputOptionSelection(question, undefined, " choice: opaque "); + expect(isPendingUserInputOptionSelected(draft, " choice: opaque ", true)).toBe(true); + expect(isPendingUserInputOptionSelected(draft, "choice: opaque", true)).toBe(false); + expect( + buildPendingUserInputAnswers([question], { + runtime: { ...draft, customAnswer: "not allowed" }, + }), + ).toEqual({ runtime: " choice: opaque " }); + }); + it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 992e6b316..3268bf4a3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -219,16 +219,15 @@ function normalizeSelectedOptionLabels( return []; } - return Array.from( - new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), - ); + return Array.from(new Set(value.filter((entry) => entry.length > 0))); } function resolvePendingUserInputAnswer( question: ThreadUserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, ): string | ReadonlyArray | null { - const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + const customAnswer = + question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } @@ -1070,12 +1069,16 @@ export function setPendingUserInputCustomAnswer( export function isPendingUserInputOptionSelected( draft: PendingUserInputDraftAnswer | undefined, optionLabel: string, + exactValue = false, ): boolean { if (normalizeDraftAnswer(draft?.customAnswer)) { return false; } - return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); + const selected = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + return exactValue + ? selected.includes(optionLabel) + : selected.some((entry) => entry.trim() === optionLabel.trim()); } export function togglePendingUserInputOptionSelection( @@ -1083,7 +1086,9 @@ export function togglePendingUserInputOptionSelection( draft: PendingUserInputDraftAnswer | undefined, optionLabel: string, ): PendingUserInputDraftAnswer { - const normalizedOptionLabel = optionLabel.trim(); + const normalizedOptionLabel = question.options.some((option) => option.value === optionLabel) + ? optionLabel + : optionLabel.trim(); if (question.multiSelect) { const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); diff --git a/apps/server/package.json b/apps/server/package.json index 8ebdeaeb0..08f598818 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,7 +41,8 @@ "node-pty": "^1.1.0", "stream-chain": "4.2.5", "stream-json": "3.6.0", - "yaml": "catalog:" + "yaml": "catalog:", + "yauzl": "^3.4.0" }, "devDependencies": { "@effect/vitest": "catalog:", @@ -51,6 +52,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", "@types/node": "catalog:", + "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", "vite-plus": "catalog:" diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a08d63770..6a5b45087 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1105,7 +1105,12 @@ const program = Effect.gen(function* () { const permission = yield* agent.client.requestPermission({ sessionId: requestedSessionId, toolCall: { - toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, + toolCallId: + process.env.T3_ACP_NATIVE_QUESTION === "1" + ? `interaction_${toolCallId}` + : index === 0 + ? toolCallId + : `${toolCallId}-${index + 1}`, title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, kind: "execute", status: "pending", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 46052963a..913ef648f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,15 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 32e5f62cf..492c16126 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1,3 +1,8 @@ +import { + isAntigravityUserInputRequest, + extractAntigravityUserInputQuestion, + makeAntigravityUserInputResponse, +} from "../../provider/acp/AntigravityProtocol.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -9241,3 +9246,194 @@ describe("acpPostSettleMonitorPromptShouldSuppress", () => { ); }); }); + +it.live("routes native Antigravity choices through V2 receipts even in full access", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("antigravity-question-test"); + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + isPermissionQuestion: isAntigravityUserInputRequest, + permissionQuestion: extractAntigravityUserInputQuestion, + permissionQuestionResponse: makeAntigravityUserInputResponse, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + environment: { + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_NATIVE_QUESTION: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: " choice: opaque ", + }, + }), + }, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }); + const threadId = ThreadId.make("antigravity-question-thread"); + const modelSelection = { instanceId, model: "default" }; + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("antigravity-question-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + let pending: Extract | undefined; + while (!pending) { + const event = yield* Queue.take(events); + if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") + pending = event; + } + assert.equal(pending.runtimeRequest.kind, "user_input"); + const invalid = yield* runtime + .respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { "interaction_tool-call-1": "not offered" }, + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(invalid)); + yield* runtime.respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { "interaction_tool-call-1": " choice: opaque " }, + }); + const duplicate = yield* runtime + .respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { "interaction_tool-call-1": " choice: opaque " }, + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(duplicate)); + }).pipe(Effect.provide(testLayer), Effect.scoped), +); + +it.live("keeps late background command updates on their original V2 run", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("antigravity-background-test"); + let notify: + | Parameters[0] + | undefined; + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + preserveBackgroundToolUpdates: true, + extractBackgroundTaskId: (tool) => (tool.kind === "execute" ? tool.toolCallId : undefined), + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => { + notify = handler; + return runtime.handleSessionUpdate(handler); + }, + prompt: () => + Effect.gen(function* () { + if (!notify) return yield* Effect.die("Missing native update handler"); + yield* notify({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "background-command", + kind: "execute", + title: "Build", + status: "in_progress", + rawInput: { command: "make" }, + }, + }); + return { stopReason: "end_turn" } as const; + }), + }), + }), + }, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }); + const threadId = ThreadId.make("background-owner-thread"); + const modelSelection = { instanceId, model: "default" }; + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("background-owner-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const turnInput = makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }); + yield* runtime.startTurn(turnInput); + let ended = false; + while (!ended) ended = (yield* Queue.take(events)).type === "turn.terminal"; + assert.isTrue(yield* runtime.hasPendingBackgroundWork ?? Effect.succeed(false)); + if (!notify) return yield* Effect.die("Missing native update handler"); + yield* notify({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "background-command", + status: "completed", + rawOutput: { output: "Build complete", exitCode: 0 }, + }, + }); + let completed = false; + while (!completed) { + const event = yield* Queue.take(events); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "completed" + ) { + assert.equal(event.turnItem.runId, turnInput.runId); + assert.include(event.turnItem.output ?? "", "Build complete"); + completed = true; + } + } + assert.isFalse(yield* runtime.hasPendingBackgroundWork ?? Effect.succeed(true)); + }).pipe(Effect.provide(testLayer), Effect.scoped), +); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 81b7cadaf..ce54a5987 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -19,6 +19,7 @@ import { type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, type ProviderApprovalDecision, + type ProviderApprovalOption, type ProviderInstanceId, type ProviderDriverKind, type ProviderRequestKind, @@ -115,6 +116,7 @@ export type AcpAdapterV2NativeLogging = Pick< >; export interface AcpAdapterV2UserInputRequest { + readonly validateAnswers?: (answers: ProviderUserInputAnswers) => boolean; readonly nativeItemId: string; readonly nativeMethod?: string; readonly nativeRequestId: string; @@ -192,6 +194,31 @@ export function acpRootTurnShouldRearmRecoveryTimers(context: { } export interface AcpAdapterV2Flavor { + readonly preferResumeSession?: boolean; + readonly configureSession?: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + selection: ModelSelection, + policy: ProviderAdapterV2RuntimePolicy, + ) => Effect.Effect; + readonly buildPrompt?: ( + text: string, + attachments: ReadonlyArray, + ) => Effect.Effect, EffectAcpErrors.AcpError>; + readonly permissionQuestion?: ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => OrchestrationV2UserInputQuestion | undefined; + readonly isPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => boolean; + readonly permissionQuestionResponse?: ( + request: EffectAcpSchema.RequestPermissionRequest, + answers: ProviderUserInputAnswers, + ) => EffectAcpSchema.RequestPermissionResponse | undefined; + readonly approvalOptions?: ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => ReadonlyArray; + readonly selectPermissionOption?: ( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, + ) => string | undefined; readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; readonly makeRuntime: ( @@ -261,6 +288,8 @@ export interface AcpAdapterV2Flavor { * can project (Grok monitors finish after the root prompt settles). */ readonly deferFinalizeForBackgroundWork?: boolean; + /** Retain the originating V2 tool row for late native command updates. */ + readonly preserveBackgroundToolUpdates?: boolean; readonly assertComplete?: Effect.Effect; /** * When true, schedule speculative local settlement after root session @@ -1270,6 +1299,7 @@ type PendingRuntimeRequest = { readonly decision: Deferred.Deferred; } | { + readonly validateAnswers?: (answers: ProviderUserInputAnswers) => boolean; readonly type: "user_input"; readonly answers: Deferred.Deferred; } @@ -1592,6 +1622,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV // lineages into the next turn on the same session so their terminal // signals can still flip the original turn items instead of leaving // them running forever. + const backgroundToolOwners = new Map(); const carryoverSubagents = yield* Ref.make<{ readonly sessionId: string; readonly subagents: ReadonlyArray; @@ -2429,6 +2460,12 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV context.persistentBackgroundTaskIds.add(backgroundTaskId); } const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); + if (flavor.preserveBackgroundToolUpdates) { + const key = `${context.nativeThreadId}:${toolCall.toolCallId}`; + if (backgroundStatus === "pending" || backgroundStatus === "running") + backgroundToolOwners.set(key, context); + else backgroundToolOwners.delete(key); + } yield* setBackgroundTaskRunning( backgroundTaskId, backgroundStatus === "pending" || backgroundStatus === "running", @@ -3127,6 +3164,21 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ) { const context = yield* Ref.get(activeTurn); const update = notification.update; + if ( + flavor.preserveBackgroundToolUpdates && + (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") + ) { + const owner = backgroundToolOwners.get( + `${notification.sessionId}:${update.toolCallId}`, + ); + if (owner && (owner.finalized || owner !== context)) { + if (yield* Ref.get(stoppedRunQuarantine)) return; + for (const event of parseSessionUpdateEvent(notification).events) { + if (event._tag === "ToolCallUpdated") yield* emitTool(owner, event.toolCall); + } + return; + } + } // Only while a finalized turn is still the active context. When // activeTurn is null, post-settle agent frames must reach // bufferPostSettleWake so continuation can attach (context?.finalized @@ -3443,6 +3495,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV type: "approval_request", requestId, requestKind, + ...(flavor.approvalOptions ? { options: flavor.approvalOptions(params) } : {}), ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }), }; yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -3580,6 +3633,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV const updated = new Map(current); updated.set(String(requestId), { type: "user_input", + ...(request.validateAnswers ? { validateAnswers: request.validateAnswers } : {}), generation, nativeResponseAcknowledgement, requestId, @@ -3895,6 +3949,29 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ); if (Option.isNone(correlated)) return yield* Effect.never; const correlatedTransportRequestId = correlated.value; + if (flavor.isPermissionQuestion?.(params)) { + const question = flavor.permissionQuestion?.(params); + if (question === undefined) return { outcome: { outcome: "cancelled" } } as const; + const answer = yield* requestUserInputWithAdmission( + handlerGeneration, + Effect.succeed({ + nativeItemId: params.toolCall.toolCallId, + nativeRequestId: params.toolCall.toolCallId, + nativeSessionId: params.sessionId, + nativeMethod: "session/request_permission", + questions: [question], + validateAnswers: (answers: ProviderUserInputAnswers) => + flavor.permissionQuestionResponse?.(params, answers) !== undefined, + }), + correlatedTransportRequestId, + ); + const response = + answer.answers === null + ? undefined + : flavor.permissionQuestionResponse?.(params, answer.answers); + yield* answer.acknowledgeNativeResponse; + return response ?? ({ outcome: { outcome: "cancelled" } } as const); + } const admitted = yield* runRuntimeCallbackAtGeneration( handlerGeneration, Effect.gen(function* () { @@ -3978,7 +4055,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV if (decision === "cancel") { return { outcome: { outcome: "cancelled" } } as const; } - const optionId = selectPermissionOptionId(params, decision); + const optionId = (flavor.selectPermissionOption ?? selectPermissionOptionId)( + params, + decision, + ); return optionId === undefined ? ({ outcome: { outcome: "cancelled" } } as const) : ({ outcome: { outcome: "selected", optionId } } as const); @@ -4162,6 +4242,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV threadId: ThreadId | null, ) { const activationOptions = { mcpServers: acpMcpServers(threadId) }; + if (flavor.preferResumeSession && canResumeSession) { + return yield* runtime.resumeSession(sessionId, activationOptions); + } if (canLoadSession) { return yield* runtime.loadSession(sessionId, activationOptions); } @@ -4179,6 +4262,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV modelSelection: ModelSelection, runtimePolicy: ProviderAdapterV2RuntimePolicy, ) { + if (flavor.configureSession) { + return yield* flavor.configureSession(runtime, modelSelection, runtimePolicy); + } const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; if ( requestedModel.length > 0 && @@ -4311,6 +4397,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV yield* Ref.set(continuationRequested, false); yield* Ref.set(runningBackgroundTaskIds, new Set()); yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); + backgroundToolOwners.clear(); yield* Ref.set(carryoverSubagents, null); yield* Ref.set(lastTurnRoute, null); }), @@ -4532,6 +4619,8 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV runOrdinal: turnInput.runOrdinal, hasT3Mcp: acpMcpServers(turnInput.threadId).length > 0, }); + if (flavor.buildPrompt) + return yield* flavor.buildPrompt(text, turnInput.message.attachments); if (text.length > 0) { prompt.push({ type: "text", text }); } @@ -4959,7 +5048,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV providerSessionId: input.providerSessionId, providerSession, events: Stream.fromEffectRepeat(Queue.take(events)), - ...(postSettleContinuationEnabled + ...(postSettleContinuationEnabled || flavor.preserveBackgroundToolUpdates ? { hasPendingBackgroundWork: Effect.gen(function* () { if ((yield* Ref.get(wakeBuffer)).length > 0) return true; @@ -5385,6 +5474,31 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV detail: `No pending ACP runtime request ${requestInput.requestId}`, }); } + if ( + pending.type === "user_input" && + requestInput.answers !== undefined && + pending.validateAnswers && + !pending.validateAnswers(requestInput.answers) + ) { + return yield* new ProviderAdapterProtocolError({ + driver, + detail: + "Select one of the offered answers. This question does not accept custom answers", + }); + } + if ( + pending.type === "approval" && + pending.turnItem.type === "approval_request" && + pending.turnItem.options && + !pending.turnItem.options.some( + (option) => option.decision === requestInput.decision, + ) + ) { + return yield* new ProviderAdapterProtocolError({ + driver, + detail: "Select one of the offered permission choices", + }); + } const settled = pending.type === "user_input" ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts new file mode 100644 index 000000000..f2f3b43b6 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -0,0 +1,202 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as AcpErrors from "effect-acp/errors"; +import type { + AcpSessionRuntime, + AcpSessionRuntimeStartResult, +} from "../../provider/acp/AcpSessionRuntime.ts"; +import type { AntigravityAcpRuntimeInput } from "../../provider/acp/AntigravityAcpSupport.ts"; +import { + antigravityPermissionMode, + applyAntigravityAcpModelSelection, + buildAntigravityPrompt, +} from "../../provider/acp/AntigravityAcpSupport.ts"; +import { + antigravityApprovalOptions, + classifyAntigravitySubagentToolCall, + extractAntigravityUserInputQuestion, + isAntigravityUserInputRequest, + makeAntigravityUserInputResponse, + normalizeAntigravityToolCall, + selectAntigravityPermissionOptionId, +} from "../../provider/acp/AntigravityProtocol.ts"; +import { + readAntigravityClientTextFile, + writeAntigravityClientTextFile, +} from "../../provider/acp/AntigravityClientFiles.ts"; +import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts"; +import type { ProviderSetupError } from "@t3tools/contracts"; +import type * as AcpSchema from "effect-acp/schema"; +import { + makeAcpAdapterV2, + AcpProviderCapabilitiesV2, + type AcpAdapterV2Options, + type AcpAdapterV2Flavor, +} from "./AcpAdapterV2.ts"; + +export interface AntigravityAdapterV2Options extends Omit { + readonly path: Path.Path; + readonly makeRuntime: ( + input: Omit, + ) => Effect.Effect< + AcpSessionRuntime["Service"], + AcpErrors.AcpError | ProviderSetupError, + Scope.Scope + >; + readonly withProcess: AntigravityAuth["withProcess"]; + readonly defaultModel: Effect.Effect; + readonly onSessionStarted: ( + started: AcpSessionRuntimeStartResult, + cwd?: string, + ) => Effect.Effect; + readonly onConfigOptionsUpdated: ( + options: ReadonlyArray, + ) => Effect.Effect; + readonly onAvailableCommands: ( + commands: ReadonlyArray, + cwd?: string, + ) => Effect.Effect; +} + +/** Reuses V2's request receipts, interruption quarantine and durable thread identity. */ +export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) { + const flavor: AcpAdapterV2Flavor = { + driver: ProviderDriverKind.make("antigravity"), + capabilities: { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + supportsRuntimeModeSwitchInSession: true, + }, + tools: { ...AcpProviderCapabilitiesV2.tools, supportsMcpTools: true }, + }, + preferResumeSession: true, + supportsImagePrompts: true, + isPermissionQuestion: isAntigravityUserInputRequest, + permissionQuestion: extractAntigravityUserInputQuestion, + permissionQuestionResponse: makeAntigravityUserInputResponse, + approvalOptions: antigravityApprovalOptions, + selectPermissionOption: selectAntigravityPermissionOptionId, + normalizeToolCall: normalizeAntigravityToolCall, + preserveBackgroundToolUpdates: true, + extractBackgroundTaskId: (toolCall) => + toolCall.kind === "execute" ? toolCall.toolCallId : undefined, + configureSession: (runtime, selection, policy) => + Effect.gen(function* () { + yield* applyAntigravityAcpModelSelection({ + runtime, + model: selection.model, + defaultModel: yield* options.defaultModel, + mapError: (error) => error, + }); + yield* runtime.setMode(antigravityPermissionMode(policy.runtimeMode)); + for (const option of selection.options ?? []) + yield* runtime.setConfigOption(option.id, option.value); + }), + buildPrompt: (text, attachments) => + buildAntigravityPrompt({ + input: text, + attachments, + attachmentsDir: options.serverConfig.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.map((blocks) => [...blocks]), + ), + makeRuntime: (input) => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + return yield* options + .withProcess( + Scope.close(scope, Exit.void), + Effect.gen(function* () { + const runtime = yield* options.makeRuntime({ ...input, clientFileSystem: true }); + const subagentBatches = new Set(); + const mcpTools = new Set(); + const allowedRoots = [input.cwd, options.serverConfig.attachmentsDir]; + yield* runtime.handleReadTextFile((request) => + readAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots, + request, + }), + ); + yield* runtime.handleWriteTextFile((request) => + writeAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots, + request, + }), + ); + const publish = (started: AcpSessionRuntimeStartResult) => + options.onSessionStarted(started, input.cwd); + return { + ...runtime, + start: () => runtime.start().pipe(Effect.tap(publish)), + resumeSession: (id, activation) => + runtime.resumeSession(id, activation).pipe(Effect.tap(publish)), + handleSessionUpdate: (handler) => + runtime.handleSessionUpdate((notification) => + Effect.gen(function* () { + const update = notification.update; + if (update.sessionUpdate === "config_option_update") + yield* options.onConfigOptionsUpdated(update.configOptions); + if (update.sessionUpdate === "available_commands_update") + yield* options.onAvailableCommands(update.availableCommands, input.cwd); + if ( + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" + ) + return yield* handler(notification); + const kind = classifyAntigravitySubagentToolCall( + { + toolCallId: update.toolCallId, + ...(update.kind ? { kind: update.kind } : {}), + ...(update.title ? { title: update.title } : {}), + data: {}, + }, + notification, + ); + if (kind === "mcp") mcpTools.add(update.toolCallId); + if (kind === "subagent" && !mcpTools.has(update.toolCallId)) + subagentBatches.add(update.toolCallId); + if ( + !subagentBatches.has(update.toolCallId) || + mcpTools.has(update.toolCallId) + ) + return yield* handler(notification); + // Launch acknowledgement is not an individual agent's completion. + // The V2 foreground-tool terminalizer ends the batch with its parent. + return yield* handler({ + ...notification, + update: { + ...update, + title: "Antigravity subagent batch", + ...(update.status === "completed" + ? { status: "in_progress" as const } + : {}), + }, + }); + }), + ), + } satisfies typeof runtime; + }), + ) + .pipe( + Effect.mapError((cause) => + cause._tag === "ProviderSetupError" + ? new AcpErrors.AcpTransportError({ detail: cause.detail, cause }) + : cause, + ), + ); + }), + }; + return makeAcpAdapterV2({ ...options, flavor }); +} diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 03c49e98f..356bdba35 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -115,6 +115,7 @@ function makeExecutorLayer(input: { ProviderSessionManagerV2, ProviderSessionManagerV2.of({ shutdown: Effect.void, + closeInstance: () => Effect.void, hasPendingBackgroundWork: Effect.succeed(false), open: () => Effect.die("unused open"), get: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 3c6621cd8..c0227cec4 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -133,6 +133,9 @@ export type ProviderSessionManagerV2Error = typeof ProviderSessionManagerV2Error export interface ProviderSessionManagerV2Shape { readonly shutdown: Effect.Effect; + readonly closeInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; /** * Whether any live session reports work held outside an active turn — the * same signal that pins a session against idle release, asked across all of @@ -1443,6 +1446,22 @@ export const layerWithOptions = ( return ProviderSessionManagerV2.of({ shutdown, + closeInstance: (instanceId) => + Effect.gen(function* () { + const owned = [...(yield* Ref.get(sessions)).values()].filter( + (entry) => entry.runtime.instanceId === instanceId, + ); + yield* Effect.forEach( + owned, + (entry) => + releaseEntry({ + providerSessionId: entry.runtime.providerSession.id, + reason: "manual_shutdown", + detail: "Provider account sign-in or sign-out.", + }), + { discard: true }, + ); + }), hasPendingBackgroundWork, open: (input) => sessionOpen.withLock( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index e927e831a..ff5957675 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -227,6 +227,7 @@ it.effect( ProviderSessionManagerV2, ProviderSessionManagerV2.of({ shutdown: Effect.void, + closeInstance: () => Effect.void, hasPendingBackgroundWork: Effect.succeed(false), open: () => Effect.die("unused open"), get: (providerSessionId) => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index da3b1c145..2ee3728de 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1,3 +1,4 @@ +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; import { assert, it } from "@effect/vitest"; import { type ChatAttachment, @@ -39,12 +40,18 @@ import { IdAllocatorV2, layer as idAllocatorLayer } from "./IdAllocator.ts"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { - layer as providerTurnStartLayer, + layer as providerTurnStartBaseLayer, ProviderTurnStartServiceV2, } from "./ProviderTurnStartService.ts"; import { RunExecutionServiceV2 } from "./RunExecutionService.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; +const providerTurnStartLayer = providerTurnStartBaseLayer.pipe( + Layer.provide( + Layer.mock(ProviderAuthService)({ tryHandlePromptCommand: () => Effect.succeed(false) }), + ), +); + const driver = ProviderDriverKind.make("codex"); const providerInstanceId = ProviderInstanceId.make("codex"); const modelSelection = { @@ -178,6 +185,7 @@ function makeProjection(now: DateTime.Utc): OrchestrationV2ThreadProjection { function makeTestLayer(input: { readonly projection: OrchestrationV2ThreadProjection; readonly committed: boolean; + readonly handleLocalCommand?: () => Effect.Effect; readonly writes: Ref.Ref>>; }) { const projectionLayer = Layer.succeed( @@ -228,6 +236,7 @@ function makeTestLayer(input: { ProviderSessionManagerV2, ProviderSessionManagerV2.of({ shutdown: Effect.void, + closeInstance: () => Effect.void, hasPendingBackgroundWork: Effect.succeed(false), open: () => Effect.die("unused open"), get: () => Effect.die("unused get"), @@ -249,7 +258,12 @@ function makeTestLayer(input: { }), ), ); - return providerTurnStartLayer.pipe( + return providerTurnStartBaseLayer.pipe( + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: input.handleLocalCommand ?? (() => Effect.succeed(false)), + }), + ), Layer.provide( Layer.mergeAll( projectionLayer, @@ -521,6 +535,7 @@ function makeStartTestLayer(input: { ProviderSessionManagerV2, ProviderSessionManagerV2.of({ shutdown: Effect.void, + closeInstance: () => Effect.void, hasPendingBackgroundWork: Effect.succeed(false), open: () => Effect.succeed(sessionRuntime), get: () => Effect.die("unused get"), @@ -736,3 +751,36 @@ it.effect("restart continuation never falls back to a fresh provider conversatio assert.deepEqual(yield* Ref.get(startInputs), []); }), ); + +it.effect("settles a local sign-out without opening a provider turn or materializing files", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const projection = makeStartProjection({ now, text: "/logout", attachments: [] }); + const writes = yield* Ref.make>>([]); + let signedOut = false; + const testLayer = makeTestLayer({ + projection, + committed: true, + writes, + handleLocalCommand: () => + Effect.sync(() => { + signedOut = true; + return true; + }), + }); + yield* Effect.gen(function* () { + const service = yield* ProviderTurnStartServiceV2; + yield* service.start({ threadId: startThreadId, runId: startRunId }); + }).pipe(Effect.provide(testLayer)); + assert.isTrue(signedOut); + const events = (yield* Ref.get(writes)).flat(); + assert.isTrue( + events.some((event) => event.type === "run.updated" && event.payload.status === "completed"), + ); + assert.isTrue( + events.some( + (event) => event.type === "message.updated" && event.payload.text.includes("Signed out"), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 1a19432d9..935feb6f5 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1,3 +1,4 @@ +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; import { CommandId, type OrchestrationV2DomainEvent, @@ -68,6 +69,7 @@ export class ProviderTurnStartServiceV2 extends Context.Service< export const layer: Layer.Layer< ProviderTurnStartServiceV2, never, + | ProviderAuthService | AttachmentMaterialization | EventSinkV2 | ContextHandoffServiceV2 @@ -79,6 +81,7 @@ export const layer: Layer.Layer< > = Layer.effect( ProviderTurnStartServiceV2, Effect.gen(function* () { + const providerAuth = yield* ProviderAuthService; const attachmentMaterialization = yield* AttachmentMaterialization; const eventSink = yield* EventSinkV2; const contextHandoffService = yield* ContextHandoffServiceV2; @@ -144,6 +147,112 @@ export const layer: Layer.Layer< cause: `Run ${runId} is missing its execution projection state.`, }); } + if ( + message.text.trim() === "/logout" && + message.attachments.length === 0 && + (yield* providerAuth.tryHandlePromptCommand({ + instanceId: run.providerInstanceId, + text: message.text, + hasAttachments: false, + })) + ) { + const now = yield* DateTime.now; + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: providerThread.driver, + nativeItemId: `local-logout:${run.id}`, + }); + const itemId = idAllocator.derive.turnItemFromProviderItem({ + driver: providerThread.driver, + nativeItemId: `local-logout:${run.id}`, + }); + const base = { + threadId: input.threadId, + runId, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + }; + const text = + "Signed out of Google. Sign in again from your provider account in Settings to continue with Antigravity."; + const events: OrchestrationV2DomainEvent[] = [ + { + ...base, + id: yield* idAllocator.allocate.event({ threadId: input.threadId }), + type: "message.updated", + payload: { + id: messageId, + threadId: input.threadId, + runId, + nodeId: rootNode.id, + createdBy: "agent", + creationSource: "server", + role: "assistant", + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }, + { + ...base, + id: yield* idAllocator.allocate.event({ threadId: input.threadId }), + type: "turn-item.updated", + payload: { + id: itemId, + threadId: input.threadId, + runId, + nodeId: rootNode.id, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: + Math.max( + run.ordinal * 100, + ...projection.turnItems + .filter((item) => item.runId === run.id) + .map((item) => item.ordinal), + ) + 1, + type: "assistant_message", + messageId, + text, + streaming: false, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + { + ...base, + id: yield* idAllocator.allocate.event({ threadId: input.threadId }), + type: "run-attempt.updated", + payload: { ...attempt, status: "completed", completedAt: now }, + }, + { + ...base, + id: yield* idAllocator.allocate.event({ threadId: input.threadId }), + type: "node.updated", + payload: { ...rootNode, status: "completed", completedAt: now }, + }, + { + ...base, + id: yield* idAllocator.allocate.event({ threadId: input.threadId }), + type: "run.updated", + payload: { ...run, status: "completed", completedAt: now }, + }, + ]; + yield* eventSink.writeIfRunCurrent({ + threadId: input.threadId, + runId, + activeAttemptId: attempt.id, + expectedStatus: "starting", + events, + }); + return; + } const providerSessionId = providerThread.providerSessionId; const isCurrentAttemptInStatus = ( expectedStatus: OrchestrationV2Run["status"], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index b7e6d4272..c8a08f6db 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,4 @@ +import { ProviderAuthServiceLive } from "../provider/Layers/ProviderAuthService.ts"; import { agentBrowserAccessEnabled } from "./AgentBrowserAccessPolicy.ts"; import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; @@ -141,7 +142,12 @@ const runExecutionServiceProvided = runExecutionServiceLayer.pipe( ), ); +const providerAuthServiceProvided = ProviderAuthServiceLive.pipe( + Layer.provide(providerSessionManagerProvided), +); + const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( + Layer.provide(providerAuthServiceProvided), Layer.provide( Layer.mergeAll( attachmentMaterializationProvided, @@ -308,6 +314,7 @@ const threadTitleRegenerationWorkerProvided = threadTitleRegenerationWorkerLive. ); export const OrchestrationV2LayerLive = Layer.mergeAll( + providerAuthServiceProvided, orchestratorProvided, threadManagementProvided, effectWorkerProvided, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 94e1f9719..04e0e75f6 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -1,3 +1,4 @@ +import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ProviderDriverKind, ProviderReplayTranscript } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -320,6 +321,9 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( ), ); const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( + Layer.provide( + Layer.mock(ProviderAuthService)({ tryHandlePromptCommand: () => Effect.succeed(false) }), + ), Layer.provide( Layer.mergeAll( attachmentMaterializationProvided, diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts new file mode 100644 index 000000000..88961641b --- /dev/null +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -0,0 +1,554 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as AcpErrors from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; + +import { + makeAntigravityAuth, + type AntigravityAuth, + type AntigravityAuthRuntime, +} from "./AntigravityAuth.ts"; +import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-auth-test"); +const owner = "t3-auth-session-owner"; +const otherOwner = "t3-auth-session-other"; +const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=test-state"; +const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code"; + +const initialized = { + protocolVersion: 1, + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentCapabilities: { auth: { logout: {} } }, +} satisfies AcpSchema.InitializeResponse; +const started: AcpSessionRuntimeStartResult = { + sessionId: "native-session", + initializeResult: initialized, + sessionSetupResult: { + sessionId: "native-session", + models: { + currentModelId: "gemini-test", + availableModels: [{ modelId: "gemini-test", name: "Gemini test" }], + }, + }, + modelConfigId: "model", +}; + +const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], sessionId = owner) => + auth.controller.subscribe(sessionId).pipe( + Stream.filter((state) => state.phase === value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( + options: { + readonly interactive?: boolean; + readonly authorizationUrls?: ReadonlyArray; + readonly supportsLogout?: boolean; + readonly beforeInitialize?: Effect.Effect; + readonly forwardCallback?: Effect.Effect; + } = {}, +) { + const authenticated = yield* Deferred.make(); + const discovered = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const events: string[] = []; + let receiveAuthorizationUrl: + | ((url: string) => Effect.Effect) + | undefined; + let forwarded = 0; + let catalog = ["previous-account-model"]; + const auth = yield* makeAntigravityAuth({ + instanceId, + makeRuntime: (input) => + Effect.gen(function* () { + receiveAuthorizationUrl = input.onAuthorizationUrl; + events.push("process-open"); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + events.push("process-close"); + yield* Deferred.succeed(closed, undefined); + }), + ); + return { + initialize: () => + Effect.gen(function* () { + events.push("initialize"); + yield* options.beforeInitialize ?? Effect.void; + return options.supportsLogout === false + ? { ...initialized, agentCapabilities: {} } + : initialized; + }), + start: () => + Effect.gen(function* () { + events.push("authenticate"); + if (options.interactive !== false && input.onAuthorizationUrl) { + for (const url of options.authorizationUrls ?? [authorizationUrl]) { + yield* input.onAuthorizationUrl(url); + } + } + yield* Deferred.await(authenticated); + events.push("session-new"); + yield* Deferred.await(discovered); + return started; + }), + request: (method) => + Effect.sync(() => { + events.push(method); + return {}; + }), + } satisfies AntigravityAuthRuntime; + }), + onAuthenticated: () => + Effect.sync(() => { + catalog = ["gemini-test"]; + events.push("catalog-published"); + }), + onSignedOut: Effect.sync(() => { + catalog = []; + events.push("catalog-cleared"); + }), + forwardCallback: () => + options.forwardCallback ?? + Effect.sync(() => { + forwarded += 1; + }), + }); + return { + auth, + authenticated, + discovered, + closed, + events, + catalog: () => catalog, + forwarded: () => forwarded, + receiveAuthorizationUrl: (url: string) => + Effect.suspend(() => + receiveAuthorizationUrl + ? receiveAuthorizationUrl(url) + : Effect.die("Authorization URL receiver is not ready."), + ), + }; +}); + +it.layer(NodeServices.layer)("AntigravityAuth", (it) => { + it.effect("accepts the same authorization URL from stderr and stdout", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + authorizationUrls: [authorizationUrl, authorizationUrl], + }); + yield* harness.auth.controller.start(owner); + const waiting = yield* phase(harness.auth, "waiting"); + assert.equal(waiting.authorizationUrl, authorizationUrl); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + }), + ); + + it.effect("accepts a delayed duplicate after callback completion starts", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* harness.auth.controller.complete(owner, { + flowId: state.flowId!, + callbackUrl, + }); + + yield* harness.receiveAuthorizationUrl(authorizationUrl); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + }), + ); + + it.effect("rejects a different second authorization URL", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + authorizationUrls: [authorizationUrl, `${authorizationUrl}&scope=another-request`], + }); + yield* harness.auth.controller.start(owner); + + const failed = yield* phase(harness.auth, "failed"); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + + it.effect("keeps a remote flow private and waits for native auth and catalog discovery", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + assert.isNotNull(state.flowId); + const waiting = yield* phase(harness.auth, "waiting"); + assert.equal(waiting.authorizationUrl, authorizationUrl); + + const other = yield* phase(harness.auth, "waiting", otherOwner); + assert.isNull(other.authorizationUrl); + assert.isNull(other.flowId); + const stolen = yield* harness.auth.controller + .complete(otherOwner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(stolen)); + assert.equal(harness.forwarded(), 0); + + const verifying = yield* harness.auth.controller.complete(owner, { + flowId: state.flowId!, + callbackUrl, + }); + assert.equal(verifying.phase, "verifying"); + assert.equal(harness.forwarded(), 1); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + const succeeded = yield* phase(harness.auth, "succeeded"); + assert.deepEqual(harness.catalog(), ["gemini-test"]); + assert.isNull(succeeded.authorizationUrl); + assert.isNull(succeeded.expiresAt); + assert.equal(harness.events.at(-1), "process-close"); + }), + ); + + it.effect( + "distinguishes a post-authentication session failure without exposing its payload", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.fail( + harness.discovered, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Internal error ${callbackUrl}`, + method: "session/new", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Antigravity authenticated, but could not initialize a session or load models.", + ); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("does not call callback HTTP success a successful Google sign-in", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* harness.auth.controller.complete(owner, { flowId: state.flowId!, callbackUrl }); + yield* Deferred.fail( + harness.authenticated, + AcpErrors.AcpRequestError.internalError(`access_denied ${callbackUrl}`), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "not approved"); + assert.notInclude(failed.message ?? "", "test-code"); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("accepts direct local or cached completion without a callback RPC", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ interactive: false }); + yield* harness.auth.controller.start(owner); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + assert.equal(harness.forwarded(), 0); + assert.deepEqual(harness.catalog(), ["gemini-test"]); + }), + ); + + it.effect("rejects mismatched callbacks without sending any HTTP request", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + for (const invalidUrl of [ + callbackUrl.replace("51234", "51235"), + callbackUrl.replace("test-state", "wrong-state"), + callbackUrl.replace("/?", "/other?"), + `${callbackUrl}&state=test-state`, + ]) { + const result = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + } + assert.equal(harness.forwarded(), 0); + assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl); + yield* harness.auth.controller.cancel(owner, state.flowId!); + }), + ); + + it.effect("fails the flow when delivery fails after the requesting client disconnects", () => + Effect.gen(function* () { + const deliveryGate = yield* Deferred.make(); + const harness = yield* makeHarness({ + forwardCallback: Deferred.await(deliveryGate).pipe( + Effect.andThen( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "loopback refused", + }), + ), + ), + ), + }); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + // The client sends the callback, then its socket drops before Google answers. + const request = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.forkScoped); + yield* phase(harness.auth, "verifying"); + yield* Fiber.interrupt(request); + yield* Deferred.succeed(deliveryGate, undefined); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "Could not deliver"); + assert.isTrue(harness.events.includes("process-close")); + }), + ); + + it.effect("cancel closes the owned process without forwarding a denial", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + const wrongOwner = yield* harness.auth.controller + .cancel(otherOwner, state.flowId!) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(wrongOwner)); + const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!); + assert.equal(cancelled.phase, "cancelled"); + assert.isNull(cancelled.authorizationUrl); + assert.equal(harness.forwarded(), 0); + yield* Deferred.await(harness.closed); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + + it.effect("expires the flow at the official deadline and removes its URL", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* TestClock.adjust("300 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "expired"); + assert.isNull(failed.authorizationUrl); + yield* Deferred.await(harness.closed); + const late = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(late)); + assert.equal(harness.forwarded(), 0); + }), + ); + + it.effect("survives subscriber disconnect and does not replace a competing client's flow", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const first = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + const second = yield* harness.auth.controller.start(owner); + assert.equal(first.flowId, second.flowId); + const competing = yield* harness.auth.controller.start(otherOwner).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(competing)); + const resumed = yield* phase(harness.auth, "waiting"); + assert.equal(resumed.flowId, first.flowId); + assert.deepEqual(harness.events, ["process-open", "authenticate"]); + yield* harness.auth.controller.cancel(owner, first.flowId!); + }), + ); + + it.effect("sign-out closes admission and every process before fresh native logout", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const stop = Effect.gen(function* () { + harness.events.push("chat-close"); + yield* Scope.close(processScope, Exit.void); + }); + yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope)); + const stopSessions = Effect.gen(function* () { + harness.events.push("sessions-stop"); + const denied = yield* harness.auth + .withProcess( + Effect.void, + Effect.sync(() => harness.events.push("late-process")), + ) + .pipe(Effect.scoped, Effect.exit); + assert.isTrue(Exit.isFailure(denied)); + }); + const result = yield* harness.auth.controller.logout(stopSessions); + assert.equal(result.phase, "idle"); + assert.deepEqual(harness.events, [ + "sessions-stop", + "chat-close", + "process-open", + "initialize", + "logout", + "catalog-cleared", + "process-close", + ]); + assert.deepEqual(harness.catalog(), []); + yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); + }), + ); + + it.effect("signs out after a slow packaged runtime starts", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const initialized = yield* Deferred.make(); + const harness = yield* makeHarness({ + beforeInitialize: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(initialized)), + ), + }); + const logout = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + yield* TestClock.adjust("47 seconds"); + yield* Deferred.succeed(initialized, undefined); + assert.equal((yield* Fiber.join(logout)).phase, "idle"); + assert.include(harness.events, "logout"); + assert.deepEqual(harness.catalog(), []); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("closes a stalled sign-out process without clearing its account catalog", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const harness = yield* makeHarness({ + beforeInitialize: Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + }); + const logout = yield* harness.auth.controller + .logout(Effect.void) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(entered); + yield* TestClock.adjust("90 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(logout))); + yield* Deferred.await(harness.closed); + assert.notInclude(harness.events, "logout"); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); + }), + ); + + it.effect( + "sign-out interrupts startup without interrupting its caller after startup returns", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const entering = yield* Deferred.make(); + const continueStartup = yield* Deferred.make(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const task = Effect.gen(function* () { + yield* Deferred.succeed(entering, undefined); + yield* Deferred.await(continueStartup); + harness.events.push("late-spawn"); + }); + const startup = yield* harness.auth + .withProcess(Scope.close(processScope, Exit.void), task) + .pipe(Scope.provide(processScope), Effect.forkScoped); + yield* Deferred.await(entering); + yield* harness.auth.controller.logout(Effect.void); + yield* Deferred.succeed(continueStartup, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.await(startup))); + assert.notInclude(harness.events, "late-spawn"); + assert.include(harness.events, "logout"); + }), + ); + + it.effect("failed session stopping still closes owned processes and skips native logout", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const stop = Effect.gen(function* () { + harness.events.push("chat-close"); + yield* Scope.close(processScope, Exit.void); + }); + yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope)); + const result = yield* harness.auth.controller + .logout( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Stop failed.", + }), + ), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(harness.events, ["chat-close"]); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + + it.effect("finishes sign-out when the requesting client disconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const stopping = yield* Deferred.make(); + const continueStop = yield* Deferred.make(); + const request = yield* harness.auth.controller + .logout( + Effect.gen(function* () { + yield* Deferred.succeed(stopping, undefined); + yield* Deferred.await(continueStop); + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(stopping); + yield* Fiber.interrupt(request); + yield* Deferred.succeed(continueStop, undefined); + const result = yield* harness.auth.controller.subscribe(owner).pipe( + Stream.filter((state) => state.message === "Signed out of Google."), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(result.phase, "idle"); + assert.deepEqual(harness.catalog(), []); + assert.include(harness.events, "logout"); + }), + ); + + it.effect("does not call logout unless the official process advertises it", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ supportsLogout: false }); + const result = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(harness.events, ["process-open", "initialize", "process-close"]); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); +}); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts new file mode 100644 index 000000000..170e5c32f --- /dev/null +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -0,0 +1,542 @@ +import { + ProviderSetupError, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as AcpErrors from "effect-acp/errors"; + +import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import { + parseAntigravityAuthorizationUrl, + type AntigravityAuthorizationUrl, +} from "./antigravityAuthSupport.ts"; +import { + forwardAntigravityCallback, + validateAntigravityCallbackUrl, +} from "./antigravityCallback.ts"; +import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; + +const AUTH_TIMEOUT_MS = 300_000; +const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; +const isSetupError = Schema.is(ProviderSetupError); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); + +interface AuthSnapshot { + readonly ownerSessionId: string | null; + readonly state: ProviderAuthState; +} + +interface AuthFlow { + readonly id: string; + readonly ownerSessionId: string; + readonly expiresAtMillis: number; + state: ProviderAuthState; + pending: AntigravityAuthorizationUrl | undefined; + callbackSent: boolean; + fiber: Fiber.Fiber | undefined; + forwarding: Fiber.Fiber | undefined; +} + +interface OwnedProcess { + readonly stop: Effect.Effect; + startup: Fiber.Fiber | undefined; +} + +export interface AntigravityAuth { + readonly controller: ProviderAuthController; + /** Tracks startup and the process scope so sign-out cannot leave cached credentials in memory. */ + readonly withProcess: ( + stop: Effect.Effect, + task: Effect.Effect, + ) => Effect.Effect; +} + +export type AntigravityAuthRuntime = Pick< + AcpSessionRuntime["Service"], + "initialize" | "start" | "request" +>; + +export interface AntigravityAuthOptions< + Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"], +> { + readonly instanceId: ProviderInstanceId; + readonly makeRuntime: (input: { + readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + }) => Effect.Effect; + readonly onAuthenticated: ( + result: AcpSessionRuntimeStartResult, + runtime: Runtime, + ) => Effect.Effect; + readonly onSignedOut: Effect.Effect; + readonly forwardCallback?: (callback: URL) => Effect.Effect; + /** False for API key methods, which authenticate without a Google sign-in page. */ + readonly usesBrowser?: boolean; +} + +function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): ProviderAuthState { + if (snapshot.ownerSessionId === null || snapshot.ownerSessionId === ownerSessionId) { + return snapshot.state; + } + const busy = ["starting", "waiting", "verifying"].includes(snapshot.state.phase); + return { + ...snapshot.state, + flowId: null, + authorizationUrl: null, + expiresAt: null, + ...(busy ? { message: "Sign-in is in progress in another client." } : {}), + }; +} + +function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): string { + const error = Cause.findErrorOption(cause); + if (Option.isSome(error)) { + if (isSetupError(error.value)) { + return error.value.detail; + } + if (isAcpRequestError(error.value)) { + if (error.value.errorMessage.includes("SUBSCRIPTION_REQUIRED")) { + return "Google requires an eligible Antigravity subscription for this account."; + } + if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { + return "Google sign-in was not approved. Start sign-in again."; + } + if (error.value.method === "session/new" && error.value.code === -32603) { + return "Antigravity authenticated, but could not initialize a session or load models."; + } + if (!usesBrowser && error.value.code === -32602) { + return "Antigravity rejected the configured credentials. Check the provider settings."; + } + } + } + return usesBrowser + ? "Google sign-in failed. Start sign-in again." + : "Antigravity could not authenticate with the configured credentials."; +} + +/** Owns one instance's explicit sign-in and all process admission around sign-out. */ +export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < + Runtime extends AntigravityAuthRuntime, +>( + options: AntigravityAuthOptions, +): Effect.fn.Return { + const crypto = yield* Crypto.Crypto; + const instanceScope = yield* Scope.Scope; + const usesBrowser = options.usesBrowser ?? true; + const lock = yield* Semaphore.make(1); + const closed = yield* Deferred.make(); + const emptyState: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + }; + const snapshot = yield* SubscriptionRef.make({ + ownerSessionId: null, + state: emptyState, + }); + const processes = new Set(); + let activeFlow: AuthFlow | undefined; + let operation: "idle" | "auth" | "logout" | "cancel" | "closed" = "idle"; + + const setupError = (name: string, detail: string) => + new ProviderSetupError({ instanceId: options.instanceId, operation: name, detail }); + const currentState = (ownerSessionId: string) => + SubscriptionRef.get(snapshot).pipe( + Effect.map((value) => visibleSnapshot(value, ownerSessionId)), + ); + const publishFlow = (flow: AuthFlow, state: ProviderAuthState) => { + flow.state = state; + return SubscriptionRef.set(snapshot, { ownerSessionId: flow.ownerSessionId, state }); + }; + const stopOwnedProcesses = Effect.suspend(() => + Effect.forEach( + Array.from(processes), + (owned) => + Effect.gen(function* () { + if (owned.startup) { + yield* Fiber.interrupt(owned.startup); + } + yield* owned.stop; + }), + { discard: true, concurrency: "unbounded" }, + ), + ); + + const withProcess: AntigravityAuth["withProcess"] = (stop, task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const owned: OwnedProcess = { stop, startup: undefined }; + const fiber = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (operation !== "idle") { + return yield* setupError( + "startProcess", + "Antigravity sign-in or sign-out is in progress. Try again after it finishes.", + ); + } + processes.add(owned); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + processes.delete(owned); + }), + ); + const child = yield* restore(task).pipe(Effect.forkIn(scope)); + owned.startup = child; + return child; + }), + ); + // Propagate interruption after the exit wait so concurrent stop waiters stay attached. + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.ensuring(Fiber.interrupt(fiber)), + Effect.ensuring( + Effect.sync(() => { + owned.startup = undefined; + }), + ), + ); + }), + ); + + const finishFlow = (flow: AuthFlow, result: Exit.Exit) => + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return; + activeFlow = undefined; + operation = "idle"; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? usesBrowser + ? "Signed in with Google." + : "Connected to Antigravity." + : safeAuthFailure(result.cause, usesBrowser), + }); + }), + ); + + const receiveAuthorizationUrl = (flow: AuthFlow, url: string) => + parseAntigravityAuthorizationUrl(url).pipe( + Effect.flatMap((authorization) => + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow || operation !== "auth") return; + if (flow.pending) { + if (flow.pending.authorizationUrl === authorization.authorizationUrl) return; + return yield* new AcpErrors.AcpTransportError({ + detail: "Antigravity started more than one Google sign-in request.", + cause: undefined, + }); + } + flow.pending = authorization; + yield* publishFlow(flow, { + ...flow.state, + phase: "waiting", + authorizationUrl: authorization.authorizationUrl, + message: + "Open the Google sign-in link. If you are remote, paste the redirect URL here.", + }); + }), + ), + ), + ); + + const runSignIn = (flow: AuthFlow, stopSessions: Effect.Effect) => + Effect.gen(function* () { + yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); + const runtime = yield* options.makeRuntime({ + onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), + }); + const started = yield* runtime.start(); + yield* lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase: "verifying", + authorizationUrl: null, + message: "Checking Antigravity access and models.", + }); + }), + ); + yield* options.onAuthenticated(started, runtime); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + }), + Effect.exit, + Effect.flatMap((result) => finishFlow(flow, result)), + ); + + const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) => + Effect.uninterruptible( + Effect.gen(function* () { + const detached = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return false; + activeFlow = undefined; + operation = "cancel"; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase, + authorizationUrl: null, + expiresAt: null, + message, + }); + return true; + }), + ); + if (!detached) return; + if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + yield* lock.withPermits(1)( + Effect.sync(() => { + if (operation === "cancel") operation = "idle"; + }), + ); + }), + ); + + const requireFlow = (ownerSessionId: string, flowId: string, name: string) => + Effect.gen(function* () { + const flow = activeFlow; + if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { + return yield* setupError(name, "This sign-in is no longer active in this client."); + } + const now = yield* Clock.currentTimeMillis; + if (now >= flow.expiresAtMillis) { + return yield* setupError(name, "Google sign-in expired. Start sign-in again."); + } + return flow; + }); + + const controller: ProviderAuthController = { + start: (ownerSessionId, stopSessions = Effect.void) => + lock.withPermits(1)( + Effect.uninterruptible( + Effect.gen(function* () { + if (activeFlow?.ownerSessionId === ownerSessionId && operation === "auth") { + return activeFlow.state; + } + if (operation !== "idle") { + return yield* setupError("start", "Antigravity setup is already in progress."); + } + const flowId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => + setupError("start", "Could not start Google sign-in. Try again."), + ), + ); + const expiresAtMillis = (yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS; + const state: ProviderAuthState = { + ...emptyState, + phase: "starting", + flowId, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)), + message: usesBrowser ? "Starting Google sign-in." : "Checking credentials.", + }; + const flow: AuthFlow = { + id: flowId, + ownerSessionId, + expiresAtMillis, + state, + pending: undefined, + callbackSent: false, + fiber: undefined, + forwarding: undefined, + }; + activeFlow = flow; + operation = "auth"; + yield* publishFlow(flow, state); + flow.fiber = yield* runSignIn(flow, stopSessions).pipe( + Effect.interruptible, + Effect.forkIn(instanceScope), + ); + return state; + }), + ), + ), + complete: Effect.fn("AntigravityAuth.complete")(function* (ownerSessionId, input) { + const pending = yield* lock.withPermits(1)( + Effect.gen(function* () { + const flow = yield* requireFlow(ownerSessionId, input.flowId, "complete"); + if (!flow.pending || flow.callbackSent) { + return yield* setupError( + "complete", + flow.callbackSent + ? "The sign-in response was already sent. Wait for Google to finish." + : "Wait for the Google sign-in link before you send a redirect URL.", + ); + } + const callback = yield* validateAntigravityCallbackUrl( + options.instanceId, + flow.pending, + input.callbackUrl, + ); + flow.callbackSent = true; + yield* publishFlow(flow, { + ...flow.state, + phase: "verifying", + authorizationUrl: null, + message: "Waiting for Google to finish sign-in.", + }); + // The instance owns delivery and its failure handling. The RPC that + // sent the callback may disconnect before Google answers, and the + // flow must still settle instead of sitting at "verifying" until + // the deadline. + const forwarding = yield* ( + options.forwardCallback?.(callback) ?? + forwardAntigravityCallback(options.instanceId, callback) + ).pipe( + // stopFlow interrupts this fiber, so it runs from a sibling fiber. + Effect.tapError(() => + stopFlow(flow, "failed", FORWARDING_FAILED_MESSAGE).pipe( + Effect.forkIn(instanceScope), + ), + ), + Effect.interruptible, + Effect.forkIn(instanceScope), + ); + flow.forwarding = forwarding; + return { flow, forwarding }; + }), + ); + const forwarded = yield* Fiber.await(pending.forwarding); + if (Exit.isFailure(forwarded)) { + return yield* setupError("complete", FORWARDING_FAILED_MESSAGE); + } + return pending.flow.state; + }), + cancel: Effect.fn("AntigravityAuth.cancel")(function* (ownerSessionId, flowId) { + const flow = yield* lock.withPermits(1)(requireFlow(ownerSessionId, flowId, "cancel")); + yield* stopFlow(flow, "cancelled", "Google sign-in was cancelled."); + return flow.state; + }), + logout: Effect.fn("AntigravityAuth.logout")(function* (stopSessions) { + const task = Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const flow = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") { + return yield* setupError("logout", "Antigravity setup is already stopping."); + } + operation = "logout"; + const currentFlow = activeFlow; + activeFlow = undefined; + if (currentFlow) { + currentFlow.pending = undefined; + yield* publishFlow(currentFlow, { + ...currentFlow.state, + phase: "cancelled", + authorizationUrl: null, + expiresAt: null, + message: "Google sign-in was cancelled by sign-out.", + }); + } + return currentFlow; + }), + ); + const stopRemaining = Effect.gen(function* () { + if (flow?.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopOwnedProcesses; + }); + const result = yield* restore( + Effect.gen(function* () { + yield* stopSessions.pipe(Effect.ensuring(stopRemaining)); + const runtime = yield* options.makeRuntime({}); + const initialized = yield* runtime.initialize(); + if (!initialized.agentCapabilities?.auth?.logout) { + return yield* setupError( + "logout", + "This Antigravity version does not support sign-out. Update the provider.", + ); + } + yield* runtime.request("logout", {}); + yield* options.onSignedOut; + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => Effect.fail(setupError("logout", "Antigravity sign-out timed out.")), + }), + ), + ).pipe(Effect.exit); + yield* lock.withPermits(1)( + Effect.gen(function* () { + operation = "idle"; + yield* SubscriptionRef.set(snapshot, { + ownerSessionId: null, + state: { + ...emptyState, + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) + ? "Signed out of Google." + : "Antigravity sign-out failed. Try again.", + }, + }); + }), + ); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + return yield* Option.isSome(failure) && isSetupError(failure.value) + ? failure.value + : setupError("logout", "Antigravity sign-out failed. Try again."); + } + return yield* currentState(""); + }), + ); + const worker = yield* task.pipe(Effect.forkIn(instanceScope)); + return yield* Fiber.await(worker).pipe(Effect.flatMap((result) => result)); + }), + subscribe: (ownerSessionId) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((value) => visibleSnapshot(value, ownerSessionId)), + Stream.interruptWhen(Deferred.await(closed)), + ), + isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", + }; + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = activeFlow; + activeFlow = undefined; + if (flow) { + flow.pending = undefined; + if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + } + yield* stopOwnedProcesses; + yield* Deferred.succeed(closed, undefined); + }), + ); + + return { controller, withProcess }; +}); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts new file mode 100644 index 000000000..ba7391bb9 --- /dev/null +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -0,0 +1,922 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeCrypto from "node:crypto"; + +import { + makeAntigravityInstallation, + type AntigravityExecutable, + type AntigravityInstallation, + type AntigravityInstallationOptions, +} from "./AntigravityInstallation.ts"; +import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; +import type { AntigravityReleaseAsset } from "./antigravityRelease.ts"; + +const serverContents = "antigravity runtime\n"; +const harnessContents = "local harness\n"; +const previousReleaseId = "1".repeat(64); +const previousVersion = "fixture-old"; +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +// Small ZIPs made with Python's zipfile module. The unsafe entries are intentional. +const zipFixtures = { + complete: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA", + missingHarness: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YEAAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAABAAEAQAAAAEQAAAAAAA==", + duplicate: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAABhZ3lfYWNwX3NlcnZlci5wYXJLzCvJTC9KLMssqVQoKgVyclO5AFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YFEAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAACAAIAgAAAAIgAAAAAAA==", + traversal: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAAVAAAALi5cYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABUAAAAAAAAAAAAAAO2BAAAAAC4uXGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUcAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCGAAAAigAAAAAA", + symlink: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAAD/oUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA", + oversizedMember: + "UEsDBBQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuSoAUEsDBBQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsy8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAFQAAAAAAAAAAAAAA7YFFAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsUEsFBgAAAAACAAIAgwAAAIgAAAAAAA==", + windows: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", +}; + +// The installation checks POSIX exec bits off the real filesystem unless the +// platform is win32, so a linux platform mock cannot pass on NTFS. Default to +// the host and let the fixture names follow; the suite is about install +// mechanics, which are the same on every platform. +const hostPlatform: NodeJS.Platform = + HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; +const completeArchive = Buffer.from( + hostPlatform === "win32" ? zipFixtures.windows : zipFixtures.complete, + "base64", +); +const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par"; +const harnessName = + hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external"; + +function releaseAsset( + archive: Uint8Array = completeArchive, + platform: NodeJS.Platform = hostPlatform, +) { + return { + version: "fixture-new", + url: "https://dl.google.com/antigravity-test.zip", + sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"), + archiveBytes: archive.byteLength, + executable: { + name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par", + bytes: Buffer.byteLength(serverContents), + }, + harness: { + name: platform === "win32" ? "localharness_external.exe" : "localharness_external", + bytes: Buffer.byteLength(harnessContents), + }, + } satisfies AntigravityReleaseAsset; +} + +const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* ( + managedDirectory: string, + asset: AntigravityReleaseAsset, + active = true, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join(managedDirectory, "versions", asset.sha256); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(path.join(directory, asset.executable.name), serverContents, { + mode: 0o755, + }); + yield* fs.writeFileString(path.join(directory, asset.harness.name), harnessContents, { + mode: 0o755, + }); + yield* fs.writeFileString( + path.join(directory, ".install-complete.json"), + encodeJsonString({ + releaseId: asset.sha256, + version: asset.version, + executable: asset.executable, + harness: asset.harness, + }), + ); + if (active) { + yield* fs.writeFileString( + path.join(managedDirectory, "active.json"), + encodeJsonString({ releaseId: asset.sha256 }), + ); + } +}); + +interface HarnessOptions { + readonly baseDir?: string; + readonly asset?: AntigravityReleaseAsset | null; + readonly archive?: Buffer; + readonly body?: Stream.Stream | undefined; + readonly contentLength?: number; + readonly contentEncoding?: string; + readonly platform?: NodeJS.Platform; + readonly path?: string; + readonly previous?: boolean; + readonly fileSystem?: FileSystem.FileSystem; + readonly validate?: AntigravityInstallationOptions["validate"]; + readonly useDefaultValidation?: boolean; +} + +const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( + options: HarnessOptions = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = + options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" })); + const platform = options.platform ?? hostPlatform; + const archive = options.archive ?? completeArchive; + const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset; + const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`); + if (options.previous) { + yield* writeRelease(managedDirectory, { + ...releaseAsset(archive, platform), + sha256: previousReleaseId, + version: previousVersion, + }); + } + const stagingReleased = yield* Deferred.make(); + const requests: string[] = []; + const validations: Array<{ executable: AntigravityExecutable; version: string }> = []; + const installationFs = options.fileSystem ?? fs; + const trackedFs = FileSystem.FileSystem.of({ + ...installationFs, + makeTempDirectoryScoped: (settings) => + settings?.prefix === ".install-" + ? Effect.acquireRelease(installationFs.makeTempDirectory(settings), (directory) => + fs + .remove(directory, { recursive: true, force: true }) + .pipe(Effect.orDie, Effect.andThen(Deferred.succeed(stagingReleased, undefined))), + ) + : installationFs.makeTempDirectoryScoped(settings), + }); + const installation = yield* makeAntigravityInstallation({ + baseDir, + releaseAsset: asset, + ...(options.useDefaultValidation + ? {} + : { + validate: (executable: AntigravityExecutable, version: string) => + Effect.sync(() => validations.push({ executable, version })).pipe( + Effect.andThen(options.validate?.(executable, version) ?? Effect.void), + ), + }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, trackedFs), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcessEnvironment, { PATH: options.path ?? "" }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push(request.url); + const response = HttpClientResponse.fromWeb( + request, + new Response(null, { + headers: { + ...(options.contentLength === undefined + ? {} + : { "content-length": String(options.contentLength) }), + ...(options.contentEncoding === undefined + ? {} + : { "content-encoding": options.contentEncoding }), + }, + }), + ); + return Object.defineProperty(response, "stream", { + value: + options.body ?? + Stream.make( + archive.subarray(0, 31), + archive.subarray(31, 149), + archive.subarray(149), + ), + }); + }), + ), + ), + ); + return { installation, fs, path, baseDir, requests, validations, stagingReleased }; +}); + +const terminalState = (installation: AntigravityInstallation["Service"]) => + installation.changes.pipe( + Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")(function* ( + installation: AntigravityInstallation["Service"], +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = yield* installation.resolve(); + expect(resolved.version).toBe(previousVersion); + expect(resolved.managedVersionDirectory).toBe( + path.join(installation.managedDirectory, "versions", previousReleaseId), + ); + expect(yield* fs.readFileString(resolved.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(resolved.harnessPath)).toBe(harnessContents); + expect((yield* installation.state).installedVersion).toBe(previousVersion); +}); + +it.layer(NodeServices.layer)("Antigravity installation", (it) => { + it.effect("verifies both files before activating a streamed download", () => + Effect.gen(function* () { + const enteredValidation = yield* Deferred.make(); + const finishValidation = yield* Deferred.make(); + const { installation, fs, path, validations, requests, stagingReleased } = yield* makeHarness( + { + previous: true, + validate: () => + Deferred.succeed(enteredValidation, undefined).pipe( + Effect.andThen(Deferred.await(finishValidation)), + ), + }, + ); + const initial = yield* installation.changes.pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(initial).toMatchObject({ phase: "idle", installedVersion: previousVersion }); + const started = yield* installation.start; + expect(started).toMatchObject({ phase: "downloading", downloadedBytes: 0 }); + yield* Deferred.await(enteredValidation); + yield* expectPreviousRelease(installation); + const validation = validations[0]; + expect(validation?.version).toBe("fixture-new"); + if (!validation) return yield* Effect.die("Expected runtime validation."); + expect(yield* fs.readFileString(validation.executable.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(validation.executable.harnessPath)).toBe(harnessContents); + + yield* Deferred.succeed(finishValidation, undefined); + expect(yield* terminalState(installation)).toMatchObject({ + phase: "succeeded", + operationId: started.operationId, + downloadedBytes: completeArchive.byteLength, + installedVersion: "fixture-new", + }); + yield* Deferred.await(stagingReleased); + const selected = yield* installation.resolve(); + expect(selected).toMatchObject({ source: "managed", version: "fixture-new" }); + expect(path.dirname(selected.harnessPath)).toBe(path.dirname(selected.executablePath)); + expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents); + expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual( + expect.arrayContaining([previousReleaseId, releaseAsset().sha256]), + ); + expect(requests).toEqual([releaseAsset().url]); + }), + ); + + it.effect.each([ + { + name: "the expected release", + agentName: "antigravity-acp", + version: "fixture-new", + valid: true, + }, + { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false }, + { + name: "a different version", + agentName: "antigravity-acp", + version: "other-version", + valid: false, + }, + ])("validates $name with initialize only and removes the disposable profile", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const encoder = new TextEncoder(); + const decodeRequest = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Union([Schema.String, Schema.Number]), + method: Schema.String, + }), + ), + ); + const methods: string[] = []; + const profiles = new Set(); + let closedRuntimes = 0; + const spawner = ChildProcessSpawner.make( + Effect.fn("test.spawnAntigravityValidator")(function* (command) { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("Expected one validation process."); + } + const profile = command.options.env?.GEMINI_HOME; + if (!profile) return yield* Effect.die("Expected a disposable validation profile."); + profiles.add(profile); + const helper = command.args[0] === "-e"; + const output = yield* Queue.unbounded(); + const exited = yield* Deferred.make(); + const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.asVoid, + ); + yield* Effect.addFinalizer(() => + terminate.pipe( + Effect.andThen(Queue.shutdown(output)), + Effect.andThen( + Effect.sync(() => { + if (!helper) closedRuntimes += 1; + }), + ), + ), + ); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2), + exitCode: helper + ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) + : Deferred.await(exited), + isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), + kill: () => terminate, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((bytes: Uint8Array) => + Effect.gen(function* () { + const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe( + Effect.orDie, + ); + methods.push(request.method); + yield* Queue.offer( + output, + encoder.encode( + `${encodeJsonString({ + jsonrpc: "2.0", + id: request.id, + ...(request.method === "initialize" + ? { + result: { + protocolVersion: 1, + agentInfo: { name: testCase.agentName, version: testCase.version }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { resume: {} }, + auth: { logout: {} }, + }, + authMethods: [{ id: "oauth-personal", name: "Google" }], + }, + } + : { + error: { + code: -32601, + message: "Validation must not sign in or create sessions.", + }, + }), + })}\n`, + ), + ); + }), + ), + stdout: helper ? Stream.empty : Stream.fromQueue(output), + stderr: helper + ? Stream.make( + encoder.encode( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`, + ), + ) + : Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const { installation, stagingReleased } = yield* makeHarness({ + previous: true, + useDefaultValidation: true, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe( + testCase.valid ? "succeeded" : "failed", + ); + yield* Deferred.await(stagingReleased); + expect(methods).toEqual(["initialize"]); + expect(closedRuntimes).toBe(1); + expect(profiles.size).toBe(1); + for (const profile of profiles) { + expect(yield* fs.exists(profile)).toBe(false); + } + if (testCase.valid) { + expect((yield* installation.resolve()).version).toBe("fixture-new"); + } else { + yield* expectPreviousRelease(installation); + } + }), + ); + + it.effect("accepts an encoded Content-Length when the body is compressed", () => + Effect.gen(function* () { + // dl.google.com gzips the archive and reports the encoded size. The decoded + // stream is still checked byte for byte and by hash. + const { installation, validations } = yield* makeHarness({ + contentLength: completeArchive.byteLength - 1_000, + contentEncoding: "gzip", + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("succeeded"); + expect(validations).toHaveLength(1); + }), + ); + + it.effect.each([ + { name: "checksum mismatch", asset: { ...releaseAsset(), sha256: "2".repeat(64) } }, + { name: "short download", archive: completeArchive.subarray(0, -1), asset: releaseAsset() }, + { + name: "oversized download", + archive: Buffer.concat([completeArchive, Buffer.from("extra")]), + asset: releaseAsset(), + }, + { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 }, + { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") }, + { name: "duplicate executable", archive: Buffer.from(zipFixtures.duplicate, "base64") }, + { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") }, + { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") }, + { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") }, + ])("rejects $name before runtime validation", (options) => + Effect.gen(function* () { + const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({ + ...options, + previous: true, + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("failed"); + expect(state.message).toBeTruthy(); + expect(validations).toEqual([]); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual( + [previousReleaseId], + ); + }), + ); + + it.effect.each(["download", "extract", "active pointer"] as const)( + "preserves the old runtime after an ENOSPC error during %s", + (stage) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const noSpace = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "write", + description: "ENOSPC: no space left on device", + }); + const fileSystem = FileSystem.FileSystem.of({ + ...fs, + sink: (target, options) => + (stage === "download" && target.endsWith("download.zip")) || + (stage === "extract" && target.endsWith(executableName)) + ? fs.sink(target, options).pipe(Sink.mapInputEffect(() => Effect.fail(noSpace))) + : fs.sink(target, options), + writeFileString: (target, content, options) => + stage === "active pointer" && target.endsWith("contents.tmp") + ? Effect.fail(noSpace) + : fs.writeFileString(target, content, options), + }); + const { installation, stagingReleased, path } = yield* makeHarness({ + previous: true, + fileSystem, + }); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("failed"); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect( + (yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).some( + (name) => name.startsWith(".install-"), + ), + ).toBe(false); + expect(yield* fs.readDirectory(installation.managedDirectory)).toEqual([ + "active.json", + "versions", + ]); + }), + ); + + it.effect.each(["downloading", "extracting", "verifying"] as const)( + "cancels during %s and waits for open resources to close", + (phase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const barrier = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ); + const { installation, stagingReleased, path, validations } = yield* makeHarness({ + previous: true, + body: + phase === "downloading" + ? Stream.concat( + Stream.make(completeArchive.subarray(0, 31)), + Stream.fromEffect(barrier.pipe(Effect.as(completeArchive.subarray(31)))), + ) + : undefined, + validate: phase === "verifying" ? () => barrier : undefined, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + sink: (target, options) => + phase === "extracting" && target.endsWith(executableName) + ? fs + .sink(target, options) + .pipe( + Sink.mapInputEffect((chunk: Uint8Array) => barrier.pipe(Effect.as(chunk))), + ) + : fs.sink(target, options), + }), + }); + const started = yield* installation.start; + yield* Deferred.await(entered); + expect((yield* installation.state).phase).toBe(phase); + expect( + (yield* installation.cancel(started.operationId ?? "missing-operation-id")).phase, + ).toBe("cancelled"); + yield* Deferred.await(interrupted); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect(validations).toHaveLength(phase === "verifying" ? 1 : 0); + expect( + yield* fs.readDirectory(path.join(installation.managedDirectory, "versions")), + ).toEqual([previousReleaseId]); + }), + ); + + it.effect.each([ + { name: "the committed install", restart: false }, + { name: "a newer install", restart: true }, + ])("does not fail $name when old pointer cleanup fails", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + const nextValidationStarted = yield* Deferred.make(); + const releaseNextValidation = yield* Deferred.make(); + const firstWorker = yield* Deferred.make>(); + const cleanupError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "EPERM: pointer temp directory is in use", + }); + let firstPointer = true; + let validationCount = 0; + const { installation, stagingReleased } = yield* makeHarness({ + previous: true, + validate: () => { + validationCount += 1; + return validationCount === 2 + ? Deferred.succeed(nextValidationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseNextValidation)), + ) + : Effect.void; + }, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + makeTempDirectoryScoped: (settings) => { + if (settings?.prefix !== "active.json." || !firstPointer) { + return fs.makeTempDirectoryScoped(settings); + } + firstPointer = false; + return Effect.fiber.pipe( + Effect.tap((worker) => Deferred.succeed(firstWorker, worker)), + Effect.andThen( + Effect.acquireRelease(fs.makeTempDirectory(settings), () => + Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + Effect.andThen(Effect.die(cleanupError)), + ), + ), + ), + ); + }, + }), + }); + yield* Effect.gen(function* () { + const first = yield* installation.start; + yield* Deferred.await(cleanupStarted); + expect(yield* installation.state).toMatchObject({ + operationId: first.operationId, + phase: "succeeded", + installedVersion: "fixture-new", + }); + const current = testCase.restart ? yield* installation.start : first; + if (testCase.restart) yield* Deferred.await(nextValidationStarted); + + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Fiber.await(yield* Deferred.await(firstWorker)); + yield* Deferred.await(stagingReleased); + expect(yield* installation.state).toMatchObject({ + operationId: current.operationId, + phase: testCase.restart ? "verifying" : "succeeded", + installedVersion: "fixture-new", + }); + expect((yield* installation.resolve()).version).toBe("fixture-new"); + + yield* Deferred.succeed(releaseNextValidation, undefined); + expect(yield* terminalState(installation)).toMatchObject({ + operationId: current.operationId, + phase: "succeeded", + installedVersion: "fixture-new", + }); + }).pipe( + Effect.ensuring( + Deferred.succeed(releaseCleanup, undefined).pipe( + Effect.andThen(Deferred.succeed(releaseNextValidation, undefined)), + ), + ), + ); + }), + ); + + it.effect( + "shares one install across callers and keeps it alive after the caller scope closes", + () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const { installation, requests, stagingReleased } = yield* makeHarness({ + body: Stream.fromEffect( + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(completeArchive), + ), + ), + }); + const callerScope = yield* Scope.make(); + const started = yield* installation.start.pipe(Scope.provide(callerScope)); + yield* Deferred.await(entered); + yield* Scope.close(callerScope, Exit.void); + const concurrent = yield* Effect.all([installation.start, installation.start], { + concurrency: "unbounded", + }); + expect(concurrent.map((state) => state.operationId)).toEqual([ + started.operationId, + started.operationId, + ]); + expect( + yield* installation.changes.pipe(Stream.runHead, Effect.map(Option.getOrThrow)), + ).toMatchObject({ phase: "downloading", operationId: started.operationId }); + yield* Deferred.succeed(release, undefined); + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + yield* Deferred.await(stagingReleased); + + const next = yield* installation.start; + expect(next.operationId).not.toBe(started.operationId); + expect( + yield* installation + .cancel(started.operationId ?? "missing-operation-id") + .pipe(Effect.flip), + ).toMatchObject({ operation: "cancel" }); + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + expect(requests).toHaveLength(1); + }), + ); + + // Real posix executables in a real temp dir, resolved by a linux-mocked + // PATH walk; a Windows temp path cannot be split on `:`. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "honors explicit paths and reports invalid overrides without falling back", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const externalDirectory = path.join(baseDir, "external"); + const externalExecutable = path.join(externalDirectory, executableName); + const externalHarness = path.join(externalDirectory, harnessName); + yield* fs.makeDirectory(externalDirectory); + yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + const { installation } = yield* makeHarness({ + baseDir, + path: externalDirectory, + previous: true, + }); + yield* expectPreviousRelease(installation); + expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({ + source: "managed", + version: previousVersion, + }); + expect(yield* installation.resolve(externalExecutable)).toMatchObject({ + executablePath: externalExecutable, + source: "override", + managedVersionDirectory: null, + }); + expect(yield* installation.resolve(executableName)).toMatchObject({ + source: "override", + }); + yield* fs.remove(externalHarness); + expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip), + ).toMatchObject({ + operation: "resolve", + }); + yield* expectPreviousRelease(installation); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + yield* installation.remove(); + expect(yield* installation.resolve()).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + const isolated = yield* makeHarness({ baseDir }); + expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }), + ).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + expect( + yield* isolated.installation.resolve(executableName, { PATH: externalDirectory }), + ).toMatchObject({ source: "override", executablePath: externalExecutable }); + }), + ); + + it.effect("keeps leased releases available while new sessions resolve the new release", () => + Effect.gen(function* () { + const { installation, fs, stagingReleased } = yield* makeHarness({ previous: true }); + const processScope = yield* Scope.make(); + const oldExecutable = yield* installation.acquire().pipe(Scope.provide(processScope)); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + yield* Deferred.await(stagingReleased); + const current = yield* installation.resolve(); + expect(current.version).toBe("fixture-new"); + expect(current.executablePath).not.toBe(oldExecutable.executablePath); + expect(yield* fs.readFileString(oldExecutable.executablePath)).toBe(serverContents); + expect(yield* installation.remove().pipe(Effect.flip)).toMatchObject({ operation: "remove" }); + yield* Scope.close(processScope, Exit.void); + yield* installation.remove(); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + }), + ); + + it.effect( + "removes an incomplete active release before reinstalling without a PATH fallback", + () => + Effect.gen(function* () { + const { installation, baseDir, fs, path } = yield* makeHarness({ previous: true }); + const previous = yield* installation.resolve(); + yield* fs.remove(previous.harnessPath); + const externalDirectory = path.join(baseDir, "external"); + yield* fs.makeDirectory(externalDirectory); + yield* fs.writeFileString(path.join(externalDirectory, executableName), "external server", { + mode: 0o755, + }); + yield* fs.writeFileString(path.join(externalDirectory, harnessName), "external harness", { + mode: 0o755, + }); + const restarted = yield* makeHarness({ baseDir, path: externalDirectory }); + expect(yield* restarted.installation.state).toMatchObject({ + phase: "failed", + installedVersion: null, + canRemove: true, + }); + expect(yield* restarted.installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + yield* restarted.installation.remove(); + expect(yield* restarted.installation.state).toMatchObject({ + phase: "idle", + canRemove: false, + }); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + yield* restarted.installation.start; + expect(yield* terminalState(restarted.installation)).toMatchObject({ + phase: "succeeded", + installedVersion: "fixture-new", + canRemove: true, + }); + yield* Deferred.await(restarted.stagingReleased); + expect((yield* restarted.installation.resolve()).source).toBe("managed"); + }), + ); + + it.effect( + "blocks removal of custom managed paths and leaves external executables and profiles intact", + () => + Effect.gen(function* () { + const { installation, fs, path, baseDir } = yield* makeHarness({ previous: true }); + const managed = yield* installation.resolve(); + const externalDirectory = path.join(baseDir, "external"); + const profileDirectory = path.join(baseDir, "providers", "antigravity", "profile"); + yield* fs.makeDirectory(externalDirectory); + yield* fs.makeDirectory(profileDirectory, { recursive: true }); + const externalExecutable = path.join(externalDirectory, executableName); + const externalHarness = path.join(externalDirectory, harnessName); + const profilePath = path.join(profileDirectory, "preferences.json"); + yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + yield* fs.writeFileString(profilePath, "{}"); + expect( + yield* installation.remove([managed.executablePath]).pipe(Effect.flip), + ).toMatchObject({ + operation: "remove", + }); + yield* expectPreviousRelease(installation); + yield* installation.remove([externalExecutable]); + expect(yield* installation.state).toMatchObject({ + phase: "idle", + operationId: null, + installedVersion: null, + }); + expect(yield* fs.readFileString(externalExecutable)).toBe("external server"); + expect(yield* fs.readFileString(externalHarness)).toBe("external harness"); + expect(yield* fs.readFileString(profilePath)).toBe("{}"); + }), + ); + + it.effect( + "reuses an immutable Windows release and preserves the pointer when rename is denied", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const archive = Buffer.from(zipFixtures.windows, "base64"); + const asset = releaseAsset(archive, "win32"); + let denyPointerRename = true; + const renameTargets: string[] = []; + const { installation, requests, validations } = yield* makeHarness({ + previous: true, + platform: "win32", + archive, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + rename: (source, target) => { + renameTargets.push(target); + return denyPointerRename + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: target, + description: "EPERM: file is in use", + }), + ) + : fs.rename(source, target); + }, + }), + }); + yield* writeRelease(installation.managedDirectory, asset, false); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("failed"); + yield* expectPreviousRelease(installation); + expect(renameTargets).toEqual([path.join(installation.managedDirectory, "active.json")]); + + denyPointerRename = false; + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + expect((yield* installation.resolve()).version).toBe("fixture-new"); + expect(requests).toEqual([]); + expect(validations).toHaveLength(2); + expect(renameTargets).toEqual([ + path.join(installation.managedDirectory, "active.json"), + path.join(installation.managedDirectory, "active.json"), + ]); + }), + ); + + it.effect("reports unsupported hosts without downloading or changing state", () => + Effect.gen(function* () { + const { installation, requests } = yield* makeHarness({ asset: null, platform: "darwin" }); + expect(yield* installation.start.pipe(Effect.flip)).toMatchObject({ operation: "start" }); + expect(yield* installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect(yield* installation.state).toMatchObject({ phase: "idle", operationId: null }); + expect(requests).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts new file mode 100644 index 000000000..cd07ec8cb --- /dev/null +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -0,0 +1,950 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query. +import * as EffectNodeStream from "@effect/platform-node/NodeStream"; +import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import type * as NodeStream from "node:stream"; +import * as Yauzl from "yauzl"; + +import { ServerConfig } from "../config.ts"; +import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; +import { + buildAntigravityAcpSpawnInput, + prepareAntigravityProfile, +} from "./antigravityAuthSupport.ts"; +import { + resolveAntigravityReleaseAsset, + type AntigravityReleaseAsset, +} from "./antigravityRelease.ts"; + +const DRIVER = ProviderDriverKind.make("antigravity"); +const DOWNLOAD_TIMEOUT = "45 minutes"; +const VALIDATION_TIMEOUT = "90 seconds"; +const FREE_SPACE_MARGIN = 256 * 1024 * 1024; +const RECORD_MAX_BYTES = 8 * 1024; +const RELEASE_RECORD = ".install-complete.json"; + +const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); +const ActiveRelease = Schema.Struct({ releaseId: ReleaseId }); +const InstalledRelease = Schema.Struct({ + releaseId: ReleaseId, + version: Schema.String, + executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), + harness: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), +}); +type InstalledRelease = typeof InstalledRelease.Type; +const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); +const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); + +export class AntigravityInstallationError extends Schema.TaggedErrorClass()( + "AntigravityInstallationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + return this.detail; + } +} +const isInstallationError = Schema.is(AntigravityInstallationError); + +export interface AntigravityExecutable { + readonly executablePath: string; + readonly harnessPath: string; + readonly source: "override" | "managed" | "path"; + readonly version: string | null; + readonly managedVersionDirectory: string | null; +} + +interface AntigravityInstallationService { + readonly managedDirectory: string; + readonly resolve: ( + binaryPath?: string, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** Hold the lease until the spawned process has exited. */ + readonly acquire: ( + binaryPath?: string, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: ( + operationId: string, + ) => Effect.Effect; + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + readonly remove: ( + protectedBinaryPaths?: ReadonlyArray, + ) => Effect.Effect; +} + +export class AntigravityInstallation extends Context.Service< + AntigravityInstallation, + AntigravityInstallationService +>()("t3/provider/AntigravityInstallation") { + static readonly layer = Layer.effect( + AntigravityInstallation, + Effect.gen(function* () { + const config = yield* ServerConfig; + return yield* makeAntigravityInstallation({ baseDir: config.baseDir }); + }), + ); +} + +export interface AntigravityInstallationOptions { + readonly baseDir: string; + readonly releaseAsset?: AntigravityReleaseAsset | null; + readonly validate?: ( + executable: AntigravityExecutable, + expectedVersion: string, + ) => Effect.Effect; +} + +const installationError = (operation: string, detail: string, cause?: unknown) => + new AntigravityInstallationError({ + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); + +const wrapFailure = (operation: string, detail: string) => (cause: unknown) => + isInstallationError(cause) ? cause : installationError(operation, detail, cause); + +function executableNames(platform: NodeJS.Platform) { + return platform === "win32" + ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" } + : { executable: "agy_acp_server.par", harness: "localharness_external" }; +} + +function isRunning(state: ProviderInstallState) { + return ( + state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying" + ); +} + +/** Open only a verified local archive. Entries stay lazy and extraction stays bounded. */ +const openArchive = Effect.fn("AntigravityInstallation.openArchive")(function* ( + archivePath: string, +) { + const opened = yield* Effect.acquireRelease( + Effect.callback< + { + readonly zip: Yauzl.ZipFile; + readonly error: () => AntigravityInstallationError | undefined; + readonly close: Effect.Effect; + }, + AntigravityInstallationError + >((resume) => { + Yauzl.open( + archivePath, + { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, + (error, zip) => { + if (error || !zip) { + resume( + Effect.fail( + installationError("extract", "Could not open the verified archive.", error), + ), + ); + return; + } + let closed = false; + let archiveError: AntigravityInstallationError | undefined; + zip.on("close", () => { + closed = true; + }); + zip.on("error", (cause: unknown) => { + archiveError = installationError("extract", "The archive could not be read.", cause); + }); + resume( + Effect.succeed({ + zip, + error: () => archiveError, + close: Effect.callback((finish) => { + if (closed) { + finish(Effect.void); + return; + } + const onClose = () => { + zip.removeListener("error", onError); + finish(Effect.void); + }; + const onError = (cause: unknown) => { + zip.removeListener("close", onClose); + finish( + Effect.die(installationError("extract", "Could not close the archive.", cause)), + ); + }; + zip.once("close", onClose); + zip.once("error", onError); + zip.close(); + }), + }), + ); + }, + ); + }), + (opened) => opened.close, + ); + + const next = Effect.callback((resume) => { + const existingError = opened.error(); + if (existingError) { + resume(Effect.fail(existingError)); + return; + } + const cleanup = () => { + opened.zip.removeListener("entry", onEntry); + opened.zip.removeListener("end", onEnd); + opened.zip.removeListener("error", onError); + }; + const onEntry = (entry: Yauzl.Entry) => { + cleanup(); + resume(Effect.succeed(entry)); + }; + const onEnd = () => { + cleanup(); + resume(Effect.succeed(null)); + }; + const onError = (cause: unknown) => { + cleanup(); + resume(Effect.fail(installationError("extract", "The archive could not be read.", cause))); + }; + opened.zip.once("entry", onEntry); + opened.zip.once("end", onEnd); + opened.zip.once("error", onError); + opened.zip.readEntry(); + return Effect.sync(cleanup); + }); + + const streamEntry = (entry: Yauzl.Entry) => + Effect.acquireRelease( + Effect.callback((resume) => { + opened.zip.openReadStream(entry, (cause, readable) => { + resume( + cause || !readable + ? Effect.fail( + installationError("extract", "Could not read an archive member.", cause), + ) + : Effect.succeed(readable), + ); + }); + }), + (readable) => + Effect.sync(() => { + readable.destroy(); + }), + ); + return { entryCount: opened.zip.entryCount, next, streamEntry }; +}); + +export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* ( + options: AntigravityInstallationOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serviceScope = yield* Effect.scope; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + const environment = yield* HostProcessEnvironment; + const releaseAsset = + options.releaseAsset === undefined + ? resolveAntigravityReleaseAsset(platform, arch) + : options.releaseAsset; + const names = executableNames(platform); + const managedDirectory = path.join( + options.baseDir, + "tools", + "antigravity-acp", + `${platform}-${arch}`, + ); + const versionsDirectory = path.join(managedDirectory, "versions"); + const activePath = path.join(managedDirectory, "active.json"); + const gate = yield* Semaphore.make(1); + const leases = new Map(); + let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined; + const state = yield* SubscriptionRef.make({ + driver: DRIVER, + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: releaseAsset?.archiveBytes ?? null, + version: releaseAsset?.version ?? null, + installedVersion: null, + canRemove: false, + message: null, + }); + + const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* ( + filePath: string, + schema: Schema.Codec, + ) { + const info = yield* fs.stat(filePath); + if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) { + return yield* installationError( + "resolve", + "The managed runtime record is invalid. Reinstall Antigravity.", + ); + } + const contents = yield* fs.readFileString(filePath); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(contents); + }); + + const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* ( + filePath: string, + bytes?: number, + ) { + const info = yield* fs.stat(filePath).pipe(Effect.option); + return ( + Option.isSome(info) && + info.value.type === "File" && + (bytes === undefined || Number(info.value.size) === bytes) && + (platform === "win32" || (info.value.mode & 0o111) !== 0) + ); + }); + + const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* ( + releaseId: string, + ) { + const directory = path.join(versionsDirectory, releaseId); + const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease); + if ( + record.releaseId !== releaseId || + record.executable.name !== names.executable || + record.harness.name !== names.harness || + !Number.isSafeInteger(record.executable.bytes) || + record.executable.bytes <= 0 || + !Number.isSafeInteger(record.harness.bytes) || + record.harness.bytes <= 0 || + !record.version.trim() || + !(yield* executableFile(path.join(directory, names.executable), record.executable.bytes)) || + !(yield* executableFile(path.join(directory, names.harness), record.harness.bytes)) + ) { + return yield* installationError( + "resolve", + "The managed Antigravity runtime is incomplete. Reinstall it.", + ); + } + return { + executablePath: path.join(directory, names.executable), + harnessPath: path.join(directory, names.harness), + source: "managed", + version: record.version, + managedVersionDirectory: directory, + } satisfies AntigravityExecutable; + }); + + const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* ( + candidate: string, + source: "override" | "path", + ) { + if (!(yield* executableFile(candidate))) return null; + const executablePath = yield* fs.realPath(candidate); + const directory = path.dirname(executablePath); + const harnessPath = path.join(directory, names.harness); + if (!(yield* executableFile(harnessPath))) return null; + const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option); + if ( + Option.isSome(realVersions) && + path.dirname(directory) === realVersions.value && + /^[a-f0-9]{64}$/u.test(path.basename(directory)) + ) { + const installed = yield* completedRelease(path.basename(directory)); + return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable; + } + return { + executablePath, + harnessPath, + source, + version: null, + managedVersionDirectory: null, + } satisfies AntigravityExecutable; + }); + + const pathCandidates = (binary: string, processEnvironment = environment) => { + const pathValue = + platform === "win32" + ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1] + : processEnvironment.PATH; + return (pathValue ?? "") + .split(platform === "win32" ? ";" : ":") + .map((directory) => directory.trim().replace(/^"|"$/gu, "")) + .filter((directory) => directory.length > 0) + .map((directory) => path.resolve(directory, binary)); + }; + + const resolve: AntigravityInstallationService["resolve"] = Effect.fn( + "AntigravityInstallation.resolve", + )( + function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) { + const override = binaryPath?.trim(); + if (override) { + const candidates = + path.isAbsolute(override) || override.includes("/") || override.includes("\\") + ? [path.resolve(override)] + : pathCandidates(override, processEnvironment); + for (const candidate of candidates) { + const selected = yield* fromExternal(candidate, "override"); + if (selected) return selected; + } + return yield* installationError( + "resolve", + "The custom Antigravity executable or its localharness_external sibling is missing or not executable.", + ); + } + if (yield* fs.exists(activePath)) { + const active = yield* readRecord(activePath, ActiveRelease); + return yield* completedRelease(active.releaseId); + } + for (const candidate of pathCandidates(names.executable, processEnvironment)) { + const selected = yield* fromExternal(candidate, "path"); + if (selected) return selected; + } + return yield* installationError( + "resolve", + releaseAsset + ? "Antigravity is not installed. Install it in this environment or set a custom executable path." + : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`, + ); + }, + Effect.mapError( + wrapFailure( + "resolve", + "Could not read the Antigravity installation. Reinstall it or set a custom executable path.", + ), + ), + ); + + const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) => + Effect.acquireRelease( + gate.withPermits(1)( + Effect.gen(function* () { + const executable = yield* resolve(binaryPath, processEnvironment); + const directory = executable.managedVersionDirectory; + if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1); + return executable; + }), + ), + (executable) => + gate.withPermits(1)( + Effect.sync(() => { + const directory = executable.managedVersionDirectory; + if (!directory) return; + const remaining = (leases.get(directory) ?? 1) - 1; + if (remaining > 0) leases.set(directory, remaining); + else leases.delete(directory); + }), + ), + ); + + const validate = + options.validate ?? + Effect.fn("AntigravityInstallation.validate")( + function* (executable: AntigravityExecutable, expectedVersion: string) { + const profileDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-antigravity-validate-", + }); + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + baseEnv: environment, + }); + const runtime = yield* makeAntigravityAcpRuntime({ + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: profileDirectory, + baseEnv: environment, + }), + cwd: profileDirectory, + childProcessSpawner: spawner, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }); + const initialized = yield* runtime.initialize(); + if ( + initialized.agentInfo?.name !== "antigravity-acp" || + initialized.agentInfo.version !== expectedVersion || + initialized.protocolVersion !== 1 || + initialized.agentCapabilities?.loadSession !== true || + !initialized.agentCapabilities.sessionCapabilities?.resume || + !initialized.agentCapabilities.auth?.logout || + !initialized.authMethods?.some((method) => method.id === "oauth-personal") + ) { + return yield* installationError( + "verify", + "The downloaded runtime did not identify as the expected Google Antigravity release.", + ); + } + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + wrapFailure( + "verify", + "The downloaded Antigravity runtime could not start in this environment.", + ), + ), + ); + + const install = Effect.fn("AntigravityInstallation.install")( + function* (asset: AntigravityReleaseAsset) { + const report = (phase: ProviderInstallState["phase"], message: string | null) => + SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); + yield* fs.makeDirectory(versionsDirectory, { recursive: true }); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true })); + const destination = path.join(versionsDirectory, asset.sha256); + const activate = Effect.fn("AntigravityInstallation.activate")( + function* () { + const pointerDirectory = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: "active.json.", + }); + const pointerPath = path.join(pointerDirectory, "contents.tmp"); + yield* fs.writeFileString( + pointerPath, + yield* encodeActiveRelease({ releaseId: asset.sha256 }), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(pointerPath, activePath); + // The pointer commits the install. Later temp cleanup cannot undo it. + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + installedVersion: asset.version, + message: null, + }) satisfies ProviderInstallState, + ); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "activate", + "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.", + ), + ), + Effect.uninterruptible, + ); + + if (yield* fs.exists(destination)) { + const existing = yield* completedRelease(asset.sha256); + if (existing.version !== asset.version) { + return yield* installationError( + "verify", + "The existing managed release has the wrong version. Remove it before reinstalling.", + ); + } + yield* report("verifying", "Checking the installed runtime."); + yield* validate(existing, asset.version).pipe( + Effect.scoped, + Effect.timeout(VALIDATION_TIMEOUT), + ); + yield* activate(); + return; + } + + const available = yield* Effect.tryPromise(() => + NodeFSP.statfs(versionsDirectory, { bigint: true }), + ).pipe(Effect.option); + const required = + asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN; + if ( + Option.isSome(available) && + available.value.bavail * available.value.bsize < BigInt(required) + ) { + return yield* installationError( + "download", + `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`, + ); + } + const staging = yield* fs.makeTempDirectoryScoped({ + directory: versionsDirectory, + prefix: ".install-", + }); + const archivePath = path.join(staging, "download.zip"); + const pairDirectory = path.join(staging, "runtime"); + yield* fs.makeDirectory(pairDirectory); + const hash = NodeCrypto.createHash("sha256"); + let downloadedBytes = 0; + let lastProgressAt = yield* Clock.currentTimeMillis; + yield* Effect.gen(function* () { + const response = yield* http + .execute(HttpClientRequest.get(asset.url)) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + // dl.google.com gzips the zip when the client accepts it, so + // `content-length` is the encoded size. The decoded stream is still + // checked against the pinned byte count and hash below. + const contentLength = response.headers["content-length"]; + const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); + const identityBody = contentEncoding === undefined || contentEncoding === "identity"; + if ( + identityBody && + contentLength !== undefined && + Number(contentLength) !== asset.archiveBytes + ) { + return yield* installationError( + "download", + "The Antigravity download size did not match the pinned release.", + ); + } + yield* response.stream.pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + downloadedBytes += chunk.byteLength; + if (downloadedBytes > asset.archiveBytes) { + return yield* installationError( + "download", + "The Antigravity download exceeded the pinned release size.", + ); + } + hash.update(chunk); + const now = yield* Clock.currentTimeMillis; + if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) { + lastProgressAt = now; + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + downloadedBytes, + })); + } + }), + ), + Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), + ); + }).pipe(Effect.timeout(DOWNLOAD_TIMEOUT)); + if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) { + return yield* installationError( + "download", + "The Antigravity download failed its size or SHA-256 check. Nothing was installed.", + ); + } + + yield* report("extracting", "Extracting the verified runtime."); + yield* Effect.gen(function* () { + const archive = yield* openArchive(archivePath); + if (archive.entryCount !== 2) { + return yield* installationError( + "extract", + "The archive must contain exactly the Antigravity executable and its harness.", + ); + } + const seen = new Set(); + for (;;) { + const entry = yield* archive.next; + if (!entry) break; + const expected = [asset.executable, asset.harness].find( + (file) => file.name === entry.fileName, + ); + const unixType = (entry.externalFileAttributes >>> 16) & 0o170000; + if ( + !expected || + seen.has(entry.fileName) || + entry.fileName.includes("/") || + entry.fileName.includes("\\") || + (unixType !== 0 && unixType !== 0o100000) || + (entry.externalFileAttributes & 0x10) !== 0 || + (entry.generalPurposeBitFlag & 1) !== 0 || + ![0, 8].includes(entry.compressionMethod) || + entry.uncompressedSize !== expected.bytes + ) { + return yield* installationError( + "extract", + "The archive contains an unexpected, unsafe, or incorrectly sized member.", + ); + } + seen.add(entry.fileName); + yield* Effect.gen(function* () { + const readable = yield* archive.streamEntry(entry); + let extractedBytes = 0; + yield* EffectNodeStream.fromReadable({ + evaluate: () => readable, + onError: wrapFailure("extract", "Could not extract the Antigravity runtime."), + }).pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + extractedBytes += chunk.byteLength; + if (extractedBytes > expected.bytes) { + return yield* installationError( + "extract", + "An archive member exceeded its pinned size.", + ); + } + }), + ), + Stream.run( + fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }), + ), + ); + if (extractedBytes !== expected.bytes) { + return yield* installationError("extract", "An archive member was truncated."); + } + }).pipe(Effect.scoped); + } + if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) { + return yield* installationError( + "extract", + "The archive is missing the Antigravity executable or its harness.", + ); + } + }).pipe(Effect.scoped); + yield* fs.remove(archivePath); + if (platform !== "win32") { + yield* fs.chmod(path.join(pairDirectory, asset.executable.name), 0o755); + yield* fs.chmod(path.join(pairDirectory, asset.harness.name), 0o755); + } + yield* report("verifying", "Checking the downloaded runtime."); + yield* validate( + { + executablePath: path.join(pairDirectory, asset.executable.name), + harnessPath: path.join(pairDirectory, asset.harness.name), + source: "managed", + version: asset.version, + managedVersionDirectory: pairDirectory, + }, + asset.version, + ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT)); + const record: InstalledRelease = { + releaseId: asset.sha256, + version: asset.version, + executable: asset.executable, + harness: asset.harness, + }; + yield* fs.writeFileString( + path.join(pairDirectory, RELEASE_RECORD), + yield* encodeInstalledRelease(record), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(pairDirectory, destination).pipe( + Effect.catch((cause) => + completedRelease(asset.sha256).pipe( + Effect.flatMap((existing) => + existing.version === asset.version + ? validate(existing, asset.version).pipe( + Effect.scoped, + Effect.timeout(VALIDATION_TIMEOUT), + ) + : Effect.fail( + installationError( + "activate", + "Another installation published a different Antigravity release.", + ), + ), + ), + Effect.mapError(() => + installationError( + "activate", + "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.", + cause, + ), + ), + ), + ), + ); + yield* activate(); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "install", + "Could not install Antigravity. Check free disk space and directory access, then try again.", + ), + ), + ); + + const start = gate + .withPermits(1)( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (isRunning(current)) return current; + if (!releaseAsset) { + return yield* installationError( + "start", + `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, + ); + } + const operationId = yield* crypto.randomUUIDv4; + const next: ProviderInstallState = { + driver: DRIVER, + operationId, + phase: "downloading", + downloadedBytes: 0, + totalBytes: releaseAsset.archiveBytes, + version: releaseAsset.version, + installedVersion: current.installedVersion, + canRemove: current.canRemove, + message: "Downloading Google's official Antigravity runtime.", + }; + yield* SubscriptionRef.set(state, next); + const work = install(releaseAsset).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? SubscriptionRef.update(state, (value) => { + if (value.operationId !== operationId || value.phase === "succeeded") + return value; + const error = Cause.findErrorOption(exit.cause); + const cancelled = Cause.hasInterruptsOnly(exit.cause); + return { + ...value, + phase: cancelled ? "cancelled" : "failed", + message: cancelled + ? "Installation cancelled. The previous runtime is unchanged." + : Option.isSome(error) + ? error.value.detail + : "Could not finish the Antigravity installation. Check disk space and directory access.", + } satisfies ProviderInstallState; + }) + : Effect.void, + ), + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + if (running?.operationId === operationId) running = undefined; + }), + ), + ); + const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope); + running = { operationId, fiber }; + return next; + }).pipe(Effect.uninterruptible), + ) + .pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation."))); + + const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) { + return yield* gate.withPermits(1)( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (current.operationId !== operationId) { + return yield* installationError( + "cancel", + "This installation is no longer current. Refresh its status before cancelling.", + ); + } + if (running?.operationId === operationId && isRunning(current)) { + yield* Fiber.interrupt(running.fiber); + } + return yield* SubscriptionRef.get(state); + }), + ); + }); + + const remove = Effect.fn("AntigravityInstallation.remove")( + function* (protectedBinaryPaths: ReadonlyArray = []) { + yield* gate.withPermits(1)( + Effect.gen(function* () { + if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) { + return yield* installationError( + "remove", + "Stop Antigravity sessions and sign-in flows before removing its managed runtime.", + ); + } + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if (Option.isSome(realManaged)) { + for (const binaryPath of protectedBinaryPaths) { + if (!binaryPath.trim()) continue; + const selected = yield* resolve(binaryPath).pipe(Effect.option); + if (Option.isSome(selected) && selected.value.managedVersionDirectory) { + return yield* installationError( + "remove", + "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", + ); + } + const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option); + const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath)); + if (candidate.startsWith(`${realManaged.value}${path.sep}`)) { + return yield* installationError( + "remove", + "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", + ); + } + } + } + yield* fs.remove(managedDirectory, { recursive: true, force: true }); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + operationId: null, + phase: "idle", + downloadedBytes: 0, + installedVersion: null, + canRemove: false, + message: null, + }) satisfies ProviderInstallState, + ); + }).pipe(Effect.uninterruptible), + ); + }, + Effect.mapError( + wrapFailure( + "remove", + "Could not remove the managed Antigravity runtime. Check for open processes and try again.", + ), + ), + ); + + yield* Effect.gen(function* () { + const canRemove = yield* fs.exists(managedDirectory); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove })); + if (!(yield* fs.exists(activePath))) return; + const active = yield* readRecord(activePath, ActiveRelease); + const installed = yield* completedRelease(active.releaseId); + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + installedVersion: installed.version, + })); + }).pipe( + Effect.catch(() => + SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "failed", + message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.", + }) satisfies ProviderInstallState, + ), + ), + ); + + return AntigravityInstallation.of({ + managedDirectory, + resolve, + acquire, + start, + cancel, + state: SubscriptionRef.get(state), + changes: SubscriptionRef.changes(state), + remove, + }); +}); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 000000000..7d664916c --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,418 @@ +import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { AcpError } from "effect-acp/errors"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + isAntigravityTextGenerationAvailable, + makeAntigravityTextGeneration, +} from "../../textGeneration/AntigravityTextGeneration.ts"; +import { makeAntigravityAuth, type AntigravityAuth } from "../AntigravityAuth.ts"; +import { AntigravityInstallation } from "../AntigravityInstallation.ts"; +import { + antigravityAuthConfigIssue, + antigravityAuthLabel, + antigravityAuthUsesBrowser, + buildAntigravityAcpSpawnInput, + isAntigravitySignInRequiredError, + prepareAntigravityProfile, + resolveAntigravityProfileDirectory, + type AntigravityAuthConfig, +} from "../antigravityAuthSupport.ts"; +import { + makeAntigravityAcpRuntime, + type AntigravityAcpRuntimeInput, +} from "../acp/AntigravityAcpSupport.ts"; +import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { removeAntigravitySessionFiles } from "../acp/AntigravitySessionFiles.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; +import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ModelManifest from "../ModelManifest.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../../orchestration-v2/IdAllocator.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; + +const DRIVER = ProviderDriverKind.make("antigravity"); +const decodeSettings = Schema.decodeSync(AntigravitySettings); + +export type AntigravityDriverEnv = + | IdAllocatorV2 + | AntigravityInstallation + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | ModelManifest.ModelManifest + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +/** Each instance owns its Google profile. Executable releases are shared by the environment. */ +export const AntigravityDriver: ProviderDriver = { + driverKind: DRIVER, + metadata: { displayName: "Antigravity", supportsMultipleInstances: true }, + configSchema: AntigravitySettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const idAllocator = yield* IdAllocatorV2; + const nativeLogger = yield* makeAcpNativeLoggerFactory(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* ServerConfig; + const installation = yield* AntigravityInstallation; + const loggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; + const settings = { ...config, enabled } satisfies AntigravitySettings; + const auth: AntigravityAuthConfig = { + authMethod: settings.authMethod, + apiKey: settings.apiKey, + gcpProject: settings.gcpProject, + gcpLocation: settings.gcpLocation, + }; + const authConfigIssue = antigravityAuthConfigIssue(auth); + const processEnvironment = mergeProviderInstanceEnvironment( + environment, + yield* HostProcessEnvironment, + ); + const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment); + const profileDirectory = resolveAntigravityProfileDirectory( + serverConfig.stateDir, + instanceId, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER, + instanceId, + }); + const stampIdentity = (draft: ServerProviderDraft) => ({ + ...draft, + instanceId, + driver: DRIVER, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + }); + // Google returns every model the account can use, including older + // Gemini generations. The manifest names the current ones so the picker + // folds the rest under its legacy section, as it does for Codex. + const classifyModels = (draft: ServerProviderDraft) => + modelManifest.current.pipe( + Effect.map((manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)), + ), + ); + + const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( + input: Omit, + ): Effect.fn.Return< + AcpSessionRuntime["Service"], + AcpError | ProviderSetupError, + Scope.Scope + > { + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation + .acquire(settings.binaryPath, processEnvironment) + .pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId, + operation: "resolve", + detail: cause.detail, + }), + ), + ); + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: processEnvironment, + auth, + userHome, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const runtime = yield* makeAntigravityAcpRuntime({ + ...input, + authMethod: auth.authMethod, + childProcessSpawner: spawner, + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: input.cwd, + baseEnv: processEnvironment, + auth, + }), + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return { + ...runtime, + start: () => + runtime + .start() + .pipe( + Effect.tapError( + (cause): Effect.Effect => + input.onAuthorizationUrl === undefined && + isAntigravitySignInRequiredError(cause) + ? provider.onAuthRequired + : Effect.void, + ), + ), + }; + }); + + const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( + input: Pick, + ) { + const cwd = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) + .pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity setup workspace.", + }), + ), + ); + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory, + sessionId, + cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const runtime = yield* makeRuntime({ + cwd, + clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, + mcpServers: [], + ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), + }); + return { + ...runtime, + start: () => + runtime.start().pipe( + Effect.tap((started) => + Effect.sync(() => { + sessionId = started.sessionId; + }), + ), + ), + }; + }); + + const publishCatalog = ( + started: AcpSessionRuntimeStartResult, + runtime: Pick, + ): Effect.Effect => + Effect.gen(function* () { + yield* provider.onSessionStarted(started); + yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); + } + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }).pipe(Effect.forkScoped); + yield* runtime.drainEvents; + }).pipe(Effect.scoped); + + const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ + instanceId, + makeRuntime: makeDisposableRuntime, + onAuthenticated: publishCatalog, + onSignedOut: Effect.suspend(() => provider.onSignedOut), + usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }); + + // Kick the TTL-gated manifest refresh alongside the health check, as + // Codex and Claude do. Without it an environment that only runs + // Antigravity would keep classifying against a stale disk cache. + const probe = Effect.gen(function* () { + yield* modelManifest.refreshInBackground; + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + return yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeRuntime({ + cwd: serverConfig.stateDir, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + mcpServers: [], + }); + return yield* runtime.initialize(); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }).pipe(Effect.scoped); + + const provider = yield* makeAntigravityProvider(settings, { + stampIdentity: classifyModels, + probe, + auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, + supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => false), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare the Antigravity provider status.", + cause, + }), + ), + ); + const defaultModel = modelManifest.current.pipe( + Effect.map((manifest) => ModelManifest.manifestDefaultModel(manifest, DRIVER)), + ); + const adapter = makeAntigravityAdapterV2({ + crypto, + fileSystem, + path, + serverConfig, + idAllocator, + instanceId, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onConfigOptionsUpdated: provider.onConfigOptionsUpdated, + onAvailableCommands: provider.onAvailableCommands, + nativeLogging: (threadId) => + nativeLogger({ nativeEventLogger: loggers.native, provider: DRIVER, threadId }), + }); + const textGeneration = yield* makeAntigravityTextGeneration({ + profileDirectory, + defaultModel, + withProcess: authFlow.withProcess, + makeRuntime: (cwd) => + makeRuntime({ + cwd, + clientInfo: { name: "t3-code-text", version: "0.0.0" }, + mcpServers: [], + }), + }); + + const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( + function* () { + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeDisposableRuntime({}); + const started = yield* runtime.start(); + yield* publishCatalog(started, runtime); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => + Effect.fail( + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", + }), + ), + }), + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, + ), + Effect.mapError((cause) => + cause._tag === "ProviderDriverError" + ? cause + : new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: isAntigravitySignInRequiredError(cause) + ? "Sign in to Antigravity in provider settings before refreshing models." + : cause._tag === "ProviderSetupError" && cause.operation === "configure" + ? cause.detail + : "Could not refresh Antigravity models. The previous model list is unchanged.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: provider.snapshot, + snapshotForCwd: (cwd) => + !enabled + ? provider.snapshot.getSnapshot + : discoverAntigravitySkills({ cwd, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not read Antigravity workspace skills.", + cause, + }), + ), + ), + orchestrationAdapter: adapter, + textGeneration, + auth: authFlow.controller, + refreshModels, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts new file mode 100644 index 000000000..180ef9968 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -0,0 +1,360 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; + +const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + const skillPath = path.join(directory, "SKILL.md"); + yield* fileSystem.writeFileString(skillPath, contents); + return skillPath; +}); + +const makeWorkspace = Effect.fn("makeWorkspace")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-skills-", + }); + return { + cwd: path.join(temporaryDirectory, "workspace"), + userHome: path.join(temporaryDirectory, "home"), + }; +}); + +it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { + it.effect("does not read user skills from a nested project or from ~/.agents", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") }; + const skillPath = yield* writeSkill( + path.join(input.userHome, ".gemini", "config", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + yield* writeSkill( + path.join(input.userHome, ".agents", "skills", "ignored"), + "---\nname: ignored\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(nested), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "user", + enabled: true, + }, + ]); + // A project rooted at the home directory sees ~/.agents/skills as its own. + assert.deepEqual( + (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [ + skill.name, + skill.scope, + ]), + [ + ["ignored", "project"], + ["review", "user"], + ], + ); + }), + ); + + it.effect("reads skill names, descriptions and paths from the current native roots", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const roots = [ + { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" }, + { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, + { + directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), + scope: "user", + }, + { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, + ]; + const expected = []; + for (const [index, root] of roots.entries()) { + const name = `review-${index}`; + const description = `Review changes in root ${index}.`; + const skillPath = yield* writeSkill( + path.join(root.directory, name), + `---\nname: ${name}\ndescription: ${description}\n---\n# Review\n`, + ); + expected.push({ name, description, path: skillPath, scope: root.scope, enabled: true }); + } + + assert.deepEqual(yield* discoverAntigravitySkills(input), expected); + }), + ); + + it.effect("returns no skills when the native roots are missing", () => + Effect.gen(function* () { + const input = yield* makeWorkspace(); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + it.effect("discovers skills from the legacy workspace root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agent", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "project", + enabled: true, + }, + ]); + }), + ); + + it.effect("uses native root order for duplicate names", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const roots = [ + path.join(input.userHome, ".gemini", "config", "skills"), + path.join(input.cwd, ".gemini", "skills"), + path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), + path.join(input.cwd, ".agents", "skills"), + path.join(input.cwd, ".agent", "skills"), + ]; + for (const [index, root] of roots.entries()) { + yield* writeSkill( + path.join(root, `copy-${index}`), + `---\nname: review\ndescription: Copy ${index}.\n---\n`, + ); + } + + for (const [index, root] of roots.entries()) { + const skills = yield* discoverAntigravitySkills(input); + assert.equal(skills.length, 1); + assert.equal(skills[0]?.path, path.join(root, `copy-${index}`, "SKILL.md")); + yield* fileSystem.remove(root, { recursive: true }); + } + }), + ); + + it.effect("loads a root skill without scanning its children", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const skillPath = yield* writeSkill(root, "---\nname: root-skill\n---\n"); + yield* writeSkill(path.join(root, "child"), "---\nname: child-skill\n---\n"); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "root-skill", path: skillPath, scope: "project", enabled: true }, + ]); + + yield* fileSystem.writeFileString(skillPath, "---\nname: [invalid\n---\n"); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + it.effect("uses the filename when valid frontmatter has no name", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "not-the-name"), + "---\n---\n# Skill body\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "SKILL", path: skillPath, scope: "project", enabled: true }, + ]); + + const lowerCasePath = path.join(path.dirname(skillPath), "skill.md"); + yield* fileSystem.rename(skillPath, lowerCasePath); + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "skill", path: lowerCasePath, scope: "project", enabled: true }, + ]); + + yield* fileSystem.rename(lowerCasePath, path.join(path.dirname(skillPath), "SKILL.MD")); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + it.effect("accepts native metadata delimiters after leading text", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "review"), + "Leading text.---\nname: review\ndescription: null\n---Skill body.", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "review", path: skillPath, scope: "project", enabled: true }, + ]); + }), + ); + + it.effect("ignores invalid files and deeper directories but keeps native hidden skills", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const invalidSkills = [ + ["plain", "# Missing frontmatter\n"], + ["broken", "---\nname: [unclosed\n---\n"], + ["scalar", "---\n42\n---\n"], + ["wrong-type", "---\nname: invalid\ndescription: {}\n---\n"], + ["blank-name", '---\nname: " "\n---\n'], + ] as const; + for (const [name, contents] of invalidSkills) { + yield* writeSkill(path.join(root, name), contents); + } + yield* writeSkill(path.join(root, "nested", "deep"), "---\nname: too-deep\n---\n"); + yield* writeSkill( + path.join(input.cwd, ".claude", "skills", "wrong-provider"), + "---\nname: wrong-provider\n---\n", + ); + yield* fileSystem.makeDirectory(path.join(root, ".not-a-skill")); + yield* fileSystem.writeFileString(path.join(root, "README.md"), "Not a skill."); + const skillPath = yield* writeSkill( + path.join(root, ".native-hidden-skill"), + "---\nname: native-name\ndescription: >\n Review the code\n and run tests.\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "native-name", + description: "Review the code and run tests.", + path: skillPath, + scope: "project", + enabled: true, + }, + ]); + }), + ); + + it.effect("uses native URI order within a root and skips an invalid higher root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + yield* writeSkill( + path.join(input.userHome, ".gemini", "config", "skills", "review"), + "---\nname: [invalid\n---\n", + ); + const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; + for (const name of nativeOrder) { + yield* writeSkill(path.join(root, name), "---\nname: review\n---\n"); + } + + for (const name of nativeOrder) { + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + path: path.join(root, name, "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + yield* fileSystem.remove(path.join(root, name), { recursive: true }); + } + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "follows directory symlinks used to install shared skills", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const sourceDirectory = path.join(input.userHome, "shared-review"); + yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); + const root = path.join(input.cwd, ".agents", "skills"); + const linkedDirectory = path.join(root, "review"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(sourceDirectory, linkedDirectory); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + path: path.join(linkedDirectory, "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + }), + ); + + it.effect("rejects an oversized skill instead of returning an incomplete catalog", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "oversized"), + `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`, + ); + + const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.reason, "scan-budget-exhausted"); + assert.equal(result.failure.path, skillPath); + } + }), + ); + + it.effect("bounds the total read size across skills", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + for (let index = 0; index < 9; index += 1) { + yield* writeSkill( + path.join(root, `large-${index}`), + `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`, + ); + } + + const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.reason, "scan-budget-exhausted"); + assert.equal(result.failure.path, path.join(root, "large-8", "SKILL.md")); + } + }), + ); +}); + +it("resolves the home the agent expands ~ against", () => { + assert.equal( + resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "/home/user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "C:\\Users\\user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + "D:\\Users\\alice", + ); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); +}); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts new file mode 100644 index 000000000..bb238e4e0 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -0,0 +1,237 @@ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { parse as parseYamlDocument } from "yaml"; + +/** + * The home directory the agent expands `~` against, matching Python's + * `os.path.expanduser` in the launch environment T3 hands the process: + * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` + * elsewhere. Values are used verbatim; a path may contain spaces. + */ +export function resolveAntigravityUserHome( + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string { + if (platform === "win32") { + if (environment.USERPROFILE) return environment.USERPROFILE; + if (environment.HOMEDRIVE && environment.HOMEPATH) { + return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; + } + return NodeOS.homedir(); + } + return environment.HOME || NodeOS.homedir(); +} + +/** + * The agent's two user-global skill directories under a Gemini home, in + * native precedence order: `config/skills` is shared with the Antigravity IDE + * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs + * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a + * private profile, so the profile links these back to the user's `~/.gemini`. + * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a + * project directory. + */ +export function antigravityUserSkillDirectories( + path: Path.Path, + geminiHome: string, +): readonly [configSkills: string, cliSkills: string] { + return [ + path.join(geminiHome, "config", "skills"), + path.join(geminiHome, "antigravity-cli", "skills"), + ]; +} + +const MAX_SKILL_BYTES = 1_000_000; +const MAX_SCAN_BYTES = 8_000_000; +const MAX_SCAN_ENTRIES = 10_000; + +const SkillFrontmatter = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeSkillFrontmatter = Schema.decodeUnknownSync(SkillFrontmatter); + +export class AntigravitySkillsProbeError extends Schema.TaggedErrorClass()( + "AntigravitySkillsProbeError", + { + reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "scan-budget-exhausted" + ? `Antigravity skill discovery exceeded its scan limit at '${this.path}'.` + : `Antigravity could not read skills at '${this.path}'.`; + } +} + +interface ScanBudget { + remainingBytes: number; + remainingEntries: number; +} + +const readIfPresent = ( + effect: Effect.Effect, + path: string, +) => + effect.pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail( + new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }), + ), + }), + ); + +function parseSkillFrontmatter(contents: string, fileName: string) { + const start = contents.indexOf("---"); + if (start === -1) return undefined; + const end = contents.indexOf("---", start + 3); + if (end === -1) return undefined; + try { + const frontmatter = decodeSkillFrontmatter( + parseYamlDocument(contents.slice(start + 3, end).trim()) ?? {}, + ); + const name = frontmatter.name || fileName.slice(0, -3); + const description = frontmatter.description?.trim(); + // Native names are not trimmed. Do not rename one to fit the picker contract. + if (!name || name !== name.trim()) return undefined; + return { name, ...(description ? { description } : {}) }; + } catch { + return undefined; + } +} + +/** The native loader orders child paths with Go's URL.EscapedPath encoding. */ +function skillPathSortKey(entry: string) { + return encodeURI(entry).replace( + /[!'()*?#]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** Read only regular skill files, with a byte limit that applies during the read. */ +const readSkill = Effect.fn("readAntigravitySkill")(function* ( + skillPath: string, + budget: ScanBudget, +) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* readIfPresent(fileSystem.stat(skillPath), skillPath); + if (info?.type !== "File") return undefined; + + const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes); + if (info.size > BigInt(byteLimit)) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: skillPath, + }); + } + const chunks = yield* readIfPresent( + fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect), + skillPath, + ); + if (chunks === undefined) return undefined; + const bytes = Buffer.concat(chunks); + if (bytes.byteLength > byteLimit) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: skillPath, + }); + } + budget.remainingBytes -= bytes.byteLength; + return bytes.toString("utf8"); +}); + +/** + * Match the official ACP's explicit skill roots. The first valid same-name skill + * wins. Each root loads its own SKILL.md or those in its immediate subdirectories. + * Read failures remain typed so workspace snapshots do not cache partial results. + */ +export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { + readonly cwd: string; + readonly userHome: string; +}): Effect.fn.Return< + ReadonlyArray, + AntigravitySkillsProbeError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); + const roots = [ + { directory: configSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, + { directory: cliSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, + { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, + ]; + const budget: ScanBudget = { + remainingBytes: MAX_SCAN_BYTES, + remainingEntries: MAX_SCAN_ENTRIES, + }; + const skillsByName = new Map(); + + const scanDirectory = Effect.fn("scanAntigravitySkillDirectory")(function* ( + directory: string, + scope: string, + scanChildren: boolean, + ): Effect.fn.Return { + const info = yield* readIfPresent(fileSystem.stat(directory), directory); + if (info?.type !== "Directory") return; + const entries = yield* readIfPresent(fileSystem.readDirectory(directory), directory); + if (entries === undefined) return; + if (entries.length > budget.remainingEntries) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: directory, + }); + } + budget.remainingEntries -= entries.length; + + const sortedEntries = entries.toSorted(); + const skillFileName = sortedEntries.find((entry) => entry.toLowerCase() === "skill.md"); + if (skillFileName !== undefined) { + if (!skillFileName.endsWith(".md")) return; + const skillPath = path.join(directory, skillFileName); + const contents = yield* readSkill(skillPath, budget); + if (contents === undefined) return; + const skill = parseSkillFrontmatter(contents, skillFileName); + if (!skill || skillsByName.has(skill.name)) return; + skillsByName.set(skill.name, { + ...skill, + path: skillPath, + scope, + enabled: true, + }); + return; + } + if (scanChildren) { + const children = sortedEntries + .map((entry) => ({ entry, sortKey: skillPathSortKey(entry) })) + .sort((left, right) => + left.sortKey < right.sortKey ? -1 : left.sortKey > right.sortKey ? 1 : 0, + ); + for (const { entry } of children) { + yield* scanDirectory(path.join(directory, entry), scope, false); + } + } + }); + + for (const root of roots) { + yield* scanDirectory(root.directory, root.scope, true); + } + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts new file mode 100644 index 000000000..34fd81a42 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + AntigravitySettings, + ProviderDriverKind, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import { + buildAntigravityModelsFromSession, + makeAntigravityProvider, +} from "./AntigravityProvider.ts"; + +const decodeSettings = Schema.decodeSync(AntigravitySettings); +const instanceId = ProviderInstanceId.make("antigravity-test"); +const driver = ProviderDriverKind.make("antigravity"); + +const initializeResult = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {} }, + }, + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentInfo: { + name: "antigravity-acp", + title: "Google Antigravity", + version: "agy_acp_server_20260818_01_RC01", + }, +} satisfies EffectAcpSchema.InitializeResponse; + +const modelOptions = [ + { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, + { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, + { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, + { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, + { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, + { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, + { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, +]; + +const modelConfig = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gemini-3.7-flash-high", + options: modelOptions, +} satisfies EffectAcpSchema.SessionConfigOption; + +const sessionSetupResult = { + sessionId: "session-1", + models: { + currentModelId: "gemini-3.7-flash-high", + availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), + }, + configOptions: [ + modelConfig, + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "yolo", name: "YOLO" }, + ], + }, + ], +} satisfies EffectAcpSchema.NewSessionResponse; + +const started = { + sessionId: "session-1", + initializeResult, + sessionSetupResult, + modelConfigId: "model", +} satisfies AcpSessionRuntimeStartResult; + +const commands = [ + { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, + { name: "logout", description: "Sign out of Google" }, +] satisfies ReadonlyArray; + +const testLayer = Layer.merge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ServerSettingsService.layerTest(), +); + +type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; + +const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( + options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, +) { + const initialProbe = yield* Deferred.make(); + const probeCalls = yield* Ref.make(0); + const safetyCalls = yield* Ref.make(0); + const probe = yield* Ref.make>( + Deferred.await(initialProbe), + ); + const safety = yield* Ref.make>(Effect.succeed(options.safe ?? true)); + const provider = yield* makeAntigravityProvider( + decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), + { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Ref.update(probeCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(probe)), + Effect.flatten, + ), + supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(safety)), + Effect.flatten, + ), + }, + ); + const initialUpdate = yield* Stream.toPull( + provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), + ), + ); + const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( + Effect.andThen(initialUpdate), + Effect.asVoid, + ); + return { + provider, + probe, + probeCalls, + safety, + safetyCalls, + initialProbe, + initialUpdate, + initialize, + }; +}); + +describe("Antigravity model catalog", () => { + it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { + const models = buildAntigravityModelsFromSession(sessionSetupResult); + expect(models.map((model) => [model.slug, model.name])).toEqual( + modelOptions.map((option) => [option.value, option.name]), + ); + expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + ]); + expect( + models + .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) + .map((model) => model.slug), + ).toEqual(["gemini-3.7-flash-high"]); + expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); + expect(models.every((model) => !model.isCustom)).toBe(true); + }); + + it("uses legacy session models only when model config is absent", () => { + const fromLegacy = buildAntigravityModelsFromSession({ + models: sessionSetupResult.models, + }); + expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect( + buildAntigravityModelsFromSession({ + ...sessionSetupResult, + configOptions: [{ ...modelConfig, options: [] }], + }), + ).toEqual([]); + }); + + it("flattens native option groups without combining distinct model IDs", () => { + const models = buildAntigravityModelsFromSession({ + configOptions: [ + { + ...modelConfig, + currentValue: "gemini-pro-agent", + options: [ + { group: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, + { group: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, + ], + }, + ], + }); + expect(models.map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", + "gemini-pro-agent", + ]); + expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); + expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( + "gemini-pro-agent", + ); + }); +}); + +it.layer(testLayer)("Antigravity provider snapshots", (it) => { + it.effect("does not probe or run helper safety checks while disabled", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + enabled: false, + installed: false, + status: "disabled", + auth: { status: "unknown" }, + models: [], + setup: { canAuthenticate: true, canInstall: true }, + showInteractionModeToggle: false, + supportsConversationRollback: false, + supportsTextGeneration: false, + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + expect(yield* Ref.get(harness.safetyCalls)).toBe(0); + }), + ), + ); + + it.effect("records explicit sign-in while disabled without starting a health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const signedIn = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const [snapshot] = yield* signedIn; + expect(snapshot).toMatchObject({ + enabled: false, + installed: true, + status: "disabled", + auth: { status: "authenticated", type: "oauth-personal" }, + workspaceSnapshots: [], + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + }), + ), + ); + + it.effect("publishes the configured sign-in method before any account is checked", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Effect.succeed(initializeResult), + supportsTextGeneration: Effect.succeed(true), + auth: { type: "gemini-api-key", label: "Gemini API key" }, + }); + expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ + status: "unknown", + type: "gemini-api-key", + }); + }), + ), + ); + + it.effect("treats initialize as installation proof, not account or model discovery", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }); + yield* harness.initialize; + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + version: "agy_acp_server_20260818_01_RC01", + auth: { status: "unknown" }, + models: [], + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("publishes session metadata and native commands without another health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const nextReady = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const [snapshot] = yield* nextReady; + expect(snapshot).toMatchObject({ + status: "ready", + auth: { status: "authenticated", type: "oauth-personal" }, + supportsTextGeneration: true, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( + commands, + ); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("does not retain a disposable sign-in workspace", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot.models).toHaveLength(11); + expect(snapshot.slashCommands).toEqual(commands); + expect(snapshot.workspaceSnapshots).toEqual([]); + }), + ), + ); + + it.effect("clears all account metadata on sign-out and authentication failure", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* clear; + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unauthenticated" }, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* harness.provider.onConfigOptionsUpdated([modelConfig]); + expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + } + const refreshed = yield* harness.provider.snapshot.refresh; + expect(refreshed.auth.status).toBe("unauthenticated"); + expect(refreshed.supportsTextGeneration).toBe(false); + }), + ), + ); + + it.effect("replaces live model choices and accepts an empty catalog", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const before = yield* harness.provider.snapshot.getSnapshot; + const configOptions = [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash-high", + options: modelOptions.slice(0, 3), + }, + ]; + const nextSnapshot = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.models.length === 3), + ), + ); + yield* harness.provider.onConfigOptionsUpdated(configOptions); + expect((yield* nextSnapshot)[0]).toMatchObject({ + models: buildAntigravityModelsFromSession({ configOptions }), + auth: before.auth, + workspaceSnapshots: before.workspaceSnapshots, + slashCommands: commands, + }); + yield* harness.provider.onConfigOptionsUpdated([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + }), + ), + ); + + it.effect("replaces one account's catalog instead of combining accounts", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onSignedOut; + yield* harness.provider.onSessionStarted({ + ...started, + sessionSetupResult: { + sessionId: "new-account-session", + configOptions: [ + { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, + ], + }, + }); + expect( + (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), + ).toEqual(["gemini-pro-agent"]); + }), + ), + ); + + it.effect("retains known account metadata when a local health check fails", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set( + harness.probe, + Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), + ); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed: true, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); + }), + ), + ); + + it.effect("allows a slow packaged runtime health check to finish", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const initialized = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), + ); + + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("47 seconds"); + yield* Deferred.succeed(initialized, initializeResult); + const snapshot = yield* Fiber.join(refresh); + + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unknown" }, + }); + }), + ), + ); + + it.effect("closes a stalled health probe at its deadline", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const closed = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(closed, undefined)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("90 seconds"); + const snapshot = yield* Fiber.join(refresh); + yield* Deferred.await(closed); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("90 seconds"); + }), + ), + ); + + it.effect("distinguishes missing executables from a failed installed executable", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const failures = [ + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), + installed: false, + }, + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), + installed: true, + }, + { + error: new ProviderSetupError({ + instanceId, + operation: "resolve", + detail: "Antigravity is not installed.", + }), + installed: false, + }, + ]; + for (const { error, installed } of failures) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set(harness.probe, Effect.fail(error)); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toHaveLength(installed ? 11 : 0); + expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); + expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); + expect(snapshot.supportsTextGeneration).toBe(installed); + } + }), + ), + ); + + it.effect("does not let an old health result restore a signed-out account", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + const releaseProbe = yield* Deferred.make(); + const probeEntered = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(probeEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseProbe)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(probeEntered); + yield* harness.provider.onSignedOut; + yield* Deferred.succeed(releaseProbe, initializeResult); + const snapshot = yield* Fiber.join(refresh); + expect(snapshot).toMatchObject({ + auth: { status: "unauthenticated" }, + models: [], + supportsTextGeneration: false, + }); + }), + ), + ); + + it.effect("exposes helper support only when the supplied safety check allows it", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ safe: false }); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); + yield* Ref.set(harness.safety, Effect.succeed(true)); + yield* harness.provider.snapshot.refresh; + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); + }), + ), + ); + + it.effect("keeps discovered workspace skills through session and command updates", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const skills = [ + { + name: "deploy", + description: "Ship it", + path: "/workspace/.agent/skills/deploy", + enabled: true, + }, + ]; + const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); + expect(discovered.skills).toEqual(skills); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const after = yield* harness.provider.snapshot.getSnapshot; + expect( + after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, + ).toEqual(skills); + expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); + }), + ), + ); + + it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + for (let index = 0; index < 35; index++) { + yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); + } + const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); + expect(snapshot.workspaceSnapshots).toHaveLength(32); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); + expect(snapshot.slashCommands).toEqual(commands); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 000000000..ef917b5a0 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,425 @@ +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + type AntigravitySettings, + type ProviderSetupError, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + buildServerProvider, + isCommandMissingCause, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const MAX_WORKSPACE_SNAPSHOTS = 32; +const HEALTH_CHECK_TIMEOUT = "90 seconds"; +const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; +const AUTH_UNCHECKED_MESSAGE = + "Antigravity is installed. Google account access is not checked yet."; + +type SessionSetupResult = Pick< + AcpSessionRuntimeStartResult["sessionSetupResult"], + "configOptions" | "models" +>; + +/** Keep the native model IDs, including model-specific thinking levels. */ +export function buildAntigravityModelsFromSession( + setup: SessionSetupResult, +): ReadonlyArray { + const config = setup.configOptions?.find( + (option) => option.id === "model" || option.category === "model", + ); + const currentValue = + config?.type === "select" ? config.currentValue : setup.models?.currentModelId; + const entries = + config?.type === "select" + ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) + : config === undefined + ? (setup.models?.availableModels.map((model) => ({ + value: model.modelId, + name: model.name, + })) ?? []) + : []; + const seen = new Set(); + return entries.flatMap((entry): ServerProviderModel[] => { + if (!entry.value.trim() || seen.has(entry.value)) return []; + seen.add(entry.value); + return [ + { + slug: entry.value, + name: entry.name.trim() ? entry.name : entry.value, + isCustom: false, + ...(entry.value === currentValue + ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } + : {}), + capabilities: EMPTY_MODEL_CAPABILITIES, + }, + ]; + }); +} + +function nativeCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return commands.flatMap((command): ServerProviderSlashCommand[] => { + if (!command.name.trim() || seen.has(command.name)) return []; + seen.add(command.name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { + name: command.name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }, + ]; + }); +} + +function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { + if (error._tag === "AcpSpawnError") { + return ( + isCommandMissingCause(error.cause) || + (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") + ); + } + return ( + error._tag === "ProviderSetupError" && + error.operation === "resolve" && + /not installed|missing|incomplete|does not publish/i.test(error.detail) + ); +} + +interface AntigravityProviderState { + readonly draft: ServerProviderDraft; + readonly authRevision: number; +} + +interface AntigravityProviderOptions { + readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect; + readonly probe: Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError | ProviderSetupError + >; + readonly supportsTextGeneration: Effect.Effect; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; + /** Auth type and label published once a session authenticates. */ + readonly auth?: { readonly type: string; readonly label: string }; +} + +/** Health uses initialize only. Session callbacks supply account-specific metadata. */ +export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( + settings: AntigravitySettings, + options: AntigravityProviderOptions, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const initialDraft = { + ...buildServerProvider({ + presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, + enabled: settings.enabled, + checkedAt, + models: [], + probe: { + installed: false, + version: null, + status: "warning", + // The configured method rides along so the registry can tell a saved + // account for this method from one left by a previous configuration. + auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, + message: settings.enabled + ? "Checking Antigravity availability." + : "Antigravity is disabled in T3 Code settings.", + }, + }), + setup: { canAuthenticate: true, canInstall: true }, + supportsConversationRollback: false, + supportsTextGeneration: false, + workspaceSnapshots: [], + } satisfies ServerProviderDraft; + const metadata = yield* SubscriptionRef.make({ + draft: initialDraft, + authRevision: 0, + }); + // Skills the driver discovered on disk per workspace. Session callbacks + // rewrite the workspace entry with native commands and must keep these, or + // the registry drops the suggestions and never re-reads the workspace. + const discoveredSkills = new Map(); + const getSnapshot = SubscriptionRef.get(metadata).pipe( + Effect.flatMap((state) => options.stampIdentity(state.draft)), + ); + + const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { + if (!settings.enabled) return yield* getSnapshot; + const before = yield* SubscriptionRef.get(metadata); + const result = yield* options.probe.pipe( + Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), + Effect.result, + ); + const initialized = + Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; + const failure = Result.isFailure(result) ? result.failure : undefined; + const missingInstallation = failure !== undefined && isMissingInstallation(failure); + const errorMessage = + initialized !== undefined + ? undefined + : failure?._tag === "ProviderSetupError" + ? failure.detail.trim() || "Antigravity could not complete its local health check." + : missingInstallation + ? "Antigravity is not installed or its executable could not be found." + : failure + ? "Antigravity could not complete its local health check." + : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; + const supportsTextGeneration = + initialized !== undefined ? yield* options.supportsTextGeneration : false; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { + if (state.authRevision !== before.authRevision) return state; + const { message: _previousMessage, ...draft } = state.draft; + const authenticated = draft.auth.status === "authenticated"; + const message = + errorMessage ?? + (authenticated + ? undefined + : draft.auth.status === "unauthenticated" + ? SIGN_IN_MESSAGE + : AUTH_UNCHECKED_MESSAGE); + return { + ...state, + draft: { + ...draft, + installed: !missingInstallation, + version: initialized?.agentInfo?.version || draft.version, + status: errorMessage ? "error" : authenticated ? "ready" : "warning", + checkedAt: updatedAt, + ...(missingInstallation + ? { + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + } + : {}), + ...(initialized !== undefined + ? { + supportsTextGeneration: + supportsTextGeneration && draft.auth.status !== "unauthenticated", + } + : {}), + ...(message ? { message } : {}), + }, + } satisfies AntigravityProviderState; + }); + return yield* options.stampIdentity(next.draft); + }); + + const maintenanceCapabilities = + options.maintenanceCapabilities ?? + makeManualOnlyProviderMaintenanceCapabilities({ + provider: ProviderDriverKind.make("antigravity"), + packageName: null, + }); + const managed = yield* makeManagedServerProvider({ + maintenanceCapabilities, + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed(settings), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => getSnapshot, + checkProvider: checkProvider(), + enrichSnapshot: ({ publishSnapshot }) => + SubscriptionRef.changes(metadata).pipe( + Stream.runForEach((state) => + options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), + ), + ), + }); + + const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( + started: AcpSessionRuntimeStartResult, + cwd?: string, + ) { + const before = yield* SubscriptionRef.get(metadata); + const supportsTextGeneration = yield* options.supportsTextGeneration; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if ( + state.authRevision !== before.authRevision && + state.draft.auth.status === "unauthenticated" + ) { + return state; + } + const { message: _previousMessage, ...draft } = state.draft; + const workspaces = draft.workspaceSnapshots ?? []; + const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; + return { + authRevision: state.authRevision + 1, + draft: { + ...draft, + installed: true, + status: settings.enabled ? "ready" : "disabled", + version: started.initializeResult.agentInfo?.version || draft.version, + auth: { + status: "authenticated", + type: options.auth?.type ?? "oauth-personal", + label: options.auth?.label ?? "Google account", + }, + checkedAt: updatedAt, + models: buildAntigravityModelsFromSession(started.sessionSetupResult), + supportsTextGeneration, + ...(cwd + ? { + workspaceSnapshots: [ + ...workspaces.filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands: workspace?.slashCommands ?? draft.slashCommands, + skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + } satisfies AntigravityProviderState; + }); + }); + + const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( + configOptions: ReadonlyArray, + ) { + const models = buildAntigravityModelsFromSession({ configOptions }); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status !== "authenticated") return state; + return { ...state, draft: { ...state.draft, models } }; + }); + }); + + const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( + commands: ReadonlyArray, + cwd?: string, + ) { + const slashCommands = nativeCommands(commands); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status === "unauthenticated") return state; + return { + ...state, + draft: { + ...state.draft, + slashCommands, + ...(cwd + ? { + workspaceSnapshots: [ + ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands, + skills: + state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? + discoveredSkills.get(cwd) ?? + [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + }; + }); + }); + + const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update( + metadata, + (state) => + ({ + authRevision: state.authRevision + 1, + draft: { + ...state.draft, + auth: { status: "unauthenticated" }, + status: settings.enabled ? "warning" : "disabled", + message: SIGN_IN_MESSAGE, + checkedAt: updatedAt, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }, + }) satisfies AntigravityProviderState, + ); + discoveredSkills.clear(); + }); + + const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( + cwd: string, + skills?: ServerProvider["skills"], + ) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + const existing = state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd); + return { + ...state, + draft: { + ...state.draft, + workspaceSnapshots: [ + ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt, + slashCommands: existing?.slashCommands ?? state.draft.slashCommands ?? [], + skills: skills ?? existing?.skills ?? [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + }, + }; + }); + if (skills) { + discoveredSkills.delete(cwd); + discoveredSkills.set(cwd, skills); + while (discoveredSkills.size > MAX_WORKSPACE_SNAPSHOTS) { + const oldest = discoveredSkills.keys().next().value; + if (oldest === undefined) break; + discoveredSkills.delete(oldest); + } + } + const snapshot = yield* getSnapshot; + const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); + return workspace + ? { ...snapshot, slashCommands: workspace.slashCommands, skills: workspace.skills } + : snapshot; + }); + + return { + snapshot: { ...managed, getSnapshot }, + onSessionStarted, + onConfigOptionsUpdated, + onAvailableCommands, + onSignedOut: clearAccountMetadata(), + onAuthRequired: clearAccountMetadata(), + snapshotForCwd, + }; +}); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts new file mode 100644 index 000000000..0e4e60c0c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -0,0 +1,90 @@ +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderSessionManagerV2 } from "../../orchestration-v2/ProviderSessionManager.ts"; + +export const makeProviderAuthService = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + const sessions = yield* ProviderSessionManagerV2; + + const getController = Effect.fn("ProviderAuthService.getController")(function* ( + instanceId: ProviderInstanceId, + operation: string, + ) { + const instance = yield* registry.getInstance(instanceId); + if (!instance?.auth) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: instance + ? "This provider does not support sign-in in T3 Code." + : "This provider instance is no longer available.", + }); + } + return instance.auth; + }); + + const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( + instanceId: ProviderInstanceId, + ) { + yield* sessions.closeInstance(instanceId).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ); + }); + + return ProviderAuthService.of({ + start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "start"); + return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + }), + complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "complete"); + return yield* auth.complete(ownerSessionId, input); + }), + cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "cancel"); + return yield* auth.cancel(ownerSessionId, input.flowId); + }), + logout: Effect.fn("ProviderAuthService.logout")(function* (input) { + const auth = yield* getController(input.instanceId, "logout"); + return yield* auth.logout(stopSessions(input.instanceId)); + }), + subscribe: (input, ownerSessionId) => + Effect.gen(function* () { + const changes = yield* registry.subscribeChanges; + const initial = yield* getController(input.instanceId, "subscribe"); + return Stream.concat( + Stream.succeed(initial), + Stream.fromSubscription(changes).pipe( + Stream.mapEffect(() => getController(input.instanceId, "subscribe")), + ), + ).pipe( + Stream.changesWith((previous, next) => previous === next), + Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), + ); + }).pipe(Stream.unwrap), + tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( + function* (input) { + const instance = yield* registry.getInstance(input.instanceId); + if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { + return false; + } + yield* instance.auth.logout(stopSessions(input.instanceId)); + return true; + }, + ), + }); +}); + +export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); diff --git a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts index 747654fe3..8e80a68d6 100644 --- a/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,3 +1,4 @@ +import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as Layer from "effect/Layer"; import { @@ -16,6 +17,7 @@ import { IdAllocatorV2, layer as idAllocatorLayer } from "../../orchestration-v2 import { layer as providerContinuationRequestsLayer } from "../../orchestration-v2/ProviderContinuationRequests.ts"; export type ProviderOrchestrationAdapterInfrastructure = + | AntigravityInstallation | ClaudeAgentSdkQueryRunner | CodexAppServerClientFactory | CursorAgentSdkRunner @@ -28,6 +30,7 @@ export type ProviderOrchestrationAdapterInfrastructure = * Effect layer memoization yields one shared queue. */ export const ProviderOrchestrationAdapterInfrastructureLive = Layer.mergeAll( + AntigravityInstallation.layer, claudeAgentSdkQueryRunnerLiveLayer, codexAppServerClientFactoryFromSettingsLayer, cursorAgentSdkRunnerLiveLayer, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 44f465a00..a04f01769 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2000,6 +2000,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "antigravity", "claudeAgent", "codex", "cursor", diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index d8be9c770..5c341adaa 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -34,6 +34,8 @@ import type * as Scope from "effect/Scope"; import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import type { HermesSessionCatalogShape } from "../hermes/HermesSessionCatalog.ts"; +import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; +import type { ServerProvider } from "@t3tools/contracts"; import type { ProviderDriverError } from "./Errors.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -76,6 +78,9 @@ export interface ProviderInstance { ProviderConsumeResetCreditResult, ProviderDriverError >; + readonly auth?: ProviderAuthController; + readonly refreshModels?: () => Effect.Effect; + readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly orchestrationAdapter: ProviderAdapterV2Shape; readonly textGeneration: TextGenerationShape; /** diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts new file mode 100644 index 000000000..ea89edf78 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -0,0 +1,59 @@ +import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +export interface ProviderAuthController { + readonly start: ( + ownerSessionId: string, + stopSessions?: Effect.Effect, + ) => Effect.Effect; + readonly complete: ( + ownerSessionId: string, + input: { readonly flowId: string; readonly callbackUrl: string }, + ) => Effect.Effect; + readonly cancel: ( + ownerSessionId: string, + flowId: string, + ) => Effect.Effect; + /** The controller closes process admission before it stops routed sessions. */ + readonly logout: ( + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly subscribe: (ownerSessionId: string) => Stream.Stream; + readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; +} + +interface ProviderAuthTarget { + readonly instanceId: ProviderInstanceId; +} + +export interface ProviderAuthServiceShape { + readonly start: ( + input: ProviderAuthTarget, + ownerSessionId: string, + ) => Effect.Effect; + readonly complete: ( + input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly cancel: ( + input: ProviderAuthTarget & { readonly flowId: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly logout: ( + input: ProviderAuthTarget, + ) => Effect.Effect; + readonly subscribe: ( + input: ProviderAuthTarget, + ownerSessionId: string, + ) => Stream.Stream; + readonly tryHandlePromptCommand: ( + input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, + ) => Effect.Effect; +} + +export class ProviderAuthService extends Context.Service< + ProviderAuthService, + ProviderAuthServiceShape +>()("t3/provider/Services/ProviderAuthService") {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index f1212ce50..3051aec20 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -62,7 +62,17 @@ export interface AcpSessionEventStreamBarrier { readonly acknowledge: Deferred.Deferred; } -export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; +export type AcpSessionRuntimeEvent = + | AcpParsedSessionEvent + | AcpSessionEventStreamBarrier + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly availableCommands: ReadonlyArray; + }; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); @@ -72,12 +82,19 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; } export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly resumeMethod?: "load" | "resume"; + readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; + readonly cancelTimeout?: Duration.Input; + readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; + readonly transformSessionUpdate?: EffectAcpClient.AcpClientOptions["transformSessionUpdate"]; + readonly onStderr?: (text: string) => Effect.Effect; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly interruptPromptOnCancel?: boolean; @@ -1159,6 +1176,10 @@ export class AcpSessionRuntime extends Context.Service< * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. * Concurrent calls share the same in-flight startup and a failed startup may be retried. */ + readonly initialize: () => Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >; readonly start: () => Effect.Effect; /** Stream of parsed ACP session events emitted after startup. */ readonly getEvents: () => Stream.Stream; @@ -1294,6 +1315,7 @@ export const make = ( const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; + const stderrFailure = yield* Deferred.make(); const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); @@ -1327,6 +1349,7 @@ export const make = ( logRequest({ method, payload, status: "started" }).pipe( Effect.flatMap(() => effect.pipe( + Effect.raceFirst(Deferred.await(stderrFailure)), Effect.tap((result) => logRequest({ method, @@ -1350,7 +1373,9 @@ export const make = ( const spawnCommand = yield* resolveSpawnCommand( options.spawn.command, options.spawn.args, - options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + options.spawn.env + ? { env: options.spawn.env, extendEnv: options.spawn.extendEnv ?? true } + : {}, ); const linuxCgroupLease = options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" @@ -1411,7 +1436,9 @@ export const make = ( .spawn( ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(spawnEnvironment ? { env: spawnEnvironment, extendEnv: true } : {}), + ...(spawnEnvironment + ? { env: spawnEnvironment, extendEnv: options.spawn.extendEnv ?? true } + : {}), ...(options.ownDetachedProcessGroup === undefined ? {} : { detached: options.ownDetachedProcessGroup }), @@ -1578,8 +1605,33 @@ export const make = ( ); } + if (options.onStderr) { + const onStderr = options.onStderr; + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach(onStderr), + Effect.catch((cause) => { + const error = Schema.is(EffectAcpErrors.AcpError)(cause) + ? cause + : new EffectAcpErrors.AcpTransportError({ + detail: "Could not read ACP process output", + cause, + }); + return Deferred.fail(stderrFailure, error).pipe( + Effect.andThen(options.onTermination?.(error) ?? Effect.void), + Effect.andThen(child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore)), + ); + }), + Effect.forkIn(runtimeScope), + ); + } + const acpContext = yield* Layer.build( EffectAcpClient.layerChildProcess(child, { + ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), + ...(options.transformSessionUpdate + ? { transformSessionUpdate: options.transformSessionUpdate } + : {}), ...(options.protocolLogging?.logIncoming !== undefined ? { logIncoming: options.protocolLogging.logIncoming } : {}), @@ -1605,6 +1657,23 @@ export const make = ( yield* acp.handleSessionUpdate((notification) => Effect.gen(function* () { + const initialState = yield* Ref.get(startStateRef); + const belongsToSession = + initialState._tag !== "Started" || + notification.sessionId === initialState.result.sessionId; + if (belongsToSession && notification.update.sessionUpdate === "config_option_update") { + yield* Ref.set(configOptionsRef, notification.update.configOptions); + yield* Queue.offer(eventQueue, { + _tag: "ConfigOptionsUpdated", + configOptions: notification.update.configOptions, + }); + } + if (belongsToSession && notification.update.sessionUpdate === "available_commands_update") { + yield* Queue.offer(eventQueue, { + _tag: "AvailableCommandsUpdated", + availableCommands: notification.update.availableCommands, + }); + } const gate = yield* Ref.get(sessionLoadGateRef); // A different session can still have an in-flight prompt while this // load replays history, so quarantine only the loading session. @@ -1898,18 +1967,16 @@ export const make = ( ), ); + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + const initialize = yield* Effect.cached( + runLoggedRequest("initialize", initializePayload, acp.agent.initialize(initializePayload)), + ); const startOnce = Effect.gen(function* () { - const initializePayload = { - protocolVersion: 1, - clientCapabilities: initializeClientCapabilities, - clientInfo: options.clientInfo, - } satisfies EffectAcpSchema.InitializeRequest; - - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - acp.agent.initialize(initializePayload), - ); + const initializeResult = yield* initialize; const authenticateAfterRequired = ( authRequiredError: EffectAcpErrors.AcpError, @@ -1966,7 +2033,9 @@ export const make = ( } satisfies EffectAcpSchema.LoadSessionRequest; sessionId = options.resumeSessionId; - sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); + sessionSetupResult = yield* options.resumeMethod === "resume" + ? runLoggedRequest("session/resume", loadPayload, acp.agent.resumeSession(loadPayload)) + : runLoadSessionWithReplayIdle(loadPayload, initializeResult); } else { const createPayload = { cwd: options.cwd, @@ -2060,6 +2129,7 @@ export const make = ( handleUnknownExtNotification: acp.handleUnknownExtNotification, handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, + initialize: () => initialize, start: () => start, getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { @@ -2186,27 +2256,59 @@ export const make = ( ); }), ), - cancel: getStartedState.pipe( - Effect.flatMap((started) => - options.interruptPromptOnCancel === false - ? acp.agent.cancel({ + cancel: + options.cancelBehavior === "wait-for-prompt" + ? Effect.gen(function* () { + const started = yield* getStartedState; + const activePrompt = yield* Ref.get(activePromptFiberRef); + yield* acp.agent.cancel({ sessionId: started.sessionId, ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), - }) - : Effect.gen(function* () { - const activePromptFiber = yield* Ref.get(activePromptFiberRef); - if (Option.isSome(activePromptFiber)) { - yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); - } - yield* acp.agent - .cancel({ - sessionId: started.sessionId, - ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), - }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); - }), - ), - ), + }); + if (Option.isNone(activePrompt)) return; + yield* Fiber.join(activePrompt.value).pipe( + Effect.timeoutOrElse({ + duration: options.cancelTimeout ?? "30 seconds", + orElse: () => { + const error = new EffectAcpErrors.AcpTransportError({ + detail: "The ACP agent did not finish cancellation. Its process was stopped.", + cause: undefined, + }); + return Deferred.fail(stderrFailure, error).pipe( + Effect.andThen( + child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore), + ), + Effect.andThen(Effect.fail(error)), + ); + }, + }), + ); + // The prompt owns the serialization permit until final stream cleanup has completed. + yield* promptSerializationSemaphore.withPermits(1)(Effect.void); + }) + : getStartedState.pipe( + Effect.flatMap((started) => + options.interruptPromptOnCancel === false + ? acp.agent.cancel({ + sessionId: started.sessionId, + ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), + }) + : Effect.gen(function* () { + const activePromptFiber = yield* Ref.get(activePromptFiberRef); + if (Option.isSome(activePromptFiber)) { + yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + } + yield* acp.agent + .cancel({ + sessionId: started.sessionId, + ...(options.cancelMeta === undefined + ? {} + : { _meta: options.cancelMeta }), + }) + .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + }), + ), + ), ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), setMode: (modeId) => Ref.get(modeStateRef).pipe( diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts new file mode 100644 index 000000000..d49db7646 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -0,0 +1,363 @@ +import { + ANTIGRAVITY_DEFAULT_MODEL, + type AntigravityAuthMethod, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type ChatAttachment, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + makeAntigravityStderrHandler, + makeAntigravityStdoutTransform, +} from "../antigravityAuthSupport.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; + +export interface AntigravityAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + | "authMethodId" + | "cancelBehavior" + | "clientCapabilities" + | "onStderr" + | "resumeMethod" + | "transformSessionUpdate" + | "transformStdout" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + /** + * Advertise `fs.readTextFile` and `fs.writeTextFile`. The agent then routes + * workspace reads and writes through T3, which turns each edit into a + * `session/request_permission` with the file content, instead of writing + * through its own tools. Chat sessions turn this on. Setup, probe, and text + * generation helpers leave it off so they never touch a workspace. + */ + readonly clientFileSystem?: boolean; + /** ACP `authenticate` method id. Defaults to the personal Google account flow. */ + readonly authMethod?: AntigravityAuthMethod; +} + +/** Normal launches reject browser login; only the auth flow supplies `onAuthorizationUrl`. */ +export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( + input: AntigravityAcpRuntimeInput, +): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + authMethodId: input.authMethod ?? "oauth-personal", + resumeMethod: "resume", + cancelBehavior: "wait-for-prompt", + clientCapabilities: { + fs: { + readTextFile: input.clientFileSystem === true, + writeTextFile: input.clientFileSystem === true, + }, + terminal: false, + }, + transformStdout: makeAntigravityStdoutTransform( + input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, + ), + onStderr: makeAntigravityStderrHandler( + input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, + ), + transformSessionUpdate: normalizeAntigravitySessionUpdate, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); +}); + +export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { + switch (runtimeMode) { + case "full-access": + return "yolo"; + case "auto-accept-edits": + return "auto_edit"; + case "auto": + case "approval-required": + return "default"; + } +} + +export function antigravityModelOptions( + configOptions: ReadonlyArray, +) { + const model = configOptions.find((option) => option.id === "model"); + if (model?.type !== "select") return []; + return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); +} + +/** + * Resolves the model a turn should run on. A saved selection is reapplied + * as-is. The provider default alias resolves to `defaultModel` when the + * account offers it, so T3 can pick a newer model than the one Google marks + * current. Otherwise the agent's current selection stands. + */ +export function resolveAntigravityModel(input: { + readonly configOptions: ReadonlyArray; + readonly model: string | null | undefined; + readonly defaultModel?: string | undefined; +}): string | undefined { + const modelConfig = input.configOptions.find((option) => option.id === "model"); + const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; + if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model; + const options = antigravityModelOptions(input.configOptions); + return input.defaultModel && options.some((option) => option.value === input.defaultModel) + ? input.defaultModel + : current; +} + +/** Never replace a saved selection with the default returned by a cold resume. */ +export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpModelSelection")( + function* (input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setModel" + >; + readonly model: string | null | undefined; + /** Model to select for the provider default alias. See `resolveAntigravityModel`. */ + readonly defaultModel?: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; + }): Effect.fn.Return { + const configOptions = yield* input.runtime.getConfigOptions; + const modelConfig = configOptions.find((option) => option.id === "model"); + const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; + const resolved = resolveAntigravityModel({ + configOptions, + model: input.model, + defaultModel: input.defaultModel, + }); + // The default alias never sends an internal ID. It selects the manifest + // default when that differs from the agent's current model, and otherwise + // leaves the agent's choice alone. + const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL; + if (resolved === undefined || (!explicit && resolved === current)) return current; + const options = antigravityModelOptions(configOptions); + if (!options.some((option) => option.value === resolved)) { + return yield* Effect.fail( + input.mapError( + EffectAcpErrors.AcpRequestError.invalidParams( + `Antigravity model '${resolved}' is unavailable for this Google account. Select an available model.`, + ), + ), + ); + } + yield* input.runtime.setModel(resolved).pipe(Effect.mapError(input.mapError)); + return resolved; + }, +); + +const IMAGE_MIME_TYPES = new Set(["image/bmp", "image/jpeg", "image/png", "image/webp"]); +// Formats the bundled SDK's Audio type accepts. Anything else is rejected up front. +const AUDIO_MIME_TYPES = new Set([ + "audio/aac", + "audio/flac", + "audio/mp3", + "audio/mpeg", + "audio/mp4", + "audio/m4a", + "audio/x-m4a", + "audio/ogg", + "audio/wav", + "audio/x-wav", + "audio/webm", +]); +const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const TEXT_MIME_TYPES = new Set([ + "application/json", + "application/ld+json", + "application/javascript", + "application/typescript", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/x-sh", +]); +const TEXT_FILE_EXTENSIONS = new Set([ + ".txt", + ".md", + ".mdx", + ".json", + ".jsonl", + ".yaml", + ".yml", + ".toml", + ".xml", + ".csv", + ".tsv", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".html", + ".css", + ".scss", + ".less", + ".py", + ".rs", + ".go", + ".java", + ".kt", + ".swift", + ".c", + ".h", + ".cc", + ".cpp", + ".hpp", + ".cs", + ".rb", + ".php", + ".sh", + ".bash", + ".zsh", + ".sql", + ".graphql", + ".svelte", + ".vue", + ".log", + ".diff", + ".patch", + ".ini", + ".conf", +]); +export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; +const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; + +/** Sends uploads as native ACP content instead of workspace path hints. */ +export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { + readonly input: string | undefined; + readonly attachments: ReadonlyArray | undefined; + readonly attachmentsDir: string; +}): Effect.fn.Return< + ReadonlyArray, + EffectAcpErrors.AcpError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blocks: Array = []; + const text = input.input?.trim(); + if (text) blocks.push({ type: "text", text }); + let totalBytes = 0; + + for (const attachment of input.attachments ?? []) { + const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; + const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); + const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); + const pdf = + (attachment.type === "file" || attachment.type === "pdf") && mimeType === "application/pdf"; + const textFile = + attachment.type === "file" && + (mimeType.startsWith("text/") || + TEXT_MIME_TYPES.has(mimeType) || + TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); + if (!image && !audio && !pdf && !textFile) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, + ); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: input.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Invalid attachment '${attachment.name}'.`, + ); + } + const info = yield* fileSystem + .stat(attachmentPath) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ), + ), + ); + const size = Number(info.size); + const limit = image + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : audio + ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES + : pdf + ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + totalBytes += size; + if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`, + ); + } + const uri = yield* path.toFileUrl(attachmentPath).pipe( + Effect.map((url) => url.href), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`), + ), + ); + if (pdf) { + blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType }); + continue; + } + const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe( + Stream.runCollect, + Effect.map((chunks) => Buffer.concat(chunks)), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ), + ), + ); + totalBytes += bytes.length - size; + if (bytes.length > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' changed while being read and is too large.`, + ); + } + if (image) { + blocks.push({ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType }); + } else if (audio) { + blocks.push({ type: "audio", data: Buffer.from(bytes).toString("base64"), mimeType }); + } else { + const decoded = yield* Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: () => + EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' is not a UTF-8 text file.`, + ), + }); + if (decoded.includes("\0")) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' contains binary data.`, + ); + } + blocks.push({ type: "resource", resource: { uri, mimeType, text: decoded } }); + } + } + if (blocks.length === 0) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + "A turn requires text or supported attachments.", + ); + } + return blocks; +}); diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.test.ts b/apps/server/src/provider/acp/AntigravityClientFiles.test.ts new file mode 100644 index 000000000..c21c6e2c2 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityClientFiles.test.ts @@ -0,0 +1,65 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { + readAntigravityClientTextFile, + writeAntigravityClientTextFile, +} from "./AntigravityClientFiles.ts"; + +it.layer(NodeServices.layer)("Antigravity client files", (it) => { + it.effect("reads native one-based line ranges and writes nested workspace files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const target = path.join(root, "nested", "file.txt"); + const input = { fileSystem, path, allowedRoots: [root] }; + yield* writeAntigravityClientTextFile({ + ...input, + request: { sessionId: "test", path: target, content: "one\ntwo\nthree\n" }, + }); + const result = yield* readAntigravityClientTextFile({ + ...input, + request: { sessionId: "test", path: target, line: 2, limit: 1 }, + }); + assert.deepEqual(result, { content: "two" }); + }), + ); + it.effect.skipIf(!symlinksSupported)( + "rejects leaf and ancestor symlinks outside the workspace", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temp = yield* fileSystem.makeTempDirectoryScoped(); + const root = path.join(temp, "workspace"); + const outside = path.join(temp, "outside"); + yield* fileSystem.makeDirectory(root); + yield* fileSystem.makeDirectory(outside); + yield* fileSystem.writeFileString(path.join(outside, "file.txt"), "outside"); + yield* fileSystem.symlink(path.join(outside, "file.txt"), path.join(root, "leaf")); + yield* fileSystem.symlink(outside, path.join(root, "parent")); + const input = { fileSystem, path, allowedRoots: [root] }; + const read = yield* readAntigravityClientTextFile({ + ...input, + request: { sessionId: "test", path: path.join(root, "leaf") }, + }).pipe(Effect.exit); + const write = yield* writeAntigravityClientTextFile({ + ...input, + request: { + sessionId: "test", + path: path.join(root, "parent", "new", "file.txt"), + content: "changed", + }, + }).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(read)); + assert.isTrue(Exit.isFailure(write)); + assert.equal(yield* fileSystem.readFileString(path.join(outside, "file.txt")), "outside"); + assert.isFalse(yield* fileSystem.exists(path.join(outside, "new"))); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts new file mode 100644 index 000000000..54c6858d5 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -0,0 +1,137 @@ +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; + +function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** Resolves an agent-supplied path and rejects anything outside the session roots. */ +const resolveClientFilePath = Effect.fn("AntigravityAdapter.resolveClientFilePath")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly requestPath: string; + }) { + const { path } = input; + const resolved = path.resolve(input.requestPath); + // Resolve the closest existing ancestor as well as an existing leaf. A new + // nested file can still be reached through a symlink above its parent. + let ancestor = resolved; + while ( + !(yield* input.fileSystem + .exists(ancestor) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + "Could not inspect the requested file path.", + ), + ), + )) + ) { + const parent = path.dirname(ancestor); + if (parent === ancestor) break; + ancestor = parent; + } + const realAncestor = yield* input.fileSystem + .realPath(ancestor) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + "Could not resolve the requested file path.", + ), + ), + ); + const real = path.resolve(realAncestor, path.relative(ancestor, resolved)); + const roots = yield* Effect.forEach(input.allowedRoots, (root) => + input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)), + ); + if (!roots.some((root) => isInsideRoot(path, root, real))) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Path '${input.requestPath}' is outside the session workspace.`, + ); + } + return real; + }, +); + +export const readAntigravityClientTextFile = Effect.fn("AntigravityAdapter.readClientTextFile")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly request: EffectAcpSchema.ReadTextFileRequest; + }): Effect.fn.Return { + const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); + const info = yield* input.fileSystem + .stat(filePath) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.resourceNotFound( + `File '${input.request.path}' not found.`, + ), + ), + ); + if (info.type !== "File" || Number(info.size) > CLIENT_FILE_MAX_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `File '${input.request.path}' is not a readable text file under ${CLIENT_FILE_MAX_BYTES} bytes.`, + ); + } + const chunks = yield* input.fileSystem + .stream(filePath, { bytesToRead: CLIENT_FILE_MAX_BYTES + 1 }) + .pipe( + Stream.runCollect, + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.internalError(`Could not read '${input.request.path}'.`), + ), + ); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (size > CLIENT_FILE_MAX_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + "File exceeds the text read limit.", + ); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder().decode(bytes); + const line = input.request.line ?? undefined; + const limit = input.request.limit ?? undefined; + if (line === undefined && limit === undefined) { + return { content: text }; + } + // ACP lines are 1-indexed. `limit` is a line count. + const lines = text.split("\n"); + const start = Math.max(0, (line ?? 1) - 1); + const end = limit === undefined ? lines.length : Math.min(lines.length, start + limit); + return { content: lines.slice(start, end).join("\n") }; + }, +); + +export const writeAntigravityClientTextFile = Effect.fn("AntigravityAdapter.writeClientTextFile")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly request: EffectAcpSchema.WriteTextFileRequest; + }): Effect.fn.Return { + const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); + yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe( + Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`), + ), + ); + return {}; + }, +); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts new file mode 100644 index 000000000..4e7541ee8 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import * as EffectAcpSchema from "effect-acp/schema"; + +import { + extractAntigravityUserInputQuestion, + isAntigravityOpenCommand, + antigravityApprovalOptions, + antigravitySubagentOutput, + isAntigravitySubagentReplayStart, + classifyAntigravitySubagentToolCall, + isAntigravityUserInputRequest, + makeAntigravityUserInputResponse, + normalizeAntigravitySessionUpdate, + normalizeAntigravityToolCall, + sanitizeAntigravityToolPayload, + selectAntigravityPermissionOptionId, +} from "./AntigravityProtocol.ts"; +import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; + +const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification); + +describe("native Antigravity subagent tools", () => { + it("recognizes only native invocation titles and excludes MCP tools", () => { + const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} }; + for (const title of ["Running start_subagent", "Run start_subagent?"]) { + expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent"); + expect( + classifyAntigravitySubagentToolCall( + { ...toolCall, title }, + { update: { _meta: { is_mcp_tool_call: true } } }, + ), + ).toBe("mcp"); + } + for (const title of [ + "Running subagent", + "start_subagent", + "Run command", + "Running manage_task", + ]) { + expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBeUndefined(); + } + expect( + classifyAntigravitySubagentToolCall( + { ...toolCall, title: "Running start_subagent", kind: "execute" }, + {}, + ), + ).toBeUndefined(); + }); + + it("recognizes history starts and bounds the launch output", () => { + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." }, + }), + ).toBe(false); + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call", status: "completed" }, + }), + ).toBe(true); + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call_update", status: "completed" }, + }), + ).toBe(false); + expect( + antigravitySubagentOutput({ + toolCallId: "trajectory:4", + data: { rawOutput: " Finished review. " }, + }), + ).toBe("Finished review."); + const result = antigravitySubagentOutput({ + toolCallId: "trajectory:4", + data: { rawOutput: `${"x".repeat(16_000)}The result.` }, + }); + expect(result?.length).toBeLessThan(8_100); + expect(result?.endsWith("The result.")).toBe(true); + expect( + antigravitySubagentOutput({ toolCallId: "trajectory:4", data: { rawOutput: {} } }), + ).toBeUndefined(); + }); +}); + +const questionRequest = { + sessionId: "session-1", + toolCall: { + toolCallId: "interaction_9960062f", + status: "pending", + title: "Which result label should be used for the verification?", + rawInput: {}, + }, + options: [ + { optionId: "1", name: "Verified", kind: "allow_once" }, + { optionId: "2", name: "Needs review", kind: "allow_once" }, + ], +} satisfies EffectAcpSchema.RequestPermissionRequest; + +const commandStarted = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "fc28d0af6ad14be8bbba20f4258d4d3e", + title: "run_command", + kind: "execute", + status: "in_progress", + rawInput: { CommandLine: "cat probe.txt", Cwd: "/workspace" }, + }, +} satisfies EffectAcpSchema.SessionNotification; + +const commandCompleted = { + sessionId: "session-1", + update: { + toolCallId: commandStarted.update.toolCallId, + status: "completed", + rawOutput: { + commandLine: "cat probe.txt", + workingDir: "/workspace", + exitCode: 0, + exit_code: 0, + combinedOutput: "after\n", + formatted_output: "after\n", + }, + sessionUpdate: "tool_call_update", + }, +} satisfies EffectAcpSchema.SessionNotification; + +function parseToolUpdate(notification: EffectAcpSchema.SessionNotification) { + const result = parseSessionUpdateEvent(normalizeAntigravitySessionUpdate(notification)); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("Expected a tool update."); + } + return event; +} + +describe("Antigravity permissions and questions", () => { + const permissionRequest = { + sessionId: "session-1", + toolCall: { toolCallId: "command-1", kind: "execute", title: "Run command" }, + options: [ + { optionId: "remember-this-command", name: "Allow always", kind: "allow_always" }, + { optionId: "run-this-time", name: "Allow", kind: "allow_once" }, + { optionId: "stop-this-command", name: "Deny", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + it("uses only the option IDs offered for each approval decision", () => { + expect(selectAntigravityPermissionOptionId(permissionRequest, "accept")).toBe("run-this-time"); + expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptForSession")).toBe( + "remember-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptAlways")).toBe( + "remember-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "decline")).toBe( + "stop-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "cancel")).toBeUndefined(); + }); + + it("surfaces the agent's prompt injection warning on the remembered approval", () => { + const risky = { + ...permissionRequest, + options: [ + { + optionId: "remember-this-command", + name: "Allow Always (risky)", + kind: "allow_always", + _meta: { + "agy.security.warning": { + severity: "high", + risk: "prompt_injection", + title: "Allowing always can be risky", + message: "Untrusted files could re-run this action without asking.", + }, + }, + }, + { optionId: "run-this-time", name: "Allow", kind: "allow_once" }, + { optionId: "stop-this-command", name: "Deny", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + expect(antigravityApprovalOptions(risky)).toEqual([ + { decision: "accept", label: "Allow once" }, + { + decision: "acceptForSession", + label: "Allow for this thread", + warning: "Untrusted files could re-run this action without asking.", + }, + { decision: "decline", label: "Deny" }, + { decision: "cancel", label: "Cancel" }, + ]); + expect(antigravityApprovalOptions(permissionRequest)[1]).not.toHaveProperty("warning"); + }); + + it("does not replace unsupported remembered approval with a single approval", () => { + const request = { + ...permissionRequest, + options: permissionRequest.options.filter((option) => option.kind !== "allow_always"), + }; + expect(selectAntigravityPermissionOptionId(request, "acceptAlways")).toBeUndefined(); + expect(selectAntigravityPermissionOptionId(request, "acceptForSession")).toBeUndefined(); + expect(selectAntigravityPermissionOptionId(request, "accept")).toBe("run-this-time"); + expect( + selectAntigravityPermissionOptionId({ ...request, options: [] }, "decline"), + ).toBeUndefined(); + }); + + it("routes the captured native question to single-choice input, not approval", () => { + expect(isAntigravityUserInputRequest(questionRequest)).toBe(true); + expect(selectAntigravityPermissionOptionId(questionRequest, "accept")).toBeUndefined(); + expect(extractAntigravityUserInputQuestion(questionRequest)).toEqual({ + id: "interaction_9960062f", + header: "Question", + question: "Which result label should be used for the verification?", + multiSelect: false, + allowCustomAnswer: false, + options: [ + { value: "1", label: "Verified", description: "Verified" }, + { value: "2", label: "Needs review", description: "Needs review" }, + ], + }); + expect(extractAntigravityUserInputQuestion(permissionRequest)).toBeUndefined(); + }); + + it("returns exact opaque IDs and accepts a unique label from an older client", () => { + for (const answer of ["1", ["1"], "Verified"]) { + expect( + makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }), + ).toEqual({ outcome: { outcome: "selected", optionId: "1" } }); + } + const request = { + ...questionRequest, + options: [{ optionId: " choice: opaque ", name: "Keep", kind: "allow_once" as const }], + }; + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: " choice: opaque " }), + ).toEqual({ outcome: { outcome: "selected", optionId: " choice: opaque " } }); + }); + + it("does not treat a question's reject choice as cancellation", () => { + const request = { + ...questionRequest, + options: [{ optionId: "deny", name: "Do not trust", kind: "reject_once" as const }], + }; + expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "deny" })).toEqual({ + outcome: { outcome: "selected", optionId: "deny" }, + }); + }); + + it("preserves duplicate labels and rejects ambiguous label answers", () => { + const request = { + ...questionRequest, + options: questionRequest.options.map((option) => ({ ...option, name: "Same label" })), + }; + expect( + extractAntigravityUserInputQuestion(request)?.options.map((option) => option.value), + ).toEqual(["1", "2"]); + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: "Same label" }), + ).toBeUndefined(); + expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "2" })).toEqual({ + outcome: { outcome: "selected", optionId: "2" }, + }); + }); + + it.each([undefined, null, "", "arbitrary answer", [], ["1", "2"], { answer: "1" }, 1])( + "keeps the question open for an unsupported answer: %j", + (answer) => { + expect( + makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }), + ).toBeUndefined(); + }, + ); + + it("rejects missing or duplicate native option IDs", () => { + for (const optionId of ["", "1"]) { + const request = { + ...questionRequest, + options: [questionRequest.options[0]!, { ...questionRequest.options[1]!, optionId }], + }; + expect(extractAntigravityUserInputQuestion(request)).toBeUndefined(); + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: "1" }), + ).toBeUndefined(); + } + }); + + it("bounds question text without changing choice IDs", () => { + const request = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, title: "Question ".repeat(2_000) }, + options: [ + { optionId: "stable-choice", name: "Choice ".repeat(2_000), kind: "allow_once" as const }, + ], + }; + const question = extractAntigravityUserInputQuestion(request); + expect(question?.question.length).toBeLessThanOrEqual(8_000); + expect(question?.options[0]?.label.length).toBeLessThanOrEqual(512); + expect(question?.options[0]?.value).toBe("stable-choice"); + }); +}); + +describe("Antigravity tool results", () => { + it("normalizes the captured command and completed output for the existing clients", () => { + const initial = parseToolUpdate(commandStarted).toolCall; + const completed = parseToolUpdate(commandCompleted).toolCall; + const toolCall = normalizeAntigravityToolCall(mergeToolCallState(initial, completed)); + + expect(toolCall).toMatchObject({ + kind: "execute", + status: "completed", + command: "cat probe.txt", + detail: "cat probe.txt", + data: { + command: "cat probe.txt", + cwd: "/workspace", + item: { + command: "cat probe.txt", + cwd: "/workspace", + aggregatedOutput: "after\n", + exitCode: 0, + }, + }, + }); + expect(toolCall.data.rawOutput).not.toHaveProperty("formatted_output"); + expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n"); + }); + + it.each([ + { CommandLine: "pwd", Cwd: "/one" }, + { CommandLine: "pwd", WorkingDirectory: "/one" }, + { command_line: "pwd", working_dir: "/one" }, + { commandLine: "pwd", workingDir: "/one" }, + { command: "pwd", cwd: "/one" }, + ])("handles native command input aliases: %j", (rawInput) => { + const event = parseToolUpdate({ + ...commandStarted, + update: { ...commandStarted.update, rawInput }, + }); + expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({ + command: "pwd", + detail: "pwd", + data: { item: { command: "pwd", cwd: "/one" } }, + }); + }); + + it("recovers command fields from history and keeps nonzero exits separate from tool failure", () => { + const event = parseToolUpdate({ + ...commandCompleted, + update: { + ...commandCompleted.update, + rawOutput: { + command_line: "test -f missing.txt", + working_dir: "/workspace", + combined_output: "", + exit_code: 1, + }, + }, + }); + expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({ + kind: "execute", + status: "completed", + command: "test -f missing.txt", + data: { item: { cwd: "/workspace", aggregatedOutput: "", exitCode: 1 } }, + }); + }); + + it("bounds both canonical output and retained raw output", () => { + const output = `${"output line\n".repeat(20_000)}last line\n`; + const event = parseToolUpdate({ + ...commandCompleted, + update: { + ...commandCompleted.update, + rawOutput: { + ...commandCompleted.update.rawOutput, + combinedOutput: output, + formatted_output: output, + }, + }, + }); + const toolCall = normalizeAntigravityToolCall(event.toolCall); + expect(toolCall.data).toMatchObject({ + item: { aggregatedOutput: expect.stringContaining("last line\n") }, + rawOutput: { combinedOutput: expect.stringContaining("last line\n") }, + }); + expect(JSON.stringify(toolCall).length).toBeLessThan(20_000); + expect(JSON.stringify(event.rawPayload).length).toBeLessThan(10_000); + expect(JSON.stringify(event.rawPayload)).not.toContain("formatted_output"); + }); + + it("removes inline images while preserving valid tool content and the local image path", () => { + const inlineImage = "inline-image-bytes".repeat(50_000); + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "image-1", + status: "completed", + rawOutput: { imageName: "result", imagePath: "file:///workspace/brain/result%20image.png" }, + content: [ + { type: "content", content: { type: "image", mimeType: "image/png", data: inlineImage } }, + { type: "content", content: { type: "text", text: "Saved the image." } }, + { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification; + const normalized = normalizeAntigravitySessionUpdate(notification); + expect(isSessionNotification(normalized)).toBe(true); + expect(JSON.stringify(normalized)).not.toContain("inline-image-bytes"); + expect(normalized.update).toMatchObject({ + content: [ + { type: "content", content: { type: "text", text: "Saved the image." } }, + { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" }, + ], + }); + expect(normalizeAntigravityToolCall(parseToolUpdate(normalized).toolCall).data.imagePath).toBe( + "/workspace/brain/result image.png", + ); + }); + + it.each([ + ["/workspace/result.png", "/workspace/result.png"], + ["C:\\work\\result.png", "C:\\work\\result.png"], + ["file:///C:/work/result.png", "C:/work/result.png"], + ["https://example.com/result.png", undefined], + ["file://another-host/result.png", undefined], + ["data:image/png;base64,AAAA", undefined], + ])("only promotes local image references: %s", (imagePath, expected) => { + const toolCall = normalizeAntigravityToolCall({ + toolCallId: "image-1", + data: { rawOutput: { imagePath } }, + }); + expect(toolCall.data.imagePath).toBe(expected); + }); + + it("bounds nested tool data and drops image blobs and data URLs", () => { + const payload = sanitizeAntigravityToolPayload({ + rawOutput: { + text: "a".repeat(100_000), + result: { mimeType: "image/png", blob: "image-blob".repeat(100_000) }, + image: { type: "image", data: "inline-image".repeat(100_000) }, + uri: "data:image/png;base64,inline-data-url", + }, + }); + const serialized = JSON.stringify(payload); + expect(serialized.length).toBeLessThan(9_000); + expect(serialized).not.toContain("image-blob"); + expect(serialized).not.toContain("inline-image"); + expect(serialized).not.toContain("inline-data-url"); + }); + + it("keeps large assistant replies and startup command metadata unchanged", () => { + const notifications = [ + { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "a".repeat(20_000) }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "plan", description: "Make a plan" }], + }, + }, + ] satisfies ReadonlyArray; + for (const notification of notifications) { + expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification); + } + }); + + it("tracks only commands that have passed approval and are still running", () => { + const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall); + expect(isAntigravityOpenCommand(running)).toBe(true); + expect(isAntigravityOpenCommand({ ...running, status: "pending" })).toBe(false); + expect(isAntigravityOpenCommand({ ...running, kind: "read" })).toBe(false); + const completed = normalizeAntigravityToolCall( + mergeToolCallState(running, parseToolUpdate(commandCompleted).toolCall), + ); + expect(isAntigravityOpenCommand(completed)).toBe(false); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts new file mode 100644 index 000000000..1993c9457 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -0,0 +1,380 @@ +import type { + ProviderApprovalDecision, + ProviderApprovalOption, + ProviderUserInputAnswers, + UserInputQuestion, +} from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; + +const TOOL_TEXT_LIMIT = 8_000; +const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; +const QUESTION_LABEL_LIMIT = 512; + +const NativeToolFields = Schema.Struct({ + command: Schema.optional(Schema.String), + CommandLine: Schema.optional(Schema.String), + command_line: Schema.optional(Schema.String), + commandLine: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + Cwd: Schema.optional(Schema.String), + WorkingDirectory: Schema.optional(Schema.String), + working_dir: Schema.optional(Schema.String), + workingDir: Schema.optional(Schema.String), + combinedOutput: Schema.optional(Schema.String), + combined_output: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Int), + exit_code: Schema.optional(Schema.Int), + imagePath: Schema.optional(Schema.String), +}); +const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields); +const decodeSingleAnswer = Schema.decodeUnknownOption( + Schema.Union([Schema.String, Schema.Tuple([Schema.String])]), +); +const decodeToolCallContent = Schema.decodeUnknownOption(EffectAcpSchema.ToolCallContent); + +/** Native questions share the permission method, but their choices are not approvals. */ +export function isAntigravityUserInputRequest( + request: EffectAcpSchema.RequestPermissionRequest, +): boolean { + return request.toolCall.toolCallId.startsWith("interaction_"); +} + +export function selectAntigravityPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, +): string | undefined { + if (decision === "cancel" || isAntigravityUserInputRequest(request)) { + return undefined; + } + const kind = + decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() ? option.optionId : undefined; +} + +/** Copy truncated text so V8 cannot retain the original large string. */ +const SECURITY_WARNING_META_KEY = "agy.security.warning"; +const WARNING_TEXT_LIMIT = 512; +const decodeSecurityWarning = Schema.decodeUnknownOption( + Schema.Struct({ + title: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +); + +/** + * The agent marks "Allow Always" on shell and web tools with a prompt injection + * warning in `_meta`. Surface it as option text so both clients can show it. + */ +function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { + const meta = option._meta; + if (!Predicate.isObject(meta)) return undefined; + const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); + const text = warning?.message?.trim() || warning?.title?.trim(); + if (!text) return undefined; + return text.length > WARNING_TEXT_LIMIT + ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`) + : text; +} + +/** Only advertise decisions that the native request can honor. */ +export function antigravityApprovalOptions( + request: EffectAcpSchema.RequestPermissionRequest, +): ReadonlyArray { + if (isAntigravityUserInputRequest(request)) return []; + const options: ProviderApprovalOption[] = []; + const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.find((entry) => entry.kind === kind && entry.optionId.trim()); + const once = optionWithKind("allow_once"); + if (once) { + options.push({ decision: "accept", label: "Allow once" }); + } + const always = optionWithKind("allow_always"); + if (always) { + const warning = antigravitySecurityWarning(always); + options.push({ + decision: "acceptForSession", + label: "Allow for this thread", + ...(warning ? { warning } : {}), + }); + } + if (optionWithKind("reject_once")) { + options.push({ decision: "decline", label: "Deny" }); + } + options.push({ decision: "cancel", label: "Cancel" }); + return options; +} + +function copyBoundedText(text: string): string { + return Buffer.from(text, "utf16le").toString("utf16le"); +} + +function questionLabel(option: EffectAcpSchema.PermissionOption): string { + const label = option.name.trim() || option.optionId; + return label.length > QUESTION_LABEL_LIMIT + ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`) + : label; +} + +export function extractAntigravityUserInputQuestion( + request: EffectAcpSchema.RequestPermissionRequest, +): UserInputQuestion | undefined { + if (!isAntigravityUserInputRequest(request) || request.options.length === 0) { + return undefined; + } + const ids = new Set(); + for (const option of request.options) { + if (!option.optionId.trim() || ids.has(option.optionId)) { + return undefined; + } + ids.add(option.optionId); + } + const question = request.toolCall.title?.trim() || "Choose an option."; + return { + id: request.toolCall.toolCallId, + header: "Question", + question: + question.length > TOOL_TEXT_LIMIT + ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`) + : question, + multiSelect: false, + allowCustomAnswer: false, + options: request.options.map((option) => ({ + value: option.optionId, + label: questionLabel(option), + description: questionLabel(option), + })), + }; +} + +/** Return undefined for an invalid answer so the adapter keeps the question open. */ +export function makeAntigravityUserInputResponse( + request: EffectAcpSchema.RequestPermissionRequest, + answers: ProviderUserInputAnswers, +): EffectAcpSchema.RequestPermissionResponse | undefined { + if (extractAntigravityUserInputQuestion(request) === undefined) { + return undefined; + } + const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId])); + const value = typeof answer === "string" ? answer : answer?.[0]; + if (value === undefined) { + return undefined; + } + const exact = request.options.find((option) => option.optionId === value); + if (exact) { + return { outcome: { outcome: "selected", optionId: exact.optionId } }; + } + const matchingLabels = request.options.filter((option) => questionLabel(option) === value); + const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined; + return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined; +} + +function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { + return text.length <= limit + ? text + : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); +} + +interface ToolPayloadBudget { + nodes: number; + text: number; +} + +function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown { + if (depth > 12 || budget.nodes-- <= 0) { + return undefined; + } + if (typeof value === "string") { + if (/^data:image\//i.test(value) || budget.text <= 0) { + return undefined; + } + const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text)); + budget.text -= text.length; + return text; + } + if (Array.isArray(value)) { + const result: unknown[] = []; + for (const entry of value) { + if (budget.nodes <= 0) break; + const sanitized = sanitizeToolValue(entry, budget, depth + 1); + if (sanitized !== undefined) result.push(sanitized); + } + return result; + } + if (!Predicate.isObject(value)) { + return value; + } + const entries: Array = []; + for (const [key, entry] of Object.entries(value)) { + if (budget.nodes <= 0) break; + if ( + (value.type === "image" && (key === "data" || key === "blob")) || + (key === "blob" && + typeof value.mimeType === "string" && + value.mimeType.startsWith("image/")) || + ((key === "formatted_output" || key === "formattedOutput") && + (entry === value.combinedOutput || entry === value.combined_output)) + ) { + continue; + } + const sanitized = sanitizeToolValue(entry, budget, depth + 1); + if (sanitized !== undefined) entries.push([key, sanitized]); + } + return Object.fromEntries(entries); +} + +/** Bound both retained raw events and display data before they enter the event stream. */ +export function sanitizeAntigravityToolPayload(payload: unknown): unknown { + return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); +} + +/** The runtime uses this before it retains tool state or dispatches raw callbacks. */ +export function normalizeAntigravitySessionUpdate( + notification: EffectAcpSchema.SessionNotification, +): EffectAcpSchema.SessionNotification { + const update = notification.update; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return notification; + } + const contentBudget = { nodes: 512, text: 32_000 }; + const content = update.content?.flatMap((entry) => { + const decoded = Option.getOrUndefined( + decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)), + ); + return decoded === undefined ? [] : [decoded]; + }); + const meta = sanitizeAntigravityToolPayload(update._meta); + return { + ...notification, + update: { + ...update, + ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}), + ...(update.rawInput !== undefined + ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) } + : {}), + ...(update.rawOutput !== undefined + ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } + : {}), + ...(update.content !== undefined ? { content: content ?? [] } : {}), + ...(update._meta !== undefined ? { _meta: Predicate.isObject(meta) ? meta : null } : {}), + }, + }; +} + +function localImagePath(imagePath: string | undefined): string | undefined { + if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) { + return undefined; + } + const path = imagePath.trim(); + if (!isWorkspaceImagePreviewPath(path)) { + return undefined; + } + if (/^file:\/\//i.test(path)) { + try { + const url = new URL(path); + if (url.hostname && url.hostname !== "localhost") return undefined; + const pathname = decodeURIComponent(url.pathname); + return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname; + } catch { + return undefined; + } + } + return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; +} + +export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState { + const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); + const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); + const nativeCommand = + input?.CommandLine ?? + input?.command_line ?? + input?.commandLine ?? + input?.command ?? + output?.commandLine ?? + output?.command_line ?? + toolCall.command; + const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined; + const nativeCwd = + input?.Cwd ?? + input?.WorkingDirectory ?? + input?.working_dir ?? + input?.workingDir ?? + input?.cwd ?? + output?.workingDir ?? + output?.working_dir; + const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined; + const nativeOutput = output?.combinedOutput ?? output?.combined_output; + const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput); + const exitCode = output?.exitCode ?? output?.exit_code; + const imagePath = localImagePath(output?.imagePath); + const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data); + const data: Record = Predicate.isObject(sanitizedData) ? sanitizedData : {}; + const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined); + if (kind !== undefined) data.kind = kind; + if (command !== undefined) data.command = command; + if (cwd !== undefined) data.cwd = cwd; + if (imagePath !== undefined) data.imagePath = imagePath; + if (kind === "execute") { + data.item = { + ...(Predicate.isObject(data.item) ? data.item : {}), + ...(command !== undefined ? { command } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + }; + } + return { + ...toolCall, + ...(kind !== undefined ? { kind } : {}), + ...(command !== undefined ? { command } : {}), + ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}), + ...(command !== undefined + ? { detail: command } + : toolCall.detail !== undefined + ? { detail: boundText(toolCall.detail) } + : {}), + data, + }; +} + +/** Only commands still running after end_turn become background tasks. */ +export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { + return toolCall.kind === "execute" && toolCall.status === "inProgress"; +} + +/** ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs or models. */ +export function classifyAntigravitySubagentToolCall( + toolCall: AcpToolCallState, + rawPayload: unknown, +): "subagent" | "mcp" | undefined { + if ( + (toolCall.kind !== undefined && toolCall.kind !== "other") || + (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?") + ) + return undefined; + const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; + const meta = Predicate.isObject(update) ? update._meta : undefined; + return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent"; +} + +/** History sends a completed start before the separate result and its final status. */ +export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean { + const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; + return ( + Predicate.isObject(update) && + update.sessionUpdate === "tool_call" && + update.status === "completed" && + (update.rawOutput === undefined || update.rawOutput === null) + ); +} + +export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined { + const output = toolCall.data.rawOutput; + return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; +} diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts new file mode 100644 index 000000000..b00bffbc1 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); +const decodeSessionMetadata = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })), +); + +/** Call after the process closes. The unique temporary cwd proves which session we own. */ +export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySessionFiles")( + function* (input: { + readonly profileDirectory: string; + readonly sessionId: string | undefined; + readonly cwd: string; + }) { + if (input.sessionId === undefined || !isNativeSessionId(input.sessionId)) { + return; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const acpDirectory = path.join(input.profileDirectory, "antigravity-acp"); + const base = path.join(acpDirectory, "conversations", input.sessionId); + if (!(yield* fs.exists(`${base}.meta`))) { + return; + } + const metadata = yield* fs + .readFileString(`${base}.meta`) + .pipe(Effect.flatMap(decodeSessionMetadata)); + if (metadata.cwd !== input.cwd) { + return; + } + for (const suffix of [".db", ".db-wal", ".db-shm", ".db-journal", ".meta"]) { + yield* fs.remove(`${base}${suffix}`, { force: true }); + } + yield* fs.remove(path.join(acpDirectory, "brain", input.sessionId), { + recursive: true, + force: true, + }); + }, + Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), +); diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts new file mode 100644 index 000000000..7f065caa4 --- /dev/null +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -0,0 +1,651 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Ndjson from "effect/unstable/encoding/Ndjson"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; + +import { + ANTIGRAVITY_AUTH_BROWSER_MARKER, + ANTIGRAVITY_AUTH_STDOUT_PREFIX, + ANTIGRAVITY_PERSONAL_AUTH, + ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + type AntigravityAuthConfig, + antigravityAuthConfigIssue, + type AntigravityProfile, + antigravityProfileSettings, + buildAntigravityAcpSpawnInput, + isAntigravitySignInRequiredError, + makeAntigravityStderrHandler, + makeAntigravityStdoutTransform, + parseAntigravityAuthorizationUrl, + prepareAntigravityProfile, + resolveAntigravityProfileDirectory, +} from "./antigravityAuthSupport.ts"; + +const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code" + + "&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A46353%2F" + + "&state=test-opaque-state&code_challenge=test-challenge&code_challenge_method=S256"; +const authLine = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`; +const encode = (text: string) => new TextEncoder().encode(text); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +describe("Antigravity process environment", () => { + const profile: AntigravityProfile = { + platform: "linux", + geminiHome: "/t3/userdata/providers/antigravity/profile", + acpDirectory: "/t3/userdata/providers/antigravity/profile/antigravity-acp", + tokenPath: "/t3/userdata/providers/antigravity/profile/antigravity-acp/acp_token.json", + browserCommand: "managed-browser-helper", + }; + + it("isolates the profile and harness after merging overrides without changing the base environment", () => { + const baseEnv = { + HOME: "/home/developer", + PATH: "/usr/bin", + GEMINI_API_KEY: "do-not-use-api-billing", + google_api_key: "case-insensitive-api-key", + GOOGLE_CLOUD_PROJECT: "do-not-use-project", + GOOGLE_CLOUD_LOCATION: "do-not-use-location", + GOOGLE_APPLICATION_CREDENTIALS: "/credentials.json", + GOOGLE_CLOUD_QUOTA_PROJECT: "do-not-use-quota-project", + GOOGLE_GENAI_USE_VERTEXAI: "true", + AGY_ACP_CCPA_PROJECT: "do-not-use-consumer-project", + AGY_ACP_ENABLE_OAUTH: "1", + GEMINI_HOME: "/shared-gemini-home", + gemini_home: "/alias-shared-home", + AGY_ACP_FORCE_FILE_STORAGE: "0", + ANTIGRAVITY_HARNESS_PATH: "/wrong-version/harness", + BROWSER: "open-real-browser", + browser: "another-real-browser", + PYTHONUNBUFFERED: "0", + ELECTRON_RUN_AS_NODE: "0", + CUSTOM_SETTING: "keep-this", + }; + const original = { ...baseEnv }; + const spawn = buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: "/project", + baseEnv, + }); + + expect(baseEnv).toEqual(original); + expect(spawn).toEqual({ + command: "/release/acp", + args: ["--uid="], + cwd: "/project", + extendEnv: false, + env: { + HOME: "/home/developer", + PATH: "/usr/bin", + CUSTOM_SETTING: "keep-this", + GEMINI_HOME: profile.geminiHome, + AGY_ACP_FORCE_FILE_STORAGE: "1", + ANTIGRAVITY_HARNESS_PATH: "/release/harness", + BROWSER: profile.browserCommand, + PYTHONUNBUFFERED: "1", + ELECTRON_RUN_AS_NODE: "1", + }, + }); + }); + + it("passes only the configured method's credential and keeps the GCP pair out of the environment", () => { + const baseEnv = { PATH: "/usr/bin", GOOGLE_API_KEY: "ambient-key" }; + const spawnFor = (auth: AntigravityAuthConfig) => + buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: "/project", + baseEnv, + auth, + }).env ?? {}; + + const geminiKey = spawnFor({ + authMethod: "gemini-api-key", + apiKey: "gemini-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }); + expect(geminiKey.GEMINI_API_KEY).toBe("gemini-secret"); + expect(geminiKey.GOOGLE_API_KEY).toBeUndefined(); + expect(geminiKey.GOOGLE_CLOUD_PROJECT).toBeUndefined(); + + const vertexKey = spawnFor({ + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "", + gcpLocation: "", + }); + expect(vertexKey.GOOGLE_API_KEY).toBe("vertex-secret"); + expect(vertexKey.GEMINI_API_KEY).toBeUndefined(); + + const business = spawnFor({ + authMethod: "oauth-business", + apiKey: "ignored", + gcpProject: "proj", + gcpLocation: "us-central1", + }); + expect(business.GEMINI_API_KEY).toBeUndefined(); + expect(business.GOOGLE_API_KEY).toBeUndefined(); + }); + + it("writes the auth method and GCP block into the agent's settings.json", () => { + expect( + decodeJson( + antigravityProfileSettings({ + authMethod: "oauth-business", + apiKey: "never-written", + gcpProject: "proj", + gcpLocation: "us-central1", + }), + ), + ).toEqual({ + auth: { type: "oauth-business" }, + gcp: { project: "proj", location: "us-central1" }, + }); + // The agent's logout reads auth.type to clear only that method's token. + expect(decodeJson(antigravityProfileSettings(ANTIGRAVITY_PERSONAL_AUTH))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }); + + it("names the missing credential for each method", () => { + expect(antigravityAuthConfigIssue(ANTIGRAVITY_PERSONAL_AUTH)).toBeNull(); + expect( + antigravityAuthConfigIssue({ ...ANTIGRAVITY_PERSONAL_AUTH, authMethod: "gemini-api-key" }), + ).toContain("API key"); + expect( + antigravityAuthConfigIssue({ + ...ANTIGRAVITY_PERSONAL_AUTH, + authMethod: "oauth-business", + gcpProject: "proj", + }), + ).toContain("location"); + expect( + antigravityAuthConfigIssue({ + ...ANTIGRAVITY_PERSONAL_AUTH, + authMethod: "agent-platform", + gcpProject: "proj", + gcpLocation: "us-central1", + }), + ).toBeNull(); + }); + + it("uses the registry launch arguments for each supported host platform", () => { + for (const platform of ["linux", "darwin", "win32"] as const) { + const spawn = buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile: { ...profile, platform }, + cwd: "/project", + baseEnv: {}, + }); + expect(spawn.args).toEqual(platform === "linux" ? ["--uid="] : []); + } + }); + + it("keeps accounts separate even when instance IDs differ only by case", () => { + const first = resolveAntigravityProfileDirectory( + "/userdata", + ProviderInstanceId.make("antigravity"), + ); + const second = resolveAntigravityProfileDirectory( + "/userdata", + ProviderInstanceId.make("Antigravity"), + ); + expect(first.toLowerCase()).not.toBe(second.toLowerCase()); + expect( + resolveAntigravityProfileDirectory("/userdata", ProviderInstanceId.make("antigravity")), + ).toBe(first); + }); +}); + +describe("Antigravity authorization URL", () => { + it.effect("returns the official Google URL and its owned loopback target", () => + Effect.gen(function* () { + expect(yield* parseAntigravityAuthorizationUrl(authorizationUrl)).toEqual({ + authorizationUrl, + redirectUri: "http://127.0.0.1:46353/", + state: "test-opaque-state", + }); + }), + ); + + it.effect( + "rejects other origins, ambiguous state, and non-loopback redirects without retaining them", + () => + Effect.gen(function* () { + const invalidUrls = [ + authorizationUrl.replace("https:", "http:"), + authorizationUrl.replace("accounts.google.com", "accounts.google.com.example.invalid"), + authorizationUrl.replace("accounts.google.com", "secret@accounts.google.com"), + authorizationUrl.replace("/o/oauth2/v2/auth", "/another-path"), + `${authorizationUrl}#secret-fragment`, + `${authorizationUrl}&state=another-state`, + authorizationUrl.replace("test-opaque-state", ""), + authorizationUrl.replace("test-opaque-state", "opaque%0astate"), + authorizationUrl.replace("127.0.0.1", "localhost"), + authorizationUrl.replace("127.0.0.1", "169.254.169.254"), + authorizationUrl.replace("46353", "80"), + authorizationUrl.replace("46353", "70000"), + authorizationUrl.replace("46353%2F", "46353%2Fother"), + authorizationUrl.replace("response_type=code", "response_type=token"), + "not a URL containing secret-code", + ]; + for (const invalidUrl of invalidUrls) { + const result = yield* parseAntigravityAuthorizationUrl(invalidUrl).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) continue; + expect(result.failure._tag).toBe("AcpTransportError"); + expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state"); + expect(encodeUnknownJson(result.failure)).not.toContain("secret-code"); + expect(encodeUnknownJson(result.failure)).not.toContain(invalidUrl); + } + }), + ); +}); + +describe("Antigravity sign-in errors", () => { + it("recognizes native auth-required errors and the blocked-login transport error", () => { + expect(isAntigravitySignInRequiredError(AcpErrors.AcpRequestError.authRequired())).toBe(true); + expect( + isAntigravitySignInRequiredError( + new AcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }), + ), + ).toBe(true); + }); + + it("does not treat other failures or arbitrary text as missing authentication", () => { + const otherErrors = [ + new AcpErrors.AcpTransportError({ detail: "The process stopped.", cause: undefined }), + AcpErrors.AcpRequestError.internalError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE), + new Error(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE), + { detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE }, + ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + undefined, + ]; + for (const error of otherErrors) expect(isAntigravitySignInRequiredError(error)).toBe(false); + }); +}); + +describe("Antigravity stdout compatibility", () => { + it.effect( + "handles fragmented login lines between JSON messages without changing protocol bytes", + () => + Effect.gen(function* () { + const urls: string[] = []; + const jsonBefore = '{"jsonrpc":"2.0","id":1,"result":{}}\r\n'; + const jsonAfter = '{"jsonrpc":"2.0","id":2,"result":{"text":"café"}}\n'; + const chunks = [ + encode(`${jsonBefore}${ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(0, 7)}`), + encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(7)), + encode(authorizationUrl.slice(0, 40)), + encode(`${authorizationUrl.slice(40)}\r`), + encode(`\n${jsonAfter}`), + ]; + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + })(Stream.fromIterable(chunks)).pipe(Stream.decodeText(), Stream.mkString); + expect(result).toBe(`${jsonBefore}${jsonAfter}`); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("handles an auth line without a final newline", () => + Effect.gen(function* () { + const urls: string[] = []; + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + })(Stream.make(encode(authLine.slice(0, -1)))).pipe(Stream.runCollect); + expect(result).toEqual([]); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () => + Effect.gen(function* () { + const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe( + Stream.runDrain, + Effect.result, + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + }); + expect(encodeUnknownJson(result.failure)).not.toContain(authorizationUrl); + expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state"); + }), + ); + + it.effect("preserves typed errors from the flow owner", () => + Effect.gen(function* () { + const failure = new AcpErrors.AcpTransportError({ + detail: "This sign-in flow has expired.", + cause: undefined, + }); + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: () => Effect.fail(failure), + })(Stream.make(encode(authLine))).pipe(Stream.runDrain, Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toBe(failure); + }), + ); + + it.effect("does not suppress malformed protocol data or similar login messages", () => + Effect.gen(function* () { + const unrelated = [ + "this is not JSON\n", + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX.toLowerCase()}${authorizationUrl}\n`, + ` ${authLine}`, + ]; + for (const line of unrelated) { + const transform = makeAntigravityStdoutTransform(); + const output = yield* transform(Stream.make(encode(line))).pipe( + Stream.decodeText(), + Stream.mkString, + ); + expect(output).toBe(line); + const decoded = yield* transform(Stream.make(encode(line))).pipe( + Stream.pipeThroughChannel(Ndjson.decode()), + Stream.runDrain, + Effect.result, + ); + expect(Result.isFailure(decoded)).toBe(true); + } + }), + ); + + it.effect("bounds unfinished protocol lines", () => + Effect.gen(function* () { + const result = yield* makeAntigravityStdoutTransform()( + Stream.make(new Uint8Array(16 * 1024 * 1024), encode("x")), + ).pipe(Stream.runDrain, Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: "Antigravity sent a protocol line that is too large.", + }); + }), + ); +}); + +describe("Antigravity stderr compatibility", () => { + it.effect("forwards fragmented native sign-in URLs from runtime 1.1.1", () => + Effect.gen(function* () { + const urls: string[] = []; + const line = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\r\n`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + yield* handleStderr(`native log\n${line.slice(0, 40)}`); + yield* handleStderr(line.slice(40, 90)); + yield* handleStderr(`${line.slice(90)}another native log\n`); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("rejects interactive sign-in during normal work", () => + Effect.gen(function* () { + const handleStderr = makeAntigravityStderrHandler(); + const error = yield* handleStderr( + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, + ).pipe(Effect.flip); + expect(isAntigravitySignInRequiredError(error)).toBe(true); + }), + ); + + it.effect("preserves failures from the sign-in flow owner", () => + Effect.gen(function* () { + const failure = new AcpErrors.AcpTransportError({ + detail: "The sign-in flow stopped.", + cause: undefined, + }); + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: () => Effect.fail(failure), + }); + const error = yield* handleStderr( + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, + ).pipe(Effect.flip); + expect(error).toBe(failure); + }), + ); + + it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () => + Effect.gen(function* () { + const urls: string[] = []; + const longAuthorizationUrl = `${authorizationUrl}&scope=${"a".repeat(9_000)}`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + expect(longAuthorizationUrl.length).toBeGreaterThan(8_192); + yield* handleStderr( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(longAuthorizationUrl)}\n`, + ); + + expect(urls).toEqual([longAuthorizationUrl]); + }), + ); + + it.effect("forwards a fragmented browser-helper URL without exposing other stderr", () => + Effect.gen(function* () { + const urls: string[] = []; + const markerLine = `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + yield* handleStderr(`native log\n${markerLine.slice(0, 12)}`); + yield* handleStderr(markerLine.slice(12, 70)); + yield* handleStderr(`${markerLine.slice(70)}another native log\n`); + + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("ignores malformed and similar browser-helper messages", () => + Effect.gen(function* () { + const urls: string[] = []; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + yield* handleStderr( + ` ${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, + ); + yield* handleStderr(`${ANTIGRAVITY_AUTH_BROWSER_MARKER}${authorizationUrl}\n`); + yield* handleStderr( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`, + ); + yield* handleStderr(`${ANTIGRAVITY_AUTH_STDOUT_PREFIX}https://example.com\n`); + yield* handleStderr(` ${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`); + + expect(urls).toEqual([]); + }), + ); +}); + +it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { + it.effect("preflights the no-browser helper and creates private directories only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(temporaryDirectory, "profile"), + }); + + expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + expect(yield* fs.exists(profile.tokenPath)).toBe(false); + if ((yield* HostProcessPlatform) !== "win32") { + expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); + expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); + } + + yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); + yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); + expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "links the user's global skill directories into the profile without touching real content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + // Only the skill directories are shared; the rest of the profile stays private. + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); + + // A stale link is repointed; a real directory the user placed there is kept. + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + }), + ); + + it.effect("rewrites the GCP block on every launch and never stores the API key", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: temporaryDirectory, + auth: { + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }, + }); + const settingsPath = path.join(profile.acpDirectory, "settings.json"); + const first = yield* fs.readFileString(settingsPath); + expect(decodeJson(first)).toEqual({ + auth: { type: "agent-platform" }, + gcp: { project: "proj", location: "us-central1" }, + }); + expect(first).not.toContain("vertex-secret"); + + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); + expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }), + ); + + it.effect("keeps the browser helper successful when cancellation closes stderr", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + let helperCommand: ChildProcess.StandardCommand | undefined; + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) helperCommand = command; + return spawner.spawn(command); + }), + ), + ); + expect(helperCommand).toBeDefined(); + if (!helperCommand) return; + const command = helperCommand; + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.spawn(command.command, command.args, { + env: { ...command.options.env }, + stdio: ["ignore", "ignore", "pipe"], + }), + ), + (process) => Effect.sync(() => void process.kill()), + ); + child.stderr?.destroy(); + const exitCode = yield* Effect.promise( + () => + new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }), + ); + expect(exitCode).toBe(0); + }), + ); + + it.effect("fails before creating a profile when the helper cannot start", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profileDirectory = path.join(temporaryDirectory, "unused-profile"); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + runtimeExecutablePath: path.join(temporaryDirectory, "missing-runtime"), + }).pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + expect(yield* fs.exists(profileDirectory)).toBe(false); + }), + ); + + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + for (const platform of ["linux", "win32"] as const) { + const profileDirectory = path.join(temporaryDirectory, platform); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(yield* fs.exists(profileDirectory)).toBe(false); + } + }), + ); +}); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts new file mode 100644 index 000000000..8e0040ae0 --- /dev/null +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -0,0 +1,562 @@ +import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. +import * as NodeFSP from "node:fs/promises"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. +import * as NodePath from "node:path"; + +import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import { + antigravityUserSkillDirectories, + resolveAntigravityUserHome, +} from "./Drivers/AntigravitySkills.ts"; + +export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = + "Open the following link to authenticate the ACP server: "; +export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__"; +export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = + "Sign in to Antigravity in Settings before you continue."; + +const maxAuthorizationUrlLength = 16_384; +const maxBrowserHelperLineLength = + Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) + + maxAuthorizationUrlLength + + 2; +const maxStdoutLineBytes = 16 * 1024 * 1024; +const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); +const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); +const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); +const ProfileSettingsFile = Schema.Struct({ + auth: Schema.Struct({ type: Schema.String }), + gcp: Schema.optional( + Schema.Struct({ + project: Schema.optional(Schema.String), + location: Schema.optional(Schema.String), + }), + ), +}); +const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile)); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); +const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); + +// Python splits BROWSER on the platform path separator before it parses quotes. +// Keep this source free of both colons and semicolons. EPIPE must still exit 0 +// so Python does not fall back to an OS browser after cancellation. +const browserHelperSource = + `process.stderr.on("error",()=>process.exit(0)).write(` + + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + + `()=>process.exit(0))`; +const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; + +const removedEnvironmentKeys = new Set([ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_QUOTA_PROJECT", + "GOOGLE_GENAI_USE_VERTEXAI", + "GCLOUD_PROJECT", + "CLOUDSDK_CORE_PROJECT", + "AGY_ACP_CCPA_PROJECT", + "AGY_ACP_ENABLE_OAUTH", + "GEMINI_HOME", + "AGY_ACP_FORCE_FILE_STORAGE", + "ANTIGRAVITY_HARNESS_PATH", + "BROWSER", + "PYTHONUNBUFFERED", + "ELECTRON_RUN_AS_NODE", +]); + +export interface AntigravityProfile { + readonly platform: NodeJS.Platform; + readonly geminiHome: string; + readonly acpDirectory: string; + readonly tokenPath: string; + readonly browserCommand: string; +} + +/** + * Credentials for the non-personal ACP auth methods. The agent reads the API + * key from its environment and the GCP project and location from + * `settings.json` in the profile. Empty strings mean "not set". + */ +export interface AntigravityAuthConfig { + readonly authMethod: AntigravityAuthMethod; + readonly apiKey: string; + readonly gcpProject: string; + readonly gcpLocation: string; +} + +export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { + authMethod: "oauth-personal", + apiKey: "", + gcpProject: "", + gcpLocation: "", +}; + +/** True for the two methods that open a Google sign-in page. */ +export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean { + return authMethod === "oauth-personal" || authMethod === "oauth-business"; +} + +/** Label shown on the provider card once the method has authenticated. */ +export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string { + switch (authMethod) { + case "oauth-personal": + return "Google account"; + case "oauth-business": + return "Gemini Enterprise"; + case "gemini-api-key": + return "Gemini API key"; + case "agent-platform": + return "Agent Platform"; + } +} + +/** + * Explains what is missing before a non-personal method can authenticate, or + * null when the config is complete. Personal sign-in never needs config. + */ +export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null { + switch (auth.authMethod) { + case "oauth-personal": + return null; + case "oauth-business": + return auth.gcpProject && auth.gcpLocation + ? null + : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings."; + case "gemini-api-key": + return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings."; + case "agent-platform": + return auth.apiKey || (auth.gcpProject && auth.gcpLocation) + ? null + : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings."; + } +} + +/** + * `settings.json` content for the agent's profile. `auth.type` names the + * selected method so a native logout clears only that method's credentials + * instead of every stored token. The GCP block feeds Enterprise and Agent + * Platform. Never holds a credential. + */ +export function antigravityProfileSettings(auth: AntigravityAuthConfig): string { + const gcp = { + ...(auth.gcpProject ? { project: auth.gcpProject } : {}), + ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), + }; + return `${encodeProfileSettings({ + auth: { type: auth.authMethod }, + ...(Object.keys(gcp).length > 0 ? { gcp } : {}), + })}\n`; +} + +export interface AntigravityAuthorizationUrl { + readonly authorizationUrl: string; + readonly redirectUri: string; + readonly state: string; +} + +function authSupportError(detail: string) { + return new AcpErrors.AcpTransportError({ detail, cause: undefined }); +} + +/** Recognizes native auth failures and interactive login blocked by T3. */ +export function isAntigravitySignInRequiredError(error: unknown): boolean { + return ( + (isAcpRequestError(error) && error.code === -32000) || + (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE) + ); +} + +/** Keeps case-sensitive instance IDs separate on case-insensitive filesystems. */ +export function resolveAntigravityProfileDirectory( + stateDir: string, + instanceId: ProviderInstanceId, +): string { + const directoryName = NodeCrypto.createHash("sha256").update(instanceId).digest("hex"); + return NodePath.join(stateDir, "providers", "antigravity", directoryName); +} + +function quoteBrowserArgument(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function antigravityEnvironment( + profile: AntigravityProfile, + baseEnv: NodeJS.ProcessEnv, + auth: AntigravityAuthConfig, +) { + const environment: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(baseEnv)) { + // Windows treats environment keys as case-insensitive. Remove aliases too. + if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; + } + // Only the configured method's credential reaches the agent. The agent + // prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair + // goes through settings.json instead of the environment. + const credential = + auth.authMethod === "gemini-api-key" && auth.apiKey + ? { GEMINI_API_KEY: auth.apiKey } + : auth.authMethod === "agent-platform" && auth.apiKey + ? { GOOGLE_API_KEY: auth.apiKey } + : {}; + return { + ...environment, + ...credential, + GEMINI_HOME: profile.geminiHome, + AGY_ACP_FORCE_FILE_STORAGE: "1", + BROWSER: profile.browserCommand, + PYTHONUNBUFFERED: "1", + ELECTRON_RUN_AS_NODE: "1", + }; +} + +/** + * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points + * at the private profile. Link the two skill directories back to the user's + * real `~/.gemini` so global skills load, while MCP servers, hooks, and + * credentials stay isolated. Best effort: a link that cannot be made only + * costs global skills, never the session. A real directory at the link path + * is the user's own content and is left alone. + */ +const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { + readonly profileDirectory: string; + readonly userHome: string; + readonly platform: NodeJS.Platform; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const links = antigravityUserSkillDirectories(path, input.profileDirectory); + const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); + for (const [link, target] of [ + [links[0], targets[0]], + [links[1], targets[1]], + ] as const) { + yield* Effect.gen(function* () { + const existing = yield* fs.readLink(link).pipe( + Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (existing === target) return; + if (existing !== undefined) { + yield* fs.remove(link); + } + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* Effect.tryPromise(() => + NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), + ); + }).pipe( + // A non-symlink at the link path fails `readLink`; anything else is a + // filesystem refusal. Both leave the profile usable. + Effect.catch((error) => + Effect.logWarning("Antigravity user skills are not linked into the profile.", { + link, + target, + error, + }), + ), + ); + } +}); + +/** Prepares a private profile without reading or copying Google credentials. */ +export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { + readonly profileDirectory: string; + readonly baseEnv?: NodeJS.ProcessEnv; + readonly runtimeExecutablePath?: string; + readonly platform?: NodeJS.Platform; + readonly auth?: AntigravityAuthConfig; + /** Home the agent expands `~` against. Defaults to the launch environment's. */ + readonly userHome?: string; +}) { + const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = input.platform ?? (yield* HostProcessPlatform); + const userHome = + input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); + const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); + const helperExecutable = + platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; + const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; + const browserCommand = browserArguments.map(quoteBrowserArgument).join(" "); + if ( + browserCommand.includes(platform === "win32" ? ";" : ":") || + helperExecutable.includes("\r") || + helperExecutable.includes("\n") || + helperExecutable.includes("\0") || + helperExecutable.includes("%s") + ) { + return yield* authSupportError( + "The T3 runtime path cannot be used to suppress Antigravity browser launches.", + ); + } + + const geminiHome = path.resolve(input.profileDirectory); + const acpDirectory = path.join(geminiHome, "antigravity-acp"); + const profile: AntigravityProfile = { + platform, + geminiHome, + acpDirectory, + tokenPath: path.join(acpDirectory, "acp_token.json"), + browserCommand, + }; + const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth); + yield* Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], { + env: environment, + extendEnv: false, + shell: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }), + collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if ( + Number(exitCode) !== 0 || + stdout.bytes !== 0 || + stdout.truncated || + stderr.truncated || + stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n` + ) { + return yield* authSupportError("Antigravity browser suppression could not be verified."); + } + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")), + }), + Effect.mapError((error) => + error._tag === "AcpTransportError" + ? error + : authSupportError("Antigravity browser suppression could not be verified."), + ), + ); + + for (const directory of [geminiHome, acpDirectory]) { + yield* fs + .makeDirectory(directory, { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile directory could not be created."), + ), + ); + if (platform !== "win32") { + yield* fs + .chmod(directory, 0o700) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile directory permissions could not be set."), + ), + ); + } + } + // Rewriting on every launch keeps a method, project, or location edit in + // Settings effective. The agent also records auth.type here after a + // sign-in, which matches the value written below. + yield* fs + .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth)) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile settings could not be written."), + ), + ); + yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); + return profile; +}); + +/** Applies the same subscription-only launch settings to every ACP process. */ +export function buildAntigravityAcpSpawnInput(input: { + readonly installation: { + readonly executablePath: string; + readonly harnessPath: string; + }; + readonly profile: AntigravityProfile; + readonly cwd: string; + readonly baseEnv?: NodeJS.ProcessEnv; + readonly auth?: AntigravityAuthConfig; +}): AcpSpawnInput { + return { + command: input.installation.executablePath, + args: input.profile.platform === "linux" ? ["--uid="] : [], + cwd: input.cwd, + env: { + ...antigravityEnvironment( + input.profile, + input.baseEnv ?? process.env, + input.auth ?? ANTIGRAVITY_PERSONAL_AUTH, + ), + ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath, + }, + extendEnv: false, + }; +} + +/** Reads only the public authorization request, never an OAuth token file. */ +export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( + function* ( + authorizationUrl: string, + ): Effect.fn.Return { + const invalidUrl = () => + authSupportError("Antigravity returned an invalid Google sign-in URL."); + if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) { + return yield* invalidUrl(); + } + const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl)); + const state = url.searchParams.get("state"); + const redirectUri = url.searchParams.get("redirect_uri"); + if ( + url.origin !== "https://accounts.google.com" || + url.pathname !== "/o/oauth2/v2/auth" || + url.username !== "" || + url.password !== "" || + url.hash !== "" || + url.searchParams.getAll("state").length !== 1 || + url.searchParams.getAll("redirect_uri").length !== 1 || + url.searchParams.getAll("response_type").length !== 1 || + url.searchParams.get("response_type") !== "code" || + state === null || + state.length === 0 || + state.length > 512 || + /\s/.test(state) || + redirectUri === null || + !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri) + ) { + return yield* invalidUrl(); + } + const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl)); + if (Number(redirect.port) < 1_024) return yield* invalidUrl(); + return { authorizationUrl, redirectUri, state }; + }, +); + +export function makeAntigravityStdoutTransform( + input: { + readonly onAuthorizationUrl?: ( + authorizationUrl: string, + ) => Effect.Effect; + } = {}, +) { + const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* ( + line: Uint8Array, + ) { + if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line]; + const message = new TextDecoder().decode(line).replace(/\r?\n$/, ""); + const request = yield* parseAntigravityAuthorizationUrl( + message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length), + ); + if (!input.onAuthorizationUrl) { + return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE); + } + yield* input.onAuthorizationUrl(request.authorizationUrl); + return []; + }); + + return ( + stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], + ): Stream.Stream => + Stream.suspend(() => { + let pending: Uint8Array[] = []; + let pendingBytes = 0; + const finishLine = () => { + const line = Buffer.concat(pending, pendingBytes); + pending = []; + pendingBytes = 0; + return line; + }; + return stdout.pipe( + Stream.mapEffect( + Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) { + const lines: Uint8Array[] = []; + let offset = 0; + while (offset < chunk.byteLength) { + const newline = chunk.indexOf(10, offset); + const end = newline === -1 ? chunk.byteLength : newline + 1; + const part = chunk.subarray(offset, end); + if (pendingBytes + part.byteLength > maxStdoutLineBytes) { + return yield* authSupportError( + "Antigravity sent a protocol line that is too large.", + ); + } + pending.push(part); + pendingBytes += part.byteLength; + if (newline !== -1) lines.push(finishLine()); + offset = end; + } + return lines; + }), + ), + Stream.flatMap(Stream.fromIterable), + Stream.concat( + Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)), + ), + Stream.mapEffect(handleLine), + Stream.flatMap(Stream.fromIterable), + ); + }); +} + +/** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */ +export function makeAntigravityStderrHandler( + input: { + readonly onAuthorizationUrl?: ( + authorizationUrl: string, + ) => Effect.Effect; + } = {}, +) { + let pending = ""; + const handleLine = (line: string) => { + const message = line.endsWith("\r") ? line.slice(0, -1) : line; + if (message.length > maxBrowserHelperLineLength) { + return Effect.void; + } + const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX) + ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length)) + : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER) + ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)) + : undefined; + if (url === undefined) return Effect.void; + return url.pipe( + Effect.flatMap(parseAntigravityAuthorizationUrl), + Effect.matchEffect({ + onFailure: () => Effect.void, + onSuccess: (request) => + input.onAuthorizationUrl + ? input.onAuthorizationUrl(request.authorizationUrl) + : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)), + }), + ); + }; + + return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) { + const lines = `${pending}${text}`.split("\n"); + pending = lines.pop() ?? ""; + if (pending.length > maxBrowserHelperLineLength) pending = ""; + yield* Effect.forEach(lines, handleLine, { discard: true }); + }); +} diff --git a/apps/server/src/provider/antigravityCallback.test.ts b/apps/server/src/provider/antigravityCallback.test.ts new file mode 100644 index 000000000..70ff3514b --- /dev/null +++ b/apps/server/src/provider/antigravityCallback.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { validateAntigravityCallbackUrl } from "./antigravityCallback.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-callback-test"); +const pending = { redirectUri: "http://127.0.0.1:51234/", state: "owned-state" }; + +it.effect("accepts the exact owned Google callback and an explicit Google denial", () => + Effect.gen(function* () { + for (const response of ["code=example-code", "error=access_denied"]) { + const callback = `http://127.0.0.1:51234/?state=owned-state&${response}&iss=https%3A%2F%2Faccounts.google.com`; + const parsed = yield* validateAntigravityCallbackUrl(instanceId, pending, callback); + assert.equal(parsed.toString(), callback); + } + }), +); + +it.effect("rejects different targets, credentials, fragments, and duplicate OAuth fields", () => + Effect.gen(function* () { + const callbacks = [ + "https://127.0.0.1:51234/?state=owned-state&code=x", + "http://localhost:51234/?state=owned-state&code=x", + "http://127.0.0.2:51234/?state=owned-state&code=x", + "http://127.0.0.1:51235/?state=owned-state&code=x", + "http://127.0.0.1:51234/other?state=owned-state&code=x", + "http://user:password@127.0.0.1:51234/?state=owned-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&code=x#fragment", + "http://127.0.0.1:51234/?state=wrong-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&state=owned-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&code=x&code=y", + "http://127.0.0.1:51234/?state=owned-state&code=x&error=access_denied", + "http://127.0.0.1:51234/?state=owned-state&error=access_denied&error=other", + "http://127.0.0.1:51234/?state=owned-state&code=", + "http://127.0.0.1:51234/?state=owned-state&iss=https%3A%2F%2Faccounts.google.com", + "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Fexample.com", + "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Faccounts.google.com&iss=https%3A%2F%2Faccounts.google.com", + ]; + for (const callback of callbacks) { + const result = yield* validateAntigravityCallbackUrl(instanceId, pending, callback).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(result), callback); + } + }), +); diff --git a/apps/server/src/provider/antigravityCallback.ts b/apps/server/src/provider/antigravityCallback.ts new file mode 100644 index 000000000..28a601396 --- /dev/null +++ b/apps/server/src/provider/antigravityCallback.ts @@ -0,0 +1,115 @@ +// @effect-diagnostics nodeBuiltinImport:off - node:http sends the one-shot loopback callback with no proxy, redirect handling, or response logging. +import * as NodeHttp from "node:http"; + +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export interface AntigravityPendingCallback { + readonly redirectUri: string; + readonly state: string; +} + +/** Only the callback advertised by this running ACP process may receive a request. */ +export const validateAntigravityCallbackUrl = Effect.fn("validateAntigravityCallbackUrl")( + function* ( + instanceId: ProviderInstanceId, + pending: AntigravityPendingCallback, + callbackUrl: string, + ) { + const invalid = (detail: string) => + new ProviderSetupError({ instanceId, operation: "complete", detail }); + if (callbackUrl.length > 16_384) { + return yield* invalid("The sign-in response URL is too long."); + } + const callback = yield* Effect.try({ + try: () => new URL(callbackUrl), + catch: () => invalid("Paste the complete redirect URL from the Google sign-in page."), + }); + const expected = new URL(pending.redirectUri); + if ( + callback.protocol !== "http:" || + callback.hostname !== "127.0.0.1" || + callback.origin !== expected.origin || + callback.pathname !== expected.pathname || + callback.username !== "" || + callback.password !== "" || + callback.hash !== "" + ) { + return yield* invalid("This redirect URL does not belong to the current sign-in."); + } + const states = callback.searchParams.getAll("state"); + if (states.length !== 1 || states[0] !== pending.state) { + return yield* invalid("This redirect URL does not belong to the current sign-in."); + } + const codes = callback.searchParams.getAll("code"); + const errors = callback.searchParams.getAll("error"); + if ( + !( + (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) || + (errors.length === 1 && Boolean(errors[0]) && codes.length === 0) + ) + ) { + return yield* invalid("The redirect URL must contain one Google sign-in response."); + } + const issuers = callback.searchParams.getAll("iss"); + if ( + issuers.length > 1 || + (issuers.length === 1 && issuers[0] !== "https://accounts.google.com") + ) { + return yield* invalid("The redirect URL is not a Google sign-in response."); + } + return callback; + }, +); + +/** Sends one callback, without proxies, redirects, readiness probes, or response logging. */ +export const forwardAntigravityCallback = ( + instanceId: ProviderInstanceId, + callback: URL, +): Effect.Effect => + Effect.callback((resume) => { + const failed = () => + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "Could not deliver the sign-in response. Start sign-in again.", + }); + let response: NodeHttp.IncomingMessage | undefined; + const request = NodeHttp.request( + { + protocol: "http:", + hostname: callback.hostname, + port: callback.port, + path: `${callback.pathname}${callback.search}`, + method: "GET", + agent: false, + }, + (incoming) => { + response = incoming; + incoming.once("error", () => resume(Effect.fail(failed()))); + incoming.once("end", () => { + const status = incoming.statusCode ?? 0; + resume(status >= 200 && status < 300 ? Effect.void : Effect.fail(failed())); + }); + incoming.resume(); + }, + ); + request.once("error", () => resume(Effect.fail(failed()))); + request.end(); + return Effect.sync(() => { + request.destroy(); + response?.destroy(); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "The sign-in response timed out. Start sign-in again.", + }), + ), + }), + ); diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts new file mode 100644 index 000000000..90ba309c1 --- /dev/null +++ b/apps/server/src/provider/antigravityRelease.ts @@ -0,0 +1,83 @@ +const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; + +export interface AntigravityReleaseAsset { + readonly version: string; + readonly url: string; + readonly sha256: string; + readonly archiveBytes: number; + readonly executable: { + readonly name: string; + readonly bytes: number; + }; + readonly harness: { + readonly name: string; + readonly bytes: number; + }; +} + +// URLs come from the official registry. Hashes and sizes were checked on 2026-09-03. +// https://github.com/agentclientprotocol/registry/blob/81bf71b55e15f630c4fb8a86d20d3088071d2071/antigravity-acp/agent.json +const releaseAssets = new Map([ + [ + "darwin-arm64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_1.1.1-darwin-arm64.zip", + sha256: "fdfa915652cdb7ba8085cc8fffed072cbe009251aa2c951aabdda07a8c28a189", + archiveBytes: 316_014_828, + executable: { name: "agy_acp_server.par", bytes: 802_163_856 }, + harness: { name: "localharness_external", bytes: 116_766_704 }, + }, + ], + [ + "linux-x64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-x86_64.zip", + sha256: "38f62d01b32deb0907b3d39a71ec301fd36369f6ffd1cf262d4af385177f79df", + archiveBytes: 681_969_407, + executable: { name: "agy_acp_server.par", bytes: 1_880_360_328 }, + harness: { name: "localharness_external", bytes: 128_966_920 }, + }, + ], + [ + "linux-arm64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-arm64.zip", + sha256: "ed69e64b308fcb123ab54bf3277bf9cb0d651064f885ea5aab0ff520c7175398", + archiveBytes: 656_572_786, + executable: { name: "agy_acp_server.par", bytes: 1_862_073_131 }, + harness: { name: "localharness_external", bytes: 122_158_704 }, + }, + ], + [ + "win32-x64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-x86_64.zip", + sha256: "47cb50eef14f0a4655d78cfcfda869bcea7aaee5f9787e936bc2935ea612c3b8", + archiveBytes: 468_238_392, + executable: { name: "agy_acp_server.exe", bytes: 430_801_616 }, + harness: { name: "localharness_external.exe", bytes: 130_971_800 }, + }, + ], + [ + "win32-arm64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-arm64.zip", + sha256: "35f4b1f47ba6a3fea7b0a3e30010df5ea73a64b4f0e7cf991cddc673ddfbcafc", + archiveBytes: 468_521_191, + executable: { name: "agy_acp_server.exe", bytes: 435_075_816 }, + harness: { name: "localharness_external.exe", bytes: 122_455_704 }, + }, + ], +]); + +export function resolveAntigravityReleaseAsset( + platform: NodeJS.Platform, + arch: string, +): AntigravityReleaseAsset | null { + return releaseAssets.get(`${platform}-${arch}`) ?? null; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 3eef461b8..d2d13b40e 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; @@ -37,6 +38,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AntigravityDriverEnv | AcpRegistryDriverEnv | ClaudeDriverEnv | CodexDriverEnv @@ -58,6 +60,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray + new ProviderSetupError({ + instanceId, + operation, + detail: "Could not read provider installation settings.", + }), + ), + ); + return deriveProviderInstanceConfigMap(current); + }); + + const requireInstance = Effect.fn("ProviderInstallation.requireInstance")(function* ( + instanceId: ProviderInstanceId, + operation: string, + managedOnly = false, + ) { + const instance = yield* instances.getInstance(instanceId); + if (instance?.driverKind !== ANTIGRAVITY) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "Managed installation is not available for this provider instance.", + }); + } + if (!managedOnly) return; + const entries = yield* readEntries(instanceId, operation); + const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation, + detail: "The Antigravity instance configuration is invalid.", + }), + ), + ); + if (config.binaryPath) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.", + }); + } + }); + + const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) => + new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); + + const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) { + yield* requireInstance(input.instanceId, "install", true); + return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); + }); + + const cancel = Effect.fn("ProviderInstallation.cancel")(function* ( + input: ProviderInstallCancelInput, + ) { + yield* requireInstance(input.instanceId, "cancel-install"); + return yield* installation + .cancel(input.operationId) + .pipe(Effect.mapError(failure(input.instanceId))); + }); + + const subscribe = (input: ProviderSetupInput) => + Stream.unwrap( + requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)), + ); + + const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) { + yield* requireInstance(input.instanceId, "remove-install", true); + const entries = yield* readEntries(input.instanceId, "remove-install"); + const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { + if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { + return Effect.succeed([]); + } + const binaryPath = entry.config.binaryPath.trim(); + return resolveCommandPath(binaryPath, { + env: mergeProviderInstanceEnvironment(entry.environment), + }).pipe( + Effect.map((resolved) => [binaryPath, resolved]), + Effect.catch(() => Effect.succeed([binaryPath])), + ); + }); + yield* installation + .remove(protectedPaths.flat()) + .pipe(Effect.mapError(failure(input.instanceId))); + const allInstances = yield* instances.listInstances; + yield* Effect.forEach( + allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY), + (instance) => providers.refreshInstance(instance.instanceId), + { discard: true }, + ); + return yield* installation.state; + }); + + return { start, cancel, subscribe, remove }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5eb6894da..c8dc46ee9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -77,6 +77,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; +import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; @@ -501,6 +502,7 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationWithHermesLive), + Layer.provideMerge(AntigravityInstallation.layer), ); const HermesCronWithServerSettingsLayerLive = HermesCron.layer.pipe( diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts new file mode 100644 index 000000000..cdf124698 --- /dev/null +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -0,0 +1,422 @@ +import { + type ModelSelection, + type ProviderSetupError, + TextGenerationError, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { type AcpError, AcpRequestError } from "effect-acp/errors"; + +import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; +import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; +import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as TextGeneration from "./TextGeneration.ts"; +import { + buildHandoffSummaryPrompt, + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const ANTIGRAVITY_TIMEOUT_MS = 180_000; +const MAX_OUTPUT_CHARS = 128_000; +const isTextGenerationError = Schema.is(TextGenerationError); +const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); +const Configuration = Schema.Record(Schema.String, Schema.Unknown); +const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); +const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); + +type AntigravityTextRuntime = Pick< + AcpSessionRuntime["Service"], + | "start" + | "setMode" + | "getConfigOptions" + | "getEvents" + | "setModel" + | "prompt" + | "cancel" + | "handleSessionUpdate" + | "handleRequestPermission" + | "handleElicitation" + | "handleReadTextFile" + | "handleWriteTextFile" + | "handleCreateTerminal" + | "handleTerminalOutput" + | "handleTerminalWaitForExit" + | "handleTerminalKill" + | "handleTerminalRelease" + | "handleUnknownExtRequest" +>; + +export interface AntigravityTextGenerationOptions { + readonly profileDirectory: string; + /** Model the provider default alias selects, when the account offers it. */ + readonly defaultModel?: Effect.Effect; + /** Uses the instance's personal Google login, with no injected MCP servers or client tools. */ + readonly makeRuntime: ( + cwd: string, + ) => Effect.Effect; + /** Registers the whole helper so sign-out can stop it before clearing credentials. */ + readonly withProcess: ( + stop: Effect.Effect, + task: Effect.Effect, + ) => Effect.Effect; +} + +/** Global hooks and MCP servers can run before a helper can deny a tool request. */ +export const isAntigravityTextGenerationAvailable = Effect.fn( + "isAntigravityTextGenerationAvailable", +)(function* (profileDirectory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const name of ["hooks.json", "mcp_config.json"]) { + const configurationPath = path.join(profileDirectory, "config", name); + if (!(yield* fs.exists(configurationPath))) { + continue; + } + const info = yield* fs.stat(configurationPath); + if (info.type !== "File" || info.size > 64_000n) { + return false; + } + const empty = yield* fs.readFileString(configurationPath).pipe( + Effect.flatMap(decodeConfiguration), + Effect.flatMap((configuration) => + decodeConfigurationObject( + configuration[name === "hooks.json" ? "hooks" : "mcpServers"] ?? configuration, + ), + ), + Effect.map((configuration) => Object.keys(configuration).length === 0), + Effect.orElseSucceed(() => false), + ); + if (!empty) return false; + } + return true; +}); + +/** Runs short-lived subscription helpers without giving them the user's workspace. */ +export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* ( + options: AntigravityTextGenerationOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")( + function* (input: { + readonly operation: keyof TextGeneration.TextGeneration["Service"]; + readonly prompt: string; + readonly outputSchema: S; + readonly modelSelection: ModelSelection; + }) { + const { operation } = input; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); + const helper = Effect.gen(function* () { + if (!(yield* available)) { + return yield* new TextGenerationError({ + operation, + detail: + "Antigravity text generation is unavailable for profiles with global hooks or MCP configuration. Select another system model.", + }); + } + + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-" }); + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory: options.profileDirectory, + sessionId, + cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ); + + const rawResult = yield* Effect.gen(function* () { + const runtime = yield* options.makeRuntime(cwd); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => + event._tag === "EventStreamBarrier" + ? Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + const output = yield* Ref.make(""); + const rejected = yield* Deferred.make(); + const reject = (detail: string) => + Deferred.fail(rejected, new TextGenerationError({ operation, detail })).pipe( + Effect.asVoid, + ); + const rejectToolRequest = () => + reject("Antigravity text generation requested a tool or user input.").pipe( + Effect.andThen( + Effect.fail( + new AcpRequestError({ + code: -32601, + errorMessage: "Tools and user input are disabled for text generation.", + }), + ), + ), + ); + + yield* runtime.handleRequestPermission(() => + reject("Antigravity text generation requested a tool permission or user input.").pipe( + Effect.as({ outcome: { outcome: "cancelled" as const } }), + ), + ); + yield* runtime.handleElicitation(() => + reject("Antigravity text generation requested user input.").pipe( + Effect.as({ action: { action: "decline" as const } }), + ), + ); + yield* runtime.handleReadTextFile(rejectToolRequest); + yield* runtime.handleWriteTextFile(rejectToolRequest); + yield* runtime.handleCreateTerminal(rejectToolRequest); + yield* runtime.handleTerminalOutput(rejectToolRequest); + yield* runtime.handleTerminalWaitForExit(rejectToolRequest); + yield* runtime.handleTerminalKill(rejectToolRequest); + yield* runtime.handleTerminalRelease(rejectToolRequest); + yield* runtime.handleUnknownExtRequest(rejectToolRequest); + yield* runtime.handleSessionUpdate((notification) => + Effect.gen(function* () { + const update = notification.update; + if ( + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" + ) { + return yield* reject("Antigravity attempted tool work during text generation."); + } + if ( + notification.sessionId !== sessionId || + update.sessionUpdate !== "agent_message_chunk" || + update.content.type !== "text" + ) { + return; + } + const text = update.content.text; + const exceeded = yield* Ref.modify(output, (current) => + current.length + text.length > MAX_OUTPUT_CHARS + ? [true, current] + : [false, current + text], + ); + if (exceeded) { + return yield* reject("Antigravity text generation exceeded the output limit."); + } + }), + ); + + return yield* Effect.gen(function* () { + const started = yield* runtime.start(); + sessionId = started.sessionId; + if (!isNativeSessionId(sessionId)) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity returned an invalid text helper session ID.", + }); + } + yield* runtime.setMode("default"); + yield* applyAntigravityAcpModelSelection({ + runtime, + model: input.modelSelection.model, + defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Could not select the Antigravity model for text generation.", + cause, + }), + }); + + const result = yield* runtime.prompt({ + prompt: [ + { + type: "text", + text: [ + "Use only the input below. Do not use tools, read or write files, run commands, or ask questions.", + "Return only the requested JSON object.", + "", + input.prompt, + ].join("\n"), + }, + ], + }); + if (yield* Deferred.isDone(rejected)) { + return yield* Deferred.await(rejected); + } + if (result.stopReason === "cancelled") { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity text generation was cancelled.", + }); + } + return (yield* Ref.get(output)).trim(); + }).pipe( + Effect.onInterrupt(() => + runtime.cancel.pipe(Effect.timeoutOption(2_000), Effect.ignore), + ), + Effect.raceFirst(Deferred.await(rejected)), + ); + }).pipe(Effect.scoped); + + if ((yield* fs.readDirectory(cwd)).length > 0) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity wrote files during text generation.", + }); + } + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity returned empty text generation output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Antigravity returned invalid structured output.", + cause, + }), + ), + ); + }).pipe( + Effect.scoped, + Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity text generation timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + return yield* options + .withProcess(Scope.close(scope, Exit.void), helper) + .pipe(Effect.provideService(Scope.Scope, scope)); + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "Antigravity text generation failed.", + cause, + }), + ), + Effect.scoped, + ), + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateCommitMessage", + ...buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }), + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generatePrContent", + ...buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }), + modelSelection: input.modelSelection, + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateBranchName", + ...buildBranchNamePrompt({ message: input.message, attachments: input.attachments }), + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateThreadTitle", + ...buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }), + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + const generateHandoffSummary: TextGeneration.TextGeneration["Service"]["generateHandoffSummary"] = + Effect.fn("AntigravityTextGeneration.generateHandoffSummary")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateHandoffSummary", + ...buildHandoffSummaryPrompt(input), + modelSelection: input.modelSelection, + }); + return { summary: generated.summary.trim() }; + }); + + return { + generateHandoffSummary, + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ac61fa5ee..173c5e4b0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,5 @@ +import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; +import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import { consumeInstanceResetCredit } from "./provider/consumeResetCredit.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import { withCreatedPullRequestLink } from "./git/linkCreatedPullRequest.ts"; @@ -25,6 +27,7 @@ import { type ApplicationStoredEvent, type AuthEnvironmentScope, AuthSessionId, + ProviderSetupError, ClientConnectionMethod, ClientDeviceType, ClientOs, @@ -664,6 +667,8 @@ const makeWsRpcLayer = ( ServerWsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; + const providerAuth = yield* ProviderAuthService; + const providerInstallation = yield* makeProviderInstallation(); const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -1858,13 +1863,62 @@ const makeWsRpcLayer = ( [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe( - Effect.tap(() => usageLimitSources.refresh), - Effect.map((providers) => ({ providers })), - ), + Effect.gen(function* () { + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const candidates = (yield* instances.listInstances).filter( + (instance) => + instance.enabled && + (input.instanceId === undefined || instance.instanceId === input.instanceId), + ); + for (const instance of candidates) { + const snapshot = yield* instance.snapshot.getSnapshot; + if (input.refreshModels && snapshot.installed && instance.refreshModels) { + yield* instance.refreshModels().pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refreshModels", + detail: cause.message, + cause, + }), + ), + ); + } + if (input.cwd && instance.snapshotForCwd) { + yield* instance.snapshotForCwd(input.cwd).pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refreshWorkspace", + detail: cause.message, + cause, + }), + ), + ); + } + } + if (input.cwd && !input.refreshModels) { + // Workspace discovery only reads skills. Selecting a thread must + // not launch a disposable provider health-check process. + const snapshots = yield* Effect.forEach( + candidates, + (instance) => instance.snapshot.getSnapshot, + ); + const providers = (yield* providerRegistry.getProviders).map( + (provider) => + snapshots.find((snapshot) => snapshot.instanceId === provider.instanceId) ?? + provider, + ); + return { providers }; + } + const providers = yield* input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); + yield* usageLimitSources.refresh; + return { providers }; + }), { "rpc.aggregate": "server" }, ), [WS_METHODS.providerUploadFeedback]: (input) => @@ -2012,6 +2066,52 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.providerAuthStart]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthStart, + providerAuth.start(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthComplete]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthComplete, + providerAuth.complete(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthCancel]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthCancel, + providerAuth.cancel(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthLogout]: (input) => + observeRpcEffect(WS_METHODS.providerAuthLogout, providerAuth.logout(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerAuthSubscribe]: (input) => + observeRpcStream( + WS_METHODS.providerAuthSubscribe, + providerAuth.subscribe(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerInstallStart]: (input) => + observeRpcEffect(WS_METHODS.providerInstallStart, providerInstallation.start(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerInstallCancel]: (input) => + observeRpcEffect(WS_METHODS.providerInstallCancel, providerInstallation.cancel(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerInstallSubscribe]: (input) => + observeRpcStream( + WS_METHODS.providerInstallSubscribe, + providerInstallation.subscribe(input), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerInstallRemove]: (input) => + observeRpcEffect(WS_METHODS.providerInstallRemove, providerInstallation.remove(input), { + "rpc.aggregate": "provider", + }), [WS_METHODS.providerConsumeResetCredit]: (input) => observeRpcEffect( WS_METHODS.providerConsumeResetCredit, diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 122440ea7..09a1a56c7 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -458,9 +458,20 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try await createProject(client: client, path: path) } + func refreshProviderWorkspace(projectID: String, instanceID: String, cwd: String?) async throws { + let route = try projectRoute(for: projectID) + let config = try await route.client.serverConfig() + guard let provider = config.providers.first(where: { $0.instanceId == instanceID }), + provider.driver == "antigravity", provider.enabled, + provider.setup != nil else { return } + let root = cwd + guard let root else { return } + _ = try await route.client.refreshProviderSnapshots(instanceID: instanceID, cwd: root) + } + func refreshSetupProviders(environmentID: String) async throws -> [ServerProviderSnapshot] { let client = try await environmentClient(id: environmentID) - return try await client.refreshProviderSnapshots() + return try await client.refreshProviderSnapshots(refreshModels: true) } func makeAgentSetupTerminal(environmentID: String, providerInstanceID: String) async throws -> any FeatureAgentSetupTerminal { @@ -576,7 +587,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, func usageLimits(environmentID: String, refresh: Bool) async throws -> [ServerProviderSnapshot] { let client = try await environmentClient(id: environmentID) - if refresh { return try await client.refreshProviderSnapshots() } + if refresh { return try await client.refreshProviderSnapshots(refreshModels: true) } return try await client.serverConfig().providers } @@ -4637,7 +4648,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, title: item.base.title ?? approvalTitle(for: requestKind), detail: prompt ?? "", options: options?.compactMap { option in - FeatureApprovalDecision(providerDecision: option.decision).map { FeatureApprovalOption(decision: $0, label: option.label) } + FeatureApprovalDecision(providerDecision: option.decision).map { FeatureApprovalOption(warning: option.warning, decision: $0, label: option.label) } } ) ) @@ -4662,8 +4673,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, header: $0.header, question: $0.question, options: $0.options.map { - FeatureInputOption(label: $0.label, detail: $0.description) - } + FeatureInputOption(label: $0.label, detail: $0.description, value: $0.value) + }, + allowsMultiple: $0.multiSelect ?? false, + allowCustomAnswer: $0.allowCustomAnswer ) } ) @@ -5414,7 +5427,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, modelPreferences: [String: ProviderModelPreferencesSnapshot] ) -> [FeatureProvider] { Self.normalizedProviders(providers.map { provider in - FeatureProvider( + var mapped = FeatureProvider( id: provider.instanceId, name: provider.displayName ?? providerDisplayName(provider.driver), isAvailable: provider.enabled @@ -5468,6 +5481,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) } ) + mapped.workspaceSnapshots = provider.workspaceSnapshots?.map { workspace in + FeatureProviderWorkspace(cwd: workspace.cwd, + slashCommands: workspace.slashCommands.map { .init(name: $0.name, description: $0.description, inputHint: $0.input?.hint) }, + skills: workspace.skills.map { .init(name: $0.name, displayName: $0.displayName, description: $0.description, + shortDescription: $0.shortDescription, path: $0.path, scope: $0.scope, isEnabled: $0.enabled) }) + } + return mapped }) } @@ -6739,6 +6759,18 @@ extension NativeFeatureClient: FeatureHermesInboxManaging { /// touched rather than to the active one: Settings lists every paired server, /// and a write has to land on the one it was made against. extension NativeFeatureClient: FeatureServerSettingsManaging { + func providerSetup(environmentID: String, instanceID: String, action: NativeProviderSetupAction) async throws { + let client = try await environmentClient(id: environmentID) + try await client.providerSetup(instanceID: instanceID, action: action) + } + func providerAuthEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream { + let client = try await environmentClient(id: environmentID) + return await client.providerAuthEvents(instanceID: instanceID) + } + func providerInstallEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream { + let client = try await environmentClient(id: environmentID) + return await client.providerInstallEvents(instanceID: instanceID) + } func providerUpdateEvents(environmentID: String) async throws -> AsyncThrowingStream<[ServerProviderSnapshot], Error> { let client = try await environmentClient(id: environmentID) let events = await client.serverConfigEvents() diff --git a/apps/swift-ios/Core/OrchestrationV2Models.swift b/apps/swift-ios/Core/OrchestrationV2Models.swift index ac82f2e99..b0d0caebc 100644 --- a/apps/swift-ios/Core/OrchestrationV2Models.swift +++ b/apps/swift-ios/Core/OrchestrationV2Models.swift @@ -87,11 +87,14 @@ public struct OrchestrationV2CheckpointFileSummary: Codable, Equatable, Sendable } public struct OrchestrationV2UserInputOption: Codable, Equatable, Sendable { + public var value: String? = nil public let label: String public let description: String } public struct OrchestrationV2UserInputQuestion: Codable, Equatable, Sendable { + public var allowCustomAnswer: Bool? = nil + public var multiSelect: Bool? = nil public let id: String public let header: String public let question: String @@ -1547,6 +1550,7 @@ public extension OrchestrationV2ThreadProjection { } public struct ProviderApprovalOption: Codable, Equatable, Hashable, Sendable { + public var warning: String? = nil public let decision: String public let label: String } diff --git a/apps/swift-ios/Core/ProviderSetup.swift b/apps/swift-ios/Core/ProviderSetup.swift new file mode 100644 index 000000000..7fbea52c1 --- /dev/null +++ b/apps/swift-ios/Core/ProviderSetup.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct NativeProviderAuthState: Codable, Equatable, Sendable { + public let instanceId: String + public let phase: String + public let flowId: String? + public let authorizationUrl: String? + public let expiresAt: String? + public let message: String? + public var isActive: Bool { ["starting", "waiting", "verifying"].contains(phase) } + public var signInURL: URL? { + guard phase == "waiting", let authorizationUrl, let url = URL(string: authorizationUrl), + url.scheme == "https", url.host == "accounts.google.com" else { return nil } + return url + } +} + +public struct NativeProviderInstallState: Codable, Equatable, Sendable { + public let driver: String + public let operationId: String? + public let phase: String + public let downloadedBytes: Double + public let totalBytes: Double? + public let version: String? + public let installedVersion: String? + public let canRemove: Bool + public let message: String? + public var isActive: Bool { ["downloading", "extracting", "verifying"].contains(phase) } +} + +public struct NativeProviderSetupCapabilities: Codable, Equatable, Sendable { + public let canAuthenticate: Bool + public let canInstall: Bool +} + +public enum NativeProviderSetupAction: Sendable { + case startAuth, logout, startInstall, removeInstall + case completeAuth(flowID: String, callbackURL: String) + case cancelAuth(flowID: String) + case cancelInstall(operationID: String) + + public var method: String { + switch self { + case .startAuth: "provider.auth.start" + case .completeAuth: "provider.auth.complete" + case .cancelAuth: "provider.auth.cancel" + case .logout: "provider.auth.logout" + case .startInstall: "provider.install.start" + case .cancelInstall: "provider.install.cancel" + case .removeInstall: "provider.install.remove" + } + } + public func payload(instanceID: String) -> JSONValue { + var value: [String: JSONValue] = ["instanceId": .string(instanceID)] + switch self { + case let .completeAuth(flowID, callbackURL): value["flowId"] = .string(flowID); value["callbackUrl"] = .string(callbackURL) + case let .cancelAuth(flowID): value["flowId"] = .string(flowID) + case let .cancelInstall(operationID): value["operationId"] = .string(operationID) + default: break + } + return .object(value) + } +} diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift index 30bdd03ac..f0eb69add 100644 --- a/apps/swift-ios/Core/ServerConfigModels.swift +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -154,7 +154,18 @@ public struct ServerProviderSkillSnapshot: Codable, Equatable, Sendable { public let shortDescription: String? } +public struct ServerProviderWorkspaceSnapshot: Codable, Equatable, Sendable { + public let cwd: String + public let checkedAt: String + public let slashCommands: [ServerProviderSlashCommandSnapshot] + public let skills: [ServerProviderSkillSnapshot] +} + public struct ServerProviderSnapshot: Codable, Identifiable, Equatable, Sendable { + public var workspaceSnapshots: [ServerProviderWorkspaceSnapshot]? = nil + public var setup: NativeProviderSetupCapabilities? = nil + public var supportsConversationRollback: Bool? = nil + public var supportsTextGeneration: Bool? = nil public var versionAdvisory: ServerProviderVersionAdvisory? = nil public var updateState: ServerProviderUpdateState? = nil public var usageLimits: ServerProviderUsageLimits? = nil diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index 4188808fd..0a3e7d45a 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -157,6 +157,18 @@ public actor T3Client { } } + public func providerSetup(instanceID: String, action: NativeProviderSetupAction) async throws { + let _: JSONValue = try await rpc.request(action.method, payload: action.payload(instanceID: instanceID), as: JSONValue.self) + } + + public func providerAuthEvents(instanceID: String) async -> AsyncThrowingStream { + await rpc.subscribe("provider.auth.subscribe", payload: .object(["instanceId": .string(instanceID)]), as: NativeProviderAuthState.self) + } + + public func providerInstallEvents(instanceID: String) async -> AsyncThrowingStream { + await rpc.subscribe("provider.install.subscribe", payload: .object(["instanceId": .string(instanceID)]), as: NativeProviderInstallState.self) + } + public func updateProvider(driver: String, instanceID: String) async throws -> [ServerProviderSnapshot] { struct Payload: Decodable { let providers: [ServerProviderSnapshot] } let result = try await rpc.request("server.updateProvider", @@ -164,9 +176,13 @@ public actor T3Client { return result.providers } - public func refreshProviderSnapshots() async throws -> [ServerProviderSnapshot] { + public func refreshProviderSnapshots(refreshModels: Bool = false, instanceID: String? = nil, cwd: String? = nil) async throws -> [ServerProviderSnapshot] { struct Payload: Decodable { let providers: [ServerProviderSnapshot] } - let payload = try await rpc.request("server.refreshProviders", as: Payload.self) + var input: [String: JSONValue] = [:] + if refreshModels { input["refreshModels"] = .bool(true) } + if let instanceID { input["instanceId"] = .string(instanceID) } + if let cwd { input["cwd"] = .string(cwd) } + let payload = try await rpc.request("server.refreshProviders", payload: .object(input), as: Payload.self) return payload.providers } diff --git a/apps/swift-ios/DesignSystem/ProviderIcon.swift b/apps/swift-ios/DesignSystem/ProviderIcon.swift index 7564049a8..c4becdc2a 100644 --- a/apps/swift-ios/DesignSystem/ProviderIcon.swift +++ b/apps/swift-ios/DesignSystem/ProviderIcon.swift @@ -4,6 +4,7 @@ enum ProviderBrand: String { case openAI = "ProviderOpenAI" case claude = "ProviderClaude" case cursor = "ProviderCursor" + case antigravity = "ProviderAntigravity" case grok = "ProviderGrok" case openCode = "ProviderOpenCode" @@ -25,6 +26,7 @@ enum ProviderBrand: String { return .cursor case "grok", "xai", "xaigrok": return .grok + case "antigravity", "googleantigravity": return .antigravity case "opencode": return .openCode default: @@ -37,7 +39,7 @@ enum ProviderBrand: String { var usesTemplateRendering: Bool { switch self { case .openAI, .cursor, .grok: true - case .claude, .openCode: false + case .claude, .openCode, .antigravity: false } } } diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift index fdc996bfe..c7d7467f5 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -68,6 +68,7 @@ struct FeatureComposerApprovalPanel: View { VStack(spacing: 9) { if let options = approval.options { ForEach(options, id: \.decision) { option in + if let warning = option.warning { Text(warning).font(T3Typography.supporting).foregroundStyle(T3Colors.warning) } approvalButton(option.label, background: option.decision == .allowOnce ? T3Colors.accent : Color.clear, border: T3Colors.border, @@ -272,7 +273,7 @@ struct FeatureComposerUserInputPanel: View { VStack(spacing: 6) { ForEach( Array(question.options.enumerated()), - id: \.element.label + id: \.offset ) { index, option in optionButton(option, number: index + 1, question: question) } @@ -283,6 +284,7 @@ struct FeatureComposerUserInputPanel: View { .frame(maxHeight: 320) .scrollIndicators(.hidden) + if question.allowCustomAnswer != false { HStack(spacing: 8) { Image(systemName: "pencil") .font(T3Typography.supporting) @@ -310,7 +312,8 @@ struct FeatureComposerUserInputPanel: View { .padding(.horizontal, 10) .padding(.top, 7) - if input.allowsAttachments == true { + } + if input.allowsAttachments == true && question.allowCustomAnswer != false { HStack { FeatureImageAttachmentPicker(attachments: Binding( get: { files[question.id] ?? [] }, set: { files[question.id] = $0 } @@ -395,10 +398,10 @@ struct FeatureComposerUserInputPanel: View { number: Int, question: FeatureInputQuestion ) -> some View { - let isSelected = isOptionSelected(option.label, for: question) + let isSelected = isOptionSelected(option.answerValue, for: question) return Button { - select(option.label, for: question) + select(option.answerValue, for: question) } label: { HStack(alignment: .center, spacing: 10) { VStack(alignment: .leading, spacing: 2) { @@ -519,7 +522,7 @@ enum FeatureComposerCustomAnswer { in answer: FeatureInputAnswer?, for question: FeatureInputQuestion ) -> String { - let optionLabels = Set(question.options.map(\.label)) + let optionLabels = Set(question.options.map(\.answerValue)) switch answer { case let .text(value): return optionLabels.contains(value) ? "" : value @@ -536,7 +539,7 @@ enum FeatureComposerCustomAnswer { for question: FeatureInputQuestion ) -> FeatureInputAnswer { guard question.allowsMultiple else { return .text(text) } - let optionLabels = Set(question.options.map(\.label)) + let optionLabels = Set(question.options.map(\.answerValue)) let selectedOptions: [String] if case let .selections(values) = answer { selectedOptions = values.filter(optionLabels.contains) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 448719e96..bcaf13c3a 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -132,6 +132,11 @@ public struct ThreadDetailView: View { .accessibilityIdentifier("thread-details-button") } } + .task(id: "\(currentThread.projectID):\(currentSelection?.providerID ?? ""):\(threadWorkspaceRoot ?? "")") { + guard let instanceID = currentSelection?.providerID, + threadProviders.first(where: { $0.id == instanceID })?.driver == "antigravity" else { return } + try? await model.client.refreshProviderWorkspace(projectID: currentThread.projectID, instanceID: instanceID, cwd: threadWorkspaceRoot) + } .task(id: draftKey) { for await key in await draftStore.discardedDrafts() where key == draftKey { draftSaveTask?.cancel() @@ -766,7 +771,7 @@ public struct ThreadDetailView: View { private var composerPowerFeatures: FeatureComposerPowerFeatures { let selectedProviderID = currentSelection?.providerID - let provider = threadProviders.first { $0.id == selectedProviderID } + let provider = threadProviders.first { $0.id == selectedProviderID }?.inWorkspace(threadWorkspaceRoot) return FeatureComposerPowerFeatures( slashCommands: provider?.slashCommands ?? [], skills: provider?.skills ?? [], diff --git a/apps/swift-ios/Features/Settings/ProviderAccountDraft.swift b/apps/swift-ios/Features/Settings/ProviderAccountDraft.swift index 46b9f74b2..bbfaa3a01 100644 --- a/apps/swift-ios/Features/Settings/ProviderAccountDraft.swift +++ b/apps/swift-ios/Features/Settings/ProviderAccountDraft.swift @@ -18,6 +18,8 @@ struct NativeProviderSettingsDefinition: Decodable, Identifiable, Sendable { let placeholder: String? let clearWhenEmpty: String let defaultBooleanValue: Bool? + var options: [Choice]? = nil + struct Choice: Decodable, Sendable { let value: String; let label: String } var id: String { key } } struct EnvironmentField: Decodable, Identifiable, Sendable { diff --git a/apps/swift-ios/Features/Settings/ServerSettingsWriting.swift b/apps/swift-ios/Features/Settings/ServerSettingsWriting.swift index 993cfbe3f..873b1144b 100644 --- a/apps/swift-ios/Features/Settings/ServerSettingsWriting.swift +++ b/apps/swift-ios/Features/Settings/ServerSettingsWriting.swift @@ -14,6 +14,9 @@ import Foundation /// working, its rows just refuse the write instead of going missing. @MainActor public protocol FeatureServerSettingsManaging: AnyObject { + func providerSetup(environmentID: String, instanceID: String, action: NativeProviderSetupAction) async throws + func providerAuthEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream + func providerInstallEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream func updateDesktopApp(environmentID: String, progress: @escaping @Sendable (String) async -> Void) async throws -> String func providerUpdateEvents(environmentID: String) async throws -> AsyncThrowingStream<[ServerProviderSnapshot], Error> func updateProvider(environmentID: String, driver: String, instanceID: String) async throws -> [ServerProviderSnapshot] @@ -87,3 +90,9 @@ extension FeatureServerSettingsManaging { throw FeatureCapabilityUnavailable("Remote desktop updates") } } + +extension FeatureServerSettingsManaging { + public func providerSetup(environmentID: String, instanceID: String, action: NativeProviderSetupAction) async throws { throw FeatureCapabilityUnavailable("Provider setup") } + public func providerAuthEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream { throw FeatureCapabilityUnavailable("Provider setup") } + public func providerInstallEvents(environmentID: String, instanceID: String) async throws -> AsyncThrowingStream { throw FeatureCapabilityUnavailable("Provider setup") } +} diff --git a/apps/swift-ios/Features/Settings/SettingsAntigravitySetupView.swift b/apps/swift-ios/Features/Settings/SettingsAntigravitySetupView.swift new file mode 100644 index 000000000..8800c972f --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsAntigravitySetupView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import UIKit + +struct SettingsAntigravitySetupView: View { + let manager: any FeatureServerSettingsManaging + let environmentID: String + let instanceID: String + let authMethod: String + let binaryPath: String + @SwiftUI.Environment(\.openURL) private var openURL + @State private var provider: ServerProviderSnapshot? + @State private var auth: NativeProviderAuthState? + @State private var installation: NativeProviderInstallState? + @State private var pending = false + @State private var errorMessage: String? + @State private var authError: String? + @State private var installError: String? + @State private var callbackURL = "" + @State private var retry = 0 + @State private var confirmLogout = false + @State private var confirmRemoval = false + + private var usesBrowser: Bool { authMethod == "oauth-personal" || authMethod == "oauth-business" } + private var installed: Bool { provider?.installed == true || (binaryPath.isEmpty && installation?.installedVersion != nil) } + private var environmentName: String { "the selected server" } + private var stateReady: Bool { auth != nil && installation != nil && authError == nil && installError == nil } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Antigravity runs on \(environmentName).").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + if let error = errorMessage ?? authError ?? installError { SettingsErrorBanner(message: error) } + if provider?.setup == nil { + Text("Update this server to install Antigravity and sign in here.").font(T3Typography.supporting) + } else { + runtimeSection + authSection + } + if pending { ProgressView("Updating setup…").frame(maxWidth: .infinity) } + Button("Refresh setup status") { retry += 1 }.disabled(pending) + }.padding(18) + } + .background(T3Colors.background) + .navigationTitle("Antigravity setup").navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(pending) + .task(id: retry) { await observeAuth() } + .task(id: retry) { await observeInstallation() } + .task(id: retry) { await observeProvider() } + .onChange(of: auth?.flowId) { _, _ in callbackURL = "" } + .confirmationDialog("Disconnect this Antigravity account?", isPresented: $confirmLogout, titleVisibility: .visible) { + Button("Disconnect", role: .destructive) { Task { await perform(.logout) } } + } message: { Text("This stops this account’s running threads on \(environmentName). Thread history is kept.") } + .confirmationDialog("Remove the downloaded runtime?", isPresented: $confirmRemoval, titleVisibility: .visible) { + Button("Remove runtime", role: .destructive) { Task { await perform(.removeInstall) } } + } message: { Text("Other accounts on this server may share this runtime. Sign-in and thread history are kept. Active processes must stop before removal.") } + } + + private var runtimeSection: some View { + SettingsSection(title: "Runtime") { + VStack(alignment: .leading, spacing: 12) { + if let installation { + Text(installation.message ?? (installed ? "Antigravity is installed." : "Install the official Google runtime before signing in.")) + .font(T3Typography.supporting) + if let version = installation.installedVersion { LabeledContent("Installed", value: version) } + if installation.phase == "downloading" { + if let total = installation.totalBytes, total > 0 { + ProgressView(value: min(installation.downloadedBytes, total), total: total) + Text("\(Int(installation.downloadedBytes / 1_000_000)) of \(Int(total / 1_000_000)) MB").font(.caption.monospacedDigit()) + } else { ProgressView("Downloading…") } + } else if installation.isActive { ProgressView(installation.phase == "extracting" ? "Extracting…" : "Verifying…") } + if installation.isActive, let operationID = installation.operationId { + Button("Cancel installation") { Task { await perform(.cancelInstall(operationID: operationID)) } } + } else if provider?.setup?.canInstall == true, binaryPath.isEmpty { + Button(installation.installedVersion == nil ? "Install Antigravity" : installation.version != installation.installedVersion ? "Update Antigravity" : "Reinstall Antigravity") { + Task { await perform(.startInstall) } + }.disabled(!stateReady || auth?.isActive == true) + if installation.canRemove { Button("Remove downloaded runtime", role: .destructive) { confirmRemoval = true }.disabled(auth?.isActive == true) } + } + if !binaryPath.isEmpty { Text("This account uses a custom executable. Clear its binary path in account settings to manage the downloaded runtime.").font(.caption).foregroundStyle(T3Colors.textSecondary) } + } else { ProgressView("Reading installation status…") } + }.frame(maxWidth: .infinity, alignment: .leading).padding(SettingsMetrics.rowPadding).disabled(pending) + } + } + + private var authSection: some View { + SettingsSection(title: usesBrowser ? "Google sign-in" : "Credentials") { + VStack(alignment: .leading, spacing: 12) { + if let auth { + Text(auth.message ?? (auth.phase == "succeeded" || provider?.auth.status == "authenticated" ? "Connected." : auth.isActive ? "Waiting for sign-in to complete." : "Connect using this account’s saved sign-in method.")) + .font(T3Typography.supporting) + if auth.isActive { ProgressView().controlSize(.small) } + if let url = auth.signInURL { + Button("Open sign-in page") { openURL(url) } + Button("Copy sign-in link") { UIPasteboard.general.url = url } + if let expiry = auth.expiresAt, let date = ISO8601DateFormatter().date(from: expiry) { Text("Link expires \(date.formatted(date: .omitted, time: .shortened)).").font(.caption).foregroundStyle(T3Colors.textSecondary) } + Text("If the final localhost page does not load, copy its full address from the browser and paste it below.").font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + TextField("http://127.0.0.1:…", text: $callbackURL).textContentType(.URL).keyboardType(.URL) + .textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(.roundedBorder) + Button("Continue") { + if let flowID = auth.flowId { Task { await perform(.completeAuth(flowID: flowID, callbackURL: callbackURL)) } } + }.disabled(callbackURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || callbackURL.count > 16_384) + } else if auth.phase == "waiting" { Text("Sign-in is open in another client. Complete or cancel it there.").font(T3Typography.supporting) } + if auth.isActive, let flowID = auth.flowId { + Button("Cancel sign-in") { Task { await perform(.cancelAuth(flowID: flowID)) } } + } else if !auth.isActive, provider?.setup?.canAuthenticate == true { + if provider?.auth.status != "authenticated" { + Button(usesBrowser ? "Sign in with Google" : "Connect") { Task { await perform(.startAuth) } } + .disabled(!installed || !stateReady || installation?.isActive == true) + } + Button(usesBrowser ? "Sign out of Google" : "Disconnect", role: .destructive) { confirmLogout = true } + } + } else { ProgressView("Reading sign-in status…") } + }.buttonStyle(.bordered).controlSize(.large).frame(maxWidth: .infinity, alignment: .leading) + .padding(SettingsMetrics.rowPadding).disabled(pending) + } + } + + private func perform(_ action: NativeProviderSetupAction) async { + guard !pending else { return } + pending = true; errorMessage = nil + defer { pending = false } + do { + try await manager.providerSetup(environmentID: environmentID, instanceID: instanceID, action: action) + if case .completeAuth = action { callbackURL = "" } + } catch { if !Task.isCancelled { errorMessage = error.localizedDescription } } + } + private func observeAuth() async { + authError = nil + do { for try await state in try await manager.providerAuthEvents(environmentID: environmentID, instanceID: instanceID) { + try Task.checkCancellation(); auth = state + }} catch { if !Task.isCancelled { authError = error.localizedDescription } } + } + private func observeInstallation() async { + installError = nil + do { for try await state in try await manager.providerInstallEvents(environmentID: environmentID, instanceID: instanceID) { + try Task.checkCancellation(); installation = state + }} catch { if !Task.isCancelled { installError = error.localizedDescription } } + } + private func observeProvider() async { + do { + let config = try await manager.providerModelConfiguration(environmentID: environmentID) + try Task.checkCancellation(); provider = config.providers.first { $0.instanceId == instanceID } + for try await providers in try await manager.providerUpdateEvents(environmentID: environmentID) { + try Task.checkCancellation(); provider = providers.first { $0.instanceId == instanceID } + } + } catch { if !Task.isCancelled { errorMessage = error.localizedDescription } } + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsProviderAccountView.swift b/apps/swift-ios/Features/Settings/SettingsProviderAccountView.swift index ef93cd7ff..060949c65 100644 --- a/apps/swift-ios/Features/Settings/SettingsProviderAccountView.swift +++ b/apps/swift-ios/Features/Settings/SettingsProviderAccountView.swift @@ -34,7 +34,16 @@ struct SettingsProviderAccountView: View { } if let definition { identitySection(draft, definition: definition).disabled(updatingProvider || !supported) - if !draft.isNew { providerUpdateSection } + if !draft.isNew { + if draft.driver == "antigravity" { + SettingsSection(title: "Antigravity", footer: "Save account changes before installing or signing in. Setup runs on the selected server.") { + NavigationLink { + SettingsAntigravitySetupView(manager: manager, environmentID: environmentID, instanceID: draft.instanceID, authMethod: draft.config["authMethod"]?.stringValue ?? "oauth-personal", binaryPath: draft.config["binaryPath"]?.stringValue ?? "") + } label: { Label("Install and sign in", systemImage: "person.crop.circle.badge.checkmark").frame(minHeight: T3Metrics.minimumTapTarget) } + .disabled(!supported || draft.envelope != draft.original).padding(SettingsMetrics.rowPadding) + } + } else { providerUpdateSection } + } configurationSection(draft, definition: definition).disabled(updatingProvider || !supported) environmentSection(draft, definition: definition).disabled(updatingProvider || !supported) if draft.canRemove { @@ -183,6 +192,10 @@ struct SettingsProviderAccountView: View { if field.control == "switch" { Toggle(field.label, isOn: Binding(get: { value.config[field.key] == .bool(true) || (value.config[field.key] == nil && field.defaultBooleanValue == true) }, set: { draft?.setField(field, value: .bool($0)) })) .frame(minHeight: T3Metrics.minimumTapTarget) + } else if field.control == "select", let choices = field.options { + Picker(field.label, selection: Binding(get: { draft?.config[field.key]?.stringValue ?? choices.first?.value ?? "" }, set: { draft?.setField(field, value: .string($0)) })) { + ForEach(choices, id: \.value) { choice in Text(choice.label).tag(choice.value) } + }.frame(minHeight: T3Metrics.minimumTapTarget) } else { Text(field.label).font(T3Typography.supportingStrong) let binding = Binding(get: { draft?.config[field.key]?.stringValue ?? "" }, set: { draft?.setField(field, value: .string($0)) }) diff --git a/apps/swift-ios/Features/Settings/SettingsThreadOrganizationView.swift b/apps/swift-ios/Features/Settings/SettingsThreadOrganizationView.swift index 27293d557..21faf5731 100644 --- a/apps/swift-ios/Features/Settings/SettingsThreadOrganizationView.swift +++ b/apps/swift-ios/Features/Settings/SettingsThreadOrganizationView.swift @@ -75,7 +75,9 @@ struct SettingsThreadOrganizationView: View { ThreadDetailsDivider() Text("Generated titles and Git messages").font(T3Typography.supportingStrong).padding(.horizontal, 14).padding(.top, 14) ProviderModelPicker( - providers: model.snapshot.providersByEnvironment?[environmentID] ?? [], + providers: (model.snapshot.providersByEnvironment?[environmentID] ?? []).filter { provider in + config?.providers.first(where: { $0.instanceId == provider.id })?.supportsTextGeneration != false + }, selection: Binding(get: { generationModel }, set: { selection in if let selection { Task { await save(.init(textGenerationModelSelection: coreSelection(selection))) } } }), diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 3db1ab68d..13e0e3598 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -149,6 +149,8 @@ public protocol FeatureClient: AnyObject { func saveSettings(_ settings: FeatureSettings) async throws + func refreshProviderWorkspace(projectID: String, instanceID: String, cwd: String?) async throws + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] func searchProjectFiles( projectID: String, @@ -226,6 +228,8 @@ public protocol FeatureClient: AnyObject { } public extension FeatureClient { + func refreshProviderWorkspace(projectID: String, instanceID: String, cwd: String?) async throws {} + func loadEarlierThreadTurns(id _: String) async throws -> FeatureThreadDetail? { nil } diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index a2fa6c6cf..f091c641c 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -584,10 +584,13 @@ public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Coda } public struct FeatureInputOption: Sendable, Equatable, Hashable, Codable { + public var value: String? = nil + public var answerValue: String { value ?? label } public var label: String public var detail: String - public init(label: String, detail: String) { + public init(label: String, detail: String, value: String? = nil) { + self.value = value self.label = label self.detail = detail } @@ -655,6 +658,7 @@ extension FeatureInputAnswer { } public struct FeatureInputQuestion: Identifiable, Sendable, Equatable, Hashable, Codable { + public var allowCustomAnswer: Bool? = nil public let id: String public var header: String public var question: String @@ -666,8 +670,10 @@ public struct FeatureInputQuestion: Identifiable, Sendable, Equatable, Hashable, header: String, question: String, options: [FeatureInputOption] = [], - allowsMultiple: Bool = false + allowsMultiple: Bool = false, + allowCustomAnswer: Bool? = nil ) { + self.allowCustomAnswer = allowCustomAnswer self.id = id self.header = header self.question = question @@ -1085,6 +1091,12 @@ public struct FeatureModelOptionSelection: Identifiable, Sendable, Equatable, Ha } } +public struct FeatureProviderWorkspace: Sendable, Equatable, Hashable, Codable { + public let cwd: String + public let slashCommands: [FeatureProviderSlashCommand] + public let skills: [FeatureProviderSkill] +} + public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Codable { public let id: String public var name: String @@ -1097,9 +1109,18 @@ public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Coda /// composer hides the toggle rather than sending a setting nothing honors. public var supportsPlanMode: Bool public var models: [FeatureModel] + public var workspaceSnapshots: [FeatureProviderWorkspace]? = nil public var slashCommands: [FeatureProviderSlashCommand]? public var skills: [FeatureProviderSkill]? + public func inWorkspace(_ cwd: String?) -> Self { + guard let cwd, let workspace = workspaceSnapshots?.first(where: { $0.cwd == cwd }) else { return self } + var resolved = self + resolved.slashCommands = workspace.slashCommands + resolved.skills = workspace.skills + return resolved + } + public init( id: String, name: String, @@ -1364,6 +1385,7 @@ public enum FeatureApprovalDecision: String, Sendable, Codable { } public struct FeatureApprovalOption: Codable, Equatable, Hashable, Sendable { + public var warning: String? = nil public let decision: FeatureApprovalDecision public let label: String } diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 41b726656..26bd86427 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -155,6 +155,11 @@ public struct NewThreadView: View { .onChange(of: selectedBranch) { scheduleDraftSave() } .onChange(of: startFromOrigin) { scheduleDraftSave() } .onChange(of: routing) { scheduleDraftSave() } + .task(id: "\(executionProject?.id ?? ""):\(selection?.providerID ?? "")") { + guard let project = executionProject, let instanceID = selection?.providerID, + creationProviders.first(where: { $0.id == instanceID })?.driver == "antigravity" else { return } + try? await model.client.refreshProviderWorkspace(projectID: project.id, instanceID: instanceID, cwd: project.path) + } .task(id: balancingRequest) { await balanceEnvironment() } .task(id: routing?.projectID) { if restoredDraftProjectID == projectID, routing?.projectID != nil { await loadBranches() } @@ -597,7 +602,7 @@ public struct NewThreadView: View { private var composerPowerFeatures: FeatureComposerPowerFeatures { let provider = creationProviders.first { $0.id == selection?.providerID - } + }?.inWorkspace(executionProject?.path) guard let project = executionProject else { return FeatureComposerPowerFeatures( slashCommands: provider?.slashCommands ?? [], diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json new file mode 100644 index 000000000..ce4f42088 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "antigravity.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png new file mode 100644 index 000000000..df1e22dbb Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png differ diff --git a/apps/swift-ios/Resources/ProviderSettingsCatalog.json b/apps/swift-ios/Resources/ProviderSettingsCatalog.json index 7521a29b2..07ccefe4a 100644 --- a/apps/swift-ios/Resources/ProviderSettingsCatalog.json +++ b/apps/swift-ios/Resources/ProviderSettingsCatalog.json @@ -334,6 +334,71 @@ } ] }, + { + "driver": "antigravity", + "label": "Antigravity", + "hasDefaultInstance": true, + "environmentFields": [], + "fields": [ + { + "key": "authMethod", + "control": "select", + "label": "Sign-in method", + "description": "Google account uses your Antigravity subscription. Gemini Enterprise needs a GCP project and location. API key and Agent Platform bill the credential you enter.", + "options": [ + { + "value": "oauth-personal", + "label": "Google account" + }, + { + "value": "oauth-business", + "label": "Gemini Enterprise" + }, + { + "value": "gemini-api-key", + "label": "Gemini API key" + }, + { + "value": "agent-platform", + "label": "Agent Platform (Vertex AI)" + } + ], + "clearWhenEmpty": "omit" + }, + { + "key": "apiKey", + "control": "password", + "label": "API key", + "description": "Gemini API key, or a Vertex AI express key for Agent Platform. Stored in plain text on this environment.", + "placeholder": "Optional", + "clearWhenEmpty": "omit" + }, + { + "key": "gcpProject", + "control": "text", + "label": "GCP project", + "description": "Required for Gemini Enterprise. Agent Platform uses it when no API key is set.", + "placeholder": "my-project-id", + "clearWhenEmpty": "omit" + }, + { + "key": "gcpLocation", + "control": "text", + "label": "GCP location", + "description": "Region for Gemini Enterprise or Agent Platform, such as us-central1.", + "placeholder": "us-central1", + "clearWhenEmpty": "omit" + }, + { + "key": "binaryPath", + "control": "text", + "label": "Binary path", + "description": "Optional path to the official Antigravity ACP executable. Leave empty for automatic selection.", + "placeholder": "Automatic", + "clearWhenEmpty": "persist" + } + ] + }, { "driver": "opencode", "label": "OpenCode", diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json index 7b28c351e..462ac8fdf 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/orchestrationV2Projection.json @@ -394,11 +394,14 @@ "requestId": "request-input", "questions": [ { + "multiSelect": false, + "allowCustomAnswer": false, "id": "q1", "header": "Pick", "question": "Which one?", "options": [ { + "value": "choice: opaque", "label": "A", "description": "First" } @@ -1036,11 +1039,14 @@ "requestId": "request-input", "questions": [ { + "multiSelect": false, + "allowCustomAnswer": false, "id": "q1", "header": "Pick", "question": "Which one?", "options": [ { + "value": "choice: opaque", "label": "A", "description": "First" } diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/projectActions.json b/apps/swift-ios/Tests/CoreTests/Fixtures/projectActions.json index 793fdd583..ca896ef53 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/projectActions.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/projectActions.json @@ -121,6 +121,15 @@ "proactiveEnabled": true, "voiceEnabled": false }, + "antigravity": { + "enabled": false, + "authMethod": "oauth-personal", + "apiKey": "", + "gcpProject": "", + "gcpLocation": "", + "binaryPath": "", + "customModels": [] + }, "opencode": { "enabled": false, "binaryPath": "opencode", diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/projectAutoPull.json b/apps/swift-ios/Tests/CoreTests/Fixtures/projectAutoPull.json index 421626f15..083a5d46b 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/projectAutoPull.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/projectAutoPull.json @@ -100,6 +100,15 @@ "proactiveEnabled": true, "voiceEnabled": false }, + "antigravity": { + "enabled": false, + "authMethod": "oauth-personal", + "apiKey": "", + "gcpProject": "", + "gcpLocation": "", + "binaryPath": "", + "customModels": [] + }, "opencode": { "enabled": false, "binaryPath": "opencode", diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/projectBrowserAccess.json b/apps/swift-ios/Tests/CoreTests/Fixtures/projectBrowserAccess.json index bbd9f3d1e..26d8afddf 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/projectBrowserAccess.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/projectBrowserAccess.json @@ -100,6 +100,15 @@ "proactiveEnabled": true, "voiceEnabled": false }, + "antigravity": { + "enabled": false, + "authMethod": "oauth-personal", + "apiKey": "", + "gcpProject": "", + "gcpLocation": "", + "binaryPath": "", + "customModels": [] + }, "opencode": { "enabled": false, "binaryPath": "opencode", diff --git a/apps/swift-ios/Tests/CoreTests/Fixtures/projectDefaults.json b/apps/swift-ios/Tests/CoreTests/Fixtures/projectDefaults.json index 2d2c6b8e9..1bfdce545 100644 --- a/apps/swift-ios/Tests/CoreTests/Fixtures/projectDefaults.json +++ b/apps/swift-ios/Tests/CoreTests/Fixtures/projectDefaults.json @@ -104,6 +104,15 @@ "proactiveEnabled": true, "voiceEnabled": false }, + "antigravity": { + "enabled": false, + "authMethod": "oauth-personal", + "apiKey": "", + "gcpProject": "", + "gcpLocation": "", + "binaryPath": "", + "customModels": [] + }, "opencode": { "enabled": false, "binaryPath": "opencode", diff --git a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift index e11b4b844..511b51546 100644 --- a/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift +++ b/apps/swift-ios/Tests/CoreTests/OrchestrationV2ContractTests.swift @@ -95,6 +95,16 @@ final class OrchestrationV2ContractTests: XCTestCase { XCTAssertEqual(decoded.payload, item.payload) } + func testNativeQuestionChoicesPreserveOpaqueValues() throws { + let item = try XCTUnwrap(try projection().turnItems.first { $0.type == "user_input_request" }) + guard case let .userInputRequest(_, questions) = item.payload else { return XCTFail("Missing question") } + let question = try XCTUnwrap(questions.first) + XCTAssertEqual(question.allowCustomAnswer, false) + XCTAssertEqual(question.multiSelect, false) + XCTAssertEqual(question.options.first?.value, " choice: opaque ") + XCTAssertEqual(try JSONDecoder().decode(OrchestrationV2TurnItem.self, from: JSONEncoder().encode(item)), item) + } + func testApprovalOptionsRoundTripAndUnknownDecisionsStayUnavailable() throws { let projection = try projection() let item = try XCTUnwrap(projection.turnItems.first { $0.type == "approval_request" }) diff --git a/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift b/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift new file mode 100644 index 000000000..e38af6a92 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import T3Code + +final class ProviderSetupTests: XCTestCase { + func testOnlyWaitingGoogleFlowsOfferAnOpenableURL() throws { + for (phase, url, available) in [ + ("waiting", "https://accounts.google.com/o/oauth2/v2/auth?state=fixture", true), + ("succeeded", "https://accounts.google.com/o/oauth2/v2/auth", false), + ("waiting", "https://accounts.google.com.example.invalid/auth", false), + ("waiting", "http://accounts.google.com/auth", false) + ] { + let data = try JSONSerialization.data(withJSONObject: ["instanceId": "account-a", "phase": phase, "authorizationUrl": url]) + let state = try JSONDecoder().decode(NativeProviderAuthState.self, from: data) + XCTAssertEqual(state.signInURL != nil, available) + } + } + + func testRemoteCallbackPreservesTheOwningAccountAndFlow() { + let callback = "http://127.0.0.1:51234/?state=fixture&code=fake" + let action = NativeProviderSetupAction.completeAuth(flowID: "owned-flow", callbackURL: callback) + XCTAssertEqual(action.method, "provider.auth.complete") + XCTAssertEqual(action.payload(instanceID: "account-b"), .object([ + "instanceId": .string("account-b"), "flowId": .string("owned-flow"), "callbackUrl": .string(callback) + ])) + XCTAssertEqual(NativeProviderSetupAction.cancelInstall(operationID: "install-b").payload(instanceID: "account-b"), + .object(["instanceId": .string("account-b"), "operationId": .string("install-b")])) + } + + func testInstallFailureKeepsThePreviouslyInstalledVersionVisible() throws { + let data = Data(#"{"driver":"antigravity","phase":"failed","downloadedBytes":42,"totalBytes":100,"installedVersion":"1.0.0","version":"1.1.1","canRemove":true,"message":"Download failed"}"#.utf8) + let state = try JSONDecoder().decode(NativeProviderInstallState.self, from: data) + XCTAssertFalse(state.isActive) + XCTAssertEqual(state.installedVersion, "1.0.0") + XCTAssertTrue(state.canRemove) + } +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9f5803dc1..866701ea6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,5 @@ +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerRestingTransition } from "./useComposerRestingTransition"; import { Spinner } from "~/components/ui/spinner"; import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; @@ -633,7 +635,11 @@ export interface ChatComposerProps { isLastQuestion: boolean; canAdvance: boolean; customAnswer: string; - activeQuestion: { id: string; multiSelect?: boolean | undefined } | null; + activeQuestion: { + id: string; + multiSelect?: boolean | undefined; + allowCustomAnswer?: boolean | undefined; + } | null; } | null; activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; @@ -1059,10 +1065,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadIsAuthoritative: threadOwnsModelSelection, settings, }); - const selectedProviderStatus = useMemo( - () => selectedProviderEntry?.snapshot ?? null, - [selectedProviderEntry], - ); + const selectedProviderStatus = useMemo(() => { + const snapshot = selectedProviderEntry?.snapshot; + if (!snapshot) return null; + const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === gitCwd); + return workspace + ? { ...snapshot, slashCommands: workspace.slashCommands, skills: workspace.skills } + : snapshot; + }, [selectedProviderEntry, gitCwd]); + const refreshWorkspace = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + useEffect(() => { + if (!gitCwd || selectedProvider !== "antigravity" || !selectedProviderEntry?.snapshot.enabled) + return; + void refreshWorkspace({ + environmentId, + input: { instanceId: selectedInstanceId, cwd: gitCwd }, + }); + }, [ + environmentId, + selectedInstanceId, + selectedProvider, + selectedProviderEntry?.snapshot.enabled, + gitCwd, + refreshWorkspace, + ]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -3514,9 +3542,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposer} + disabled={ + activePendingProgress?.activeQuestion?.allowCustomAnswer === false + } aria-label="Write custom answer" > - {activePendingProgress?.customAnswer || "Write custom answer"} + {activePendingProgress?.activeQuestion?.allowCustomAnswer === false + ? "Select an answer above" + : activePendingProgress?.customAnswer || "Write custom answer"} {activePendingProgress?.activeQuestion?.multiSelect ? ( {activePendingProgress ? activePendingProgress.customAnswer || - "Type your own answer, or leave this blank to use the selected option" + (activePendingProgress.activeQuestion?.allowCustomAnswer === false + ? "Select one of the offered answers" + : "Type your own answer, or leave this blank to use the selected option") : prompt.trim() || (noProviderAvailable ? providerAvailabilityCopy.placeholder @@ -3854,7 +3889,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerApprovalState ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" + ? activePendingProgress.activeQuestion?.allowCustomAnswer === false + ? "Select one of the offered answers above" + : "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired @@ -3867,7 +3904,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? DISCONNECTED_COMPOSER_PLACEHOLDER : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} + disabled={ + isConnecting || + isComposerApprovalState || + projectSelectionRequired || + activePendingProgress?.activeQuestion?.allowCustomAnswer === false + } /> {isComposerResting && composerImages.some((image) => image.type === "image") ? (
) : null} + {approval.options + ?.filter((option) => option.warning) + .map((option) => ( +

+ {option.label}: {option.warning} +

+ ))} {approval.detail ? (
 document.removeEventListener("keydown", handler);
@@ -235,10 +235,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
             {activeQuestion.options.map((option, index) => {
               const isOptimisticallySelected =
                 optimisticSingleSelect?.questionId === activeQuestion.id &&
-                optimisticSingleSelect.optionLabel === option.label;
+                optimisticSingleSelect.optionLabel === (option.value ?? option.label);
               const isSelected =
                 isOptimisticallySelected ||
-                (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
+                (!customAnswerActive &&
+                  progress.selectedOptionLabels.includes(option.value ?? option.label));
               const shortcutKey = index < 9 ? index + 1 : null;
               const className = cn(
                 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25",
@@ -272,13 +273,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
               );
               return (
                 
+ {driverKind === "antigravity" && environmentId ? ( + updateEnabled(true)} + /> + ) : null} {driverOption ? ( | undefined) => void; } +/** Stores the default choice as an omitted key so unchanged configs stay small. */ +function ProviderSettingsSelect({ + field, + value, + inputId, + size, + className, + onChange, +}: { + readonly field: ProviderSettingsFieldModel; + readonly value: unknown; + readonly inputId: string; + readonly size: "sm" | "xs"; + readonly className?: string | undefined; + readonly onChange: ProviderSettingsFormProps["onChange"]; +}) { + const options = field.options ?? []; + const fallback = options[0]?.value ?? ""; + const current = readProviderConfigString(value, field.key) || fallback; + const label = options.find((option) => option.value === current)?.label ?? current; + return ( + + ); +} + function FieldFrame(props: { readonly variant: ProviderSettingsFormProps["variant"]; readonly children: ReactNode; @@ -87,6 +130,25 @@ function ProviderSettingsFieldRow({ ); } + if (field.control === "select") { + return ( + + + + ); + } + if (field.control === "textarea") { return ( diff --git a/apps/web/src/components/settings/ProviderSetupSection.tsx b/apps/web/src/components/settings/ProviderSetupSection.tsx new file mode 100644 index 000000000..49ad14cdd --- /dev/null +++ b/apps/web/src/components/settings/ProviderSetupSection.tsx @@ -0,0 +1,483 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + ANTIGRAVITY_AUTH_METHODS, + type AntigravityAuthMethod, + type EnvironmentId, + type ProviderAuthState, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { useRef, useState } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { ensureLocalApi } from "../../localApi"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +interface ProviderSetupSectionProps { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly instanceId: ProviderInstanceId; + readonly provider: ServerProvider | undefined; + readonly binaryPath?: string | undefined; + readonly authMethod?: AntigravityAuthMethod | undefined; + readonly enabled: boolean; + readonly readOnly: boolean; + readonly onEnable: () => void; +} + +const AUTH_PHASE_LABELS: Record = { + idle: "Sign in with your Google account.", + starting: "Starting Google sign-in.", + waiting: "Waiting for Google sign-in.", + verifying: "Checking Google sign-in and available models.", + succeeded: "Google sign-in complete.", + failed: "Google sign-in failed.", + cancelled: "Google sign-in cancelled.", +}; + +/** API key methods skip the browser, so the phases read as a credential check. */ +const CREDENTIAL_PHASE_LABELS: Record = { + idle: "Connect with the credentials in the provider settings.", + starting: "Checking credentials.", + waiting: "Checking credentials.", + verifying: "Checking credentials and available models.", + succeeded: "Connected.", + failed: "Could not connect with the configured credentials.", + cancelled: "Connection cancelled.", +}; + +/** Read the configured method from the instance config. Unknown values fall back to personal. */ +export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod { + const value = + config !== null && typeof config === "object" && "authMethod" in config + ? config.authMethod + : undefined; + return ( + ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal" + ); +} + +/** Setup state belongs to the selected environment and is never saved in client settings. */ +export function ProviderSetupSection(props: ProviderSetupSectionProps) { + return ( +
+

Antigravity runs on {props.environmentLabel}.

+ {!props.enabled ? ( +
+ Enable it to use it in threads. + {!props.readOnly ? ( + + ) : null} +
+ ) : null} + {props.readOnly ? ( +

This connection cannot change provider setup.

+ ) : props.provider?.setup === undefined ? ( +

+ Update this environment to install Antigravity and sign in with Google here. +

+ ) : ( + + )} +
+ ); +} + +function ProviderSetupActions({ + environmentId, + environmentLabel, + instanceId, + provider, + enabled, + binaryPath, + authMethod, +}: Pick< + ProviderSetupSectionProps, + "environmentId" | "environmentLabel" | "instanceId" | "enabled" | "binaryPath" +> & { + readonly provider: ServerProvider; + readonly authMethod: AntigravityAuthMethod; +}) { + const target = { environmentId, input: { instanceId } }; + const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business"; + const phaseLabels = usesBrowser ? AUTH_PHASE_LABELS : CREDENTIAL_PHASE_LABELS; + const methodLabel = + ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ?? + "Google account"; + const authQuery = useEnvironmentQuery(serverEnvironment.providerAuthState(target)); + const installQuery = useEnvironmentQuery(serverEnvironment.providerInstallState(target)); + const auth = authQuery.data; + const installation = installQuery.data; + const commandOptions = { reportFailure: false, reportDefect: false }; + const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, commandOptions); + const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, commandOptions); + const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, commandOptions); + const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, commandOptions); + const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, commandOptions); + const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, commandOptions); + const removeInstall = useAtomCommand( + serverEnvironment.removeProviderInstallation, + commandOptions, + ); + const [pendingLabel, setPendingLabel] = useState(null); + const pendingRef = useRef(false); + const [error, setError] = useState(null); + const [callbackDraft, setCallbackDraft] = useState({ flowId: null as string | null, value: "" }); + const [copiedFlowId, setCopiedFlowId] = useState(null); + const callbackUrl = callbackDraft.flowId === auth?.flowId ? callbackDraft.value : ""; + const authActive = + auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; + const installActive = + installation?.phase === "downloading" || + installation?.phase === "extracting" || + installation?.phase === "verifying"; + const usesCustomBinary = Boolean(binaryPath?.trim()); + const installed = + provider.installed || (!usesCustomBinary && installation?.installedVersion != null); + const authenticated = provider.auth.status === "authenticated"; + const authStatusMessage = + auth === null + ? "Reading sign-in status." + : authActive || auth.phase === "failed" || auth.phase === "cancelled" + ? (auth.message ?? phaseLabels[auth.phase]) + : authenticated + ? usesBrowser + ? "Signed in with Google." + : "Connected." + : auth.phase === "idle" && auth.message + ? auth.message + : phaseLabels.idle; + const authorizationUrl = auth?.phase === "waiting" ? auth.authorizationUrl : null; + const queryError = authQuery.error ?? installQuery.error; + const actionsDisabled = pendingLabel !== null || queryError !== null; + const installationStatusMessage = + installation?.phase === "downloading" + ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.` + : installation?.phase === "extracting" + ? "Extracting Antigravity." + : installation?.phase === "verifying" + ? "Checking the downloaded runtime." + : installed + ? "Antigravity is installed." + : usesCustomBinary + ? enabled + ? "The configured Antigravity runtime is unavailable." + : "The configured Antigravity runtime has not been checked." + : "Install the official Antigravity runtime before signing in."; + + async function runCommand( + label: string, + request: () => Promise>, + ): Promise { + if (pendingRef.current) return false; + pendingRef.current = true; + setPendingLabel(label); + setError(null); + try { + const result = await request(); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "Provider setup failed."); + } + return false; + } + return true; + } catch { + setError("Provider setup failed. Try again."); + return false; + } finally { + pendingRef.current = false; + setPendingLabel(null); + } + } + + async function openSignInPage() { + if (!authorizationUrl) return; + try { + await ensureLocalApi().shell.openExternal(authorizationUrl); + setError(null); + } catch { + setError("Could not open the sign-in page. Copy the link and open it in your browser."); + } + } + + async function copySignInLink() { + if (!authorizationUrl) return; + try { + await writeTextToClipboard(authorizationUrl, "Google sign-in link"); + setCopiedFlowId(auth?.flowId ?? null); + setError(null); + } catch { + setError("Could not copy the sign-in link. Use Open sign-in page."); + } + } + + async function submitCallback() { + const flowId = auth?.flowId; + if (!flowId || !callbackUrl.trim() || auth.phase !== "waiting") return; + const accepted = await runCommand("Checking redirect", () => + completeAuth({ environmentId, input: { instanceId, flowId, callbackUrl } }), + ); + if (accepted) { + setCallbackDraft({ flowId: null, value: "" }); + } + } + + async function signOut() { + const confirmed = await ensureLocalApi().dialogs.confirm( + `${usesBrowser ? "Sign out of Google" : "Disconnect"} for ${provider.displayName ?? "Antigravity"} on ${environmentLabel}? This stops its running threads. Thread history is kept.`, + ); + if (confirmed) { + await runCommand("Signing out", () => logoutAuth(target)); + } + } + + async function removeRuntime() { + const confirmed = await ensureLocalApi().dialogs.confirm( + `Remove the downloaded Antigravity runtime from ${environmentLabel}? Google sign-in and thread history are kept.`, + ); + if (confirmed) { + await runCommand("Removing runtime", () => removeInstall(target)); + } + } + + return ( +
+
+

Runtime

+

+ {installationStatusMessage} +

+ {installation?.phase === "downloading" && + installation.totalBytes !== null && + installation.totalBytes > 0 ? ( + + ) : null} + {installation?.message && installation.message !== installationStatusMessage ? ( +

{installation.message}

+ ) : null} + {usesCustomBinary ? ( +

+ This instance uses the binary path below. Installing a managed runtime does not change + that path. +

+ ) : null} + {!installed && !usesCustomBinary && !installActive && installation?.totalBytes ? ( +

+ Downloads {Math.ceil(installation.totalBytes / 1_000_000)} MB from Google. +

+ ) : null} + {!installed && !provider.setup?.canInstall ? ( +

+ Automatic installation is unavailable here. Set an existing binary path below or use a + supported remote environment. +

+ ) : null} +
+ {installActive && installation.operationId ? ( + + ) : !installActive && provider.setup?.canInstall ? ( + + ) : null} + {installation?.canRemove && !installActive ? ( + + ) : null} +
+
+ +
+

{methodLabel}

+

+ {authStatusMessage} +

+ {authorizationUrl ? ( + <> +
+ + +
+ {auth?.expiresAt ? ( +

+ Link expires at{" "} + + . +

+ ) : null} +
{ + event.preventDefault(); + void submitCallback(); + }} + > + + + setCallbackDraft({ flowId: auth?.flowId ?? null, value: event.target.value }) + } + /> + +
+ + ) : auth?.phase === "waiting" ? ( +

+ Sign-in is open in another client. Complete or cancel it there. +

+ ) : null} +
+ {authActive && auth?.flowId ? ( + + ) : !authActive && !authenticated && provider.setup?.canAuthenticate ? ( + + ) : null} + {!authActive && provider.setup?.canAuthenticate ? ( + + ) : null} +
+
+ + {pendingLabel ?

{pendingLabel}.

: null} + {error || queryError ? ( +
+

+ {error ?? queryError} +

+ {queryError ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7f9550add..7b5f0e421 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2289,7 +2289,12 @@ export function GeneralSettingsPanel() { const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries( + serverProviders.filter((provider) => provider.supportsTextGeneration !== false), + ), + settings, + ), ); const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, @@ -3228,7 +3233,7 @@ function EnvironmentProviderSettings( void (async () => { const result = await refreshServerProviders({ environmentId: targetEnvironment.environmentId, - input: {}, + input: { refreshModels: true }, }); refreshingRef.current = false; setIsRefreshingProviders(false); @@ -3527,6 +3532,8 @@ function EnvironmentProviderSettings( ) : null; return ( = { claudeAgent: ClaudeAI, cursor: CursorIcon, grok: GrokIcon, + antigravity: AntigravityIcon, hermes: HermesIcon, openclaw: OpenClawIcon, hermesAcp: HermesIcon, diff --git a/apps/web/src/components/settings/providerSettingsDefinitions.ts b/apps/web/src/components/settings/providerSettingsDefinitions.ts index c50aeca37..d1e8905db 100644 --- a/apps/web/src/components/settings/providerSettingsDefinitions.ts +++ b/apps/web/src/components/settings/providerSettingsDefinitions.ts @@ -1,4 +1,5 @@ import { + AntigravitySettings, AcpRegistrySettings, ClaudeSettings, CodexSettings, @@ -149,6 +150,11 @@ export const PROVIDER_SETTINGS_DEFINITIONS: readonly ProviderSettingsDefinition[ settingsSchema: AcpRegistrySettings, hasDefaultInstance: false, }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + settingsSchema: AntigravitySettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/components/settings/providerSettingsFields.ts b/apps/web/src/components/settings/providerSettingsFields.ts index c2acb5b72..d3dd5ac6c 100644 --- a/apps/web/src/components/settings/providerSettingsFields.ts +++ b/apps/web/src/components/settings/providerSettingsFields.ts @@ -3,11 +3,13 @@ import * as Schema from "effect/Schema"; import type { ProviderSettingsFormAnnotation, ProviderSettingsFormControl, + ProviderSettingsFormOption, ProviderSettingsFormSchemaAnnotation, } from "@t3tools/contracts"; import type { ProviderSettingsDefinition } from "./providerSettingsDefinitions.ts"; export interface ProviderSettingsFieldModel { + readonly options?: ReadonlyArray; readonly key: string; readonly control: ProviderSettingsFormControl; readonly label: string; @@ -93,6 +95,7 @@ export function deriveProviderSettingsFields( ...(formAnnotation.placeholder !== undefined ? { placeholder: formAnnotation.placeholder } : {}), + ...(formAnnotation.options ? { options: formAnnotation.options } : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 2d4eaeace..4b3d98f77 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -224,3 +224,20 @@ describe("pending user input question progress", () => { }); }); }); + +describe("native option identities", () => { + it("preserves opaque option values and forbids custom answers when requested", () => { + const question = { + ...singleSelectQuestion, + allowCustomAnswer: false, + options: [{ label: "Choice", description: "", value: " choice: opaque " }], + }; + const draft = togglePendingUserInputOptionSelection(question, undefined, " choice: opaque "); + expect(resolvePendingUserInputAnswer(question, { ...draft, customAnswer: "not offered" })).toBe( + " choice: opaque ", + ); + expect(buildPendingUserInputAnswers([question], { [question.id]: draft })).toEqual({ + scope: " choice: opaque ", + }); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 76868ad14..1ab3c3334 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -36,9 +36,8 @@ function normalizeSelectedOptionLabels(value: string[] | undefined): string[] { const normalized: string[] = []; for (const entry of value) { if (typeof entry !== "string") continue; - const trimmed = entry.trim(); - if (trimmed.length > 0) { - normalized.push(trimmed); + if (entry.length > 0) { + normalized.push(entry); } } @@ -49,7 +48,8 @@ export function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, ): string | string[] | null { - const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + const customAnswer = + question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } @@ -140,7 +140,8 @@ export function derivePendingUserInputProgress( const resolvedAnswer = activeQuestion ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) : null; - const customAnswer = activeDraft?.customAnswer ?? ""; + const customAnswer = + activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? ""); const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); const isLastQuestion = questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md new file mode 100644 index 000000000..44f096b21 --- /dev/null +++ b/docs/user/providers-antigravity.md @@ -0,0 +1,31 @@ +# Antigravity + +Add an Antigravity account in **Settings → Providers** on web or **Settings → Agents** on iOS. Choose the server that will run it, +then enable the account. Open **Install and sign in** to install the managed runtime and +sign in with Google. Installation and sign-in happen on the selected server, including +when you connect remotely from a phone. The native iOS app and web app offer the same +setup actions. + +Choose a personal Google account, a business Google account, a Gemini API key, or Agent +Platform in the account editor. Save changes before starting setup. Business accounts +require a Google Cloud project and location; the editor names any missing configuration. +API keys remain sensitive account settings. + +Google sign-in opens only when you choose **Open Google sign-in**. When signing in to a +remote server, Google may redirect your browser to a local address that cannot load. +Copy that complete callback address and paste it into the setup screen to finish the +same sign-in. You can cancel or retry without replacing another device's sign-in flow. + +The model picker shows the models returned by your account. **Refresh** in Agents +refreshes that catalog. Workspace commands and skills follow the selected project or +worktree. Native questions require one of the offered choices; permission prompts show +any warning attached to a persistent approval. + +Sign out from the account's setup screen or send `/logout` in an Antigravity thread. +Signing out stops that account's active sessions. Other provider accounts remain separate. +Removing the managed runtime requires its sessions to be stopped; it does not delete your +account profile or an executable you supplied yourself. + +Antigravity reports subagent launches as a batch. The timeline keeps that batch active +until its parent turn ends, without inventing individual agent conversations or results. +Commands that continue after the response remain visible until they finish. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 95e4d0bbd..b6d09a390 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -43,6 +43,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread | typeof WS_METHODS.subscribeAuthAccess + | typeof WS_METHODS.providerAuthSubscribe + | typeof WS_METHODS.providerInstallSubscribe | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5110849ca..2523853ce 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1068,6 +1068,52 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:scheduled-task:run-now", tag: WS_METHODS.scheduledTasksRunNow, }), + providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:provider:auth-state", + tag: WS_METHODS.providerAuthSubscribe, + idleTtlMs: 0, + }), + startProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-start", + tag: WS_METHODS.providerAuthStart, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), + }, + }), + completeProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-complete", + tag: WS_METHODS.providerAuthComplete, + }), + cancelProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-cancel", + tag: WS_METHODS.providerAuthCancel, + }), + logoutProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-logout", + tag: WS_METHODS.providerAuthLogout, + }), + providerInstallState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:provider:install-state", + tag: WS_METHODS.providerInstallSubscribe, + idleTtlMs: 0, + }), + startProviderInstall: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:install-start", + tag: WS_METHODS.providerInstallStart, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + cancelProviderInstall: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:install-cancel", + tag: WS_METHODS.providerInstallCancel, + }), + removeProviderInstallation: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:install-remove", + tag: WS_METHODS.providerInstallRemove, + }), consumeResetCredit: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:consume-reset-credit", tag: WS_METHODS.providerConsumeResetCredit, diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index abf860816..2c6cd9238 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -17,10 +17,12 @@ export interface ThreadPendingApproval { } export interface ThreadUserInputQuestion { + readonly allowCustomAnswer?: boolean | undefined; readonly id: string; readonly header: string; readonly question: string; readonly options: ReadonlyArray<{ + readonly value?: string | undefined; readonly label: string; readonly description: string; }>; @@ -58,7 +60,10 @@ export function derivePendingThreadRequests( userInputs.push({ requestId: request.id, createdAt: DateTime.formatIso(request.createdAt), - questions: item.questions.map((question) => ({ ...question, multiSelect: false })), + questions: item.questions.map((question) => ({ + ...question, + multiSelect: question.multiSelect ?? false, + })), responseCapability, }); continue; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 34fc5cff8..f1c53e48f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -64,3 +64,5 @@ export * from "./usageLimitSourceId.ts"; export * from "./browserProfile.ts"; export * from "./browserImport.ts"; + +export * from "./providerSetup.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index ea4996851..4d2e45709 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -177,6 +177,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ "gpt-5.6-terra", ]; export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; +/** Keep the official Antigravity session's current model. Never send this ID to ACP. */ +export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { @@ -184,6 +186,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial > = { [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL, + [ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL, [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", [CURSOR_DRIVER_KIND]: "composer-2", [OPENCODE_DRIVER_KIND]: "openai/gpt-5", @@ -253,6 +257,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< // ── Provider display names ──────────────────────────────────────────── export const PROVIDER_DISPLAY_NAMES: Partial> = { + [ProviderDriverKind.make("antigravity")]: "Antigravity", [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 20a7f9c6d..3db6259ca 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -899,11 +899,14 @@ export const OrchestrationV2PlanStep = Schema.Struct({ export type OrchestrationV2PlanStep = typeof OrchestrationV2PlanStep.Type; export const OrchestrationV2UserInputQuestion = Schema.Struct({ + multiSelect: Schema.optional(Schema.Boolean), + allowCustomAnswer: Schema.optional(Schema.Boolean), id: TrimmedNonEmptyString, header: TrimmedNonEmptyString, question: TrimmedNonEmptyString, options: Schema.Array( Schema.Struct({ + value: Schema.optional(TrimmedNonEmptyString), label: TrimmedNonEmptyString, description: TrimmedNonEmptyString, }), diff --git a/packages/contracts/src/providerPolicy.ts b/packages/contracts/src/providerPolicy.ts index 5118911c2..233d72fa3 100644 --- a/packages/contracts/src/providerPolicy.ts +++ b/packages/contracts/src/providerPolicy.ts @@ -53,6 +53,7 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; // An approval prompt whose choices the provider names itself (MCP elicitations // label their own buttons); absent when the standard accept/decline set applies. export const ProviderApprovalOption = Schema.Struct({ + warning: Schema.optional(TrimmedNonEmptyString), decision: ProviderApprovalDecision, label: TrimmedNonEmptyString, }); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 0dc06a5b2..d6a993ff8 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -447,12 +447,14 @@ const RequestResolvedPayload = Schema.Struct({ export type RequestResolvedPayload = typeof RequestResolvedPayload.Type; const UserInputQuestionOption = Schema.Struct({ + value: Schema.optional(TrimmedNonEmptyStringSchema), label: TrimmedNonEmptyStringSchema, description: TrimmedNonEmptyStringSchema, }); export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; export const UserInputQuestion = Schema.Struct({ + allowCustomAnswer: Schema.optional(Schema.Boolean), id: TrimmedNonEmptyStringSchema, header: TrimmedNonEmptyStringSchema, question: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts new file mode 100644 index 000000000..a4489259f --- /dev/null +++ b/packages/contracts/src/providerSetup.ts @@ -0,0 +1,71 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +export const ProviderSetupInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type ProviderSetupInput = typeof ProviderSetupInput.Type; + +const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); + +export const ProviderAuthState = Schema.Struct({ + instanceId: ProviderInstanceId, + phase: Schema.Literals([ + "idle", + "starting", + "waiting", + "verifying", + "succeeded", + "failed", + "cancelled", + ]), + flowId: Schema.NullOr(SetupOperationId), + authorizationUrl: Schema.NullOr(Schema.String), + expiresAt: Schema.NullOr(IsoDateTime), + message: Schema.NullOr(Schema.String), +}); +export type ProviderAuthState = typeof ProviderAuthState.Type; + +export const ProviderAuthCompleteInput = Schema.Struct({ + instanceId: ProviderInstanceId, + flowId: SetupOperationId, + callbackUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), +}); +export type ProviderAuthCompleteInput = typeof ProviderAuthCompleteInput.Type; + +export const ProviderAuthCancelInput = Schema.Struct({ + instanceId: ProviderInstanceId, + flowId: SetupOperationId, +}); +export type ProviderAuthCancelInput = typeof ProviderAuthCancelInput.Type; + +const ByteCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +export const ProviderInstallState = Schema.Struct({ + driver: ProviderDriverKind, + operationId: Schema.NullOr(SetupOperationId), + phase: Schema.Literals([ + "idle", + "downloading", + "extracting", + "verifying", + "succeeded", + "failed", + "cancelled", + ]), + downloadedBytes: ByteCount, + totalBytes: Schema.NullOr(ByteCount), + version: Schema.NullOr(TrimmedNonEmptyString), + installedVersion: Schema.NullOr(TrimmedNonEmptyString), + canRemove: Schema.Boolean, + message: Schema.NullOr(Schema.String), +}); +export type ProviderInstallState = typeof ProviderInstallState.Type; + +export const ProviderInstallCancelInput = Schema.Struct({ + instanceId: ProviderInstanceId, + operationId: SetupOperationId, +}); +export type ProviderInstallCancelInput = typeof ProviderInstallCancelInput.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0df01d997..1c8b51125 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,12 @@ +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ProviderAuthState, + ProviderAuthCompleteInput, + ProviderAuthCancelInput, + ProviderSetupInput, + ProviderInstallState, + ProviderInstallCancelInput, +} from "./providerSetup.ts"; import { AgentSessionScanInput, AgentSessionScanResult, @@ -286,6 +295,15 @@ import { VcsError } from "./vcs.ts"; import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; export const WS_METHODS = { + providerAuthStart: "provider.auth.start", + providerAuthComplete: "provider.auth.complete", + providerAuthCancel: "provider.auth.cancel", + providerAuthLogout: "provider.auth.logout", + providerAuthSubscribe: "provider.auth.subscribe", + providerInstallStart: "provider.install.start", + providerInstallCancel: "provider.install.cancel", + providerInstallSubscribe: "provider.install.subscribe", + providerInstallRemove: "provider.install.remove", providerConsumeResetCredit: "provider.consumeResetCredit", // Project registry methods projectsList: "projects.list", @@ -479,9 +497,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv * refreshes. */ instanceId: Schema.optional(ProviderInstanceId), + cwd: Schema.optional(TrimmedNonEmptyString), + refreshModels: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, - error: EnvironmentAuthorizationError, + error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), }); export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { @@ -572,6 +592,64 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry error: EnvironmentAuthorizationError, }); +const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); + +const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { + payload: ProviderSetupInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { + payload: ProviderAuthCompleteInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { + payload: ProviderAuthCancelInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { + payload: ProviderSetupInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { + payload: ProviderSetupInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, + stream: true, +}); + +const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { + payload: ProviderSetupInput, + success: ProviderInstallState, + error: ProviderSetupRpcError, +}); + +const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { + payload: ProviderInstallCancelInput, + success: ProviderInstallState, + error: ProviderSetupRpcError, +}); + +const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { + payload: ProviderSetupInput, + success: ProviderInstallState, + error: ProviderSetupRpcError, + stream: true, +}); + +const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { + payload: ProviderSetupInput, + success: ProviderInstallState, + error: ProviderSetupRpcError, +}); + export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { payload: ProviderConsumeResetCreditInput, success: ProviderConsumeResetCreditResult, @@ -1404,6 +1482,15 @@ export const WsRpcGroup = RpcGroup.make( WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, WsProviderConsumeResetCreditRpc, + WsProviderAuthStartRpc, + WsProviderAuthCompleteRpc, + WsProviderAuthCancelRpc, + WsProviderAuthLogoutRpc, + WsProviderAuthSubscribeRpc, + WsProviderInstallStartRpc, + WsProviderInstallCancelRpc, + WsProviderInstallSubscribeRpc, + WsProviderInstallRemoveRpc, WsServerRefreshUsageRatesRpc, WsServerSignalProcessRpc, WsHermesSessionsDiscoverRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index f7dfe6a43..7cac074b4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -120,6 +120,14 @@ export type ServerProviderSkill = typeof ServerProviderSkill.Type; * `installed: false` and `enabled: false`; the runtime refuses turn * starts against them with a structured error. */ +export const ServerProviderWorkspaceSnapshot = Schema.Struct({ + cwd: TrimmedNonEmptyString, + checkedAt: IsoDateTime, + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; + export const ServerProviderAvailability = Schema.Literals(["available", "unavailable"]); export type ServerProviderAvailability = typeof ServerProviderAvailability.Type; @@ -181,6 +189,11 @@ export const ServerProvider = Schema.Struct({ /** Reserves context-meter space while a started thread hydrates. */ reportsContextWindow: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), + supportsConversationRollback: Schema.optional(Schema.Boolean), + supportsTextGeneration: Schema.optional(Schema.Boolean), + setup: Schema.optional( + Schema.Struct({ canAuthenticate: Schema.Boolean, canInstall: Schema.Boolean }), + ), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), @@ -197,6 +210,7 @@ export const ServerProvider = Schema.Struct({ // Human-readable reason populated when `availability === "unavailable"`. // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), + workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), models: Schema.Array(ServerProviderModel), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 474b8a748..fff9cc713 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -390,13 +390,20 @@ const makeBinaryPathSetting = (fallback: string) => Schema.withDecodingDefault(Effect.succeed(fallback)), ); -export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; +export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; + +export interface ProviderSettingsFormOption { + readonly value: string; + readonly label: string; +} export interface ProviderSettingsFormAnnotation { readonly control?: ProviderSettingsFormControl | undefined; readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; + /** Choices for a `select` control. The first entry is the default. */ + readonly options?: ReadonlyArray | undefined; } export interface ProviderSettingsFormSchemaAnnotation { @@ -775,6 +782,89 @@ export const OpenClawSettings = makeProviderSettingsSchema( ); export type OpenClawSettings = typeof OpenClawSettings.Type; +/** + * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in + * in the browser. The API key and Agent Platform methods take credentials from + * the instance config and never open a browser. + */ +export const ANTIGRAVITY_AUTH_METHODS = [ + { value: "oauth-personal", label: "Google account" }, + { value: "oauth-business", label: "Gemini Enterprise" }, + { value: "gemini-api-key", label: "Gemini API key" }, + { value: "agent-platform", label: "Agent Platform (Vertex AI)" }, +] as const satisfies ReadonlyArray; +export const AntigravityAuthMethod = Schema.Literals( + ANTIGRAVITY_AUTH_METHODS.map((method) => method.value), +); +export type AntigravityAuthMethod = typeof AntigravityAuthMethod.Type; + +export const AntigravitySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + authMethod: AntigravityAuthMethod.pipe( + Schema.withDecodingDefault(Effect.succeed("oauth-personal" as const)), + Schema.annotateKey({ + title: "Sign-in method", + description: + "Google account uses your Antigravity subscription. Gemini Enterprise needs a GCP project and location. API key and Agent Platform bill the credential you enter.", + providerSettingsForm: { + control: "select", + options: ANTIGRAVITY_AUTH_METHODS, + clearWhenEmpty: "omit", + }, + }), + ), + apiKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API key", + description: + "Gemini API key, or a Vertex AI express key for Agent Platform. Stored in plain text on this environment.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + gcpProject: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "GCP project", + description: + "Required for Gemini Enterprise. Agent Platform uses it when no API key is set.", + providerSettingsForm: { placeholder: "my-project-id", clearWhenEmpty: "omit" }, + }), + ), + gcpLocation: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "GCP location", + description: "Region for Gemini Enterprise or Agent Platform, such as us-central1.", + providerSettingsForm: { placeholder: "us-central1", clearWhenEmpty: "omit" }, + }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Binary path", + description: + "Optional path to the official Antigravity ACP executable. Leave empty for automatic selection.", + providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["authMethod", "apiKey", "gcpProject", "gcpLocation", "binaryPath"] }, +); +export type AntigravitySettings = typeof AntigravitySettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -1192,6 +1282,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -1368,6 +1459,16 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const AntigravitySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + authMethod: Schema.optionalKey(AntigravityAuthMethod), + apiKey: Schema.optionalKey(TrimmedString), + gcpProject: Schema.optionalKey(TrimmedString), + gcpLocation: Schema.optionalKey(TrimmedString), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -1459,6 +1560,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), hermes: Schema.optionalKey(HermesSettingsPatch), + antigravity: Schema.optionalKey(AntigravitySettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index f54f81e2a..1b0b40c07 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -5,6 +5,7 @@ import { RpcClientError } from "effect/unstable/rpc"; import * as AcpSchema from "../_generated/schema.gen.ts"; import * as AcpError from "../errors.ts"; const isError = Schema.is(AcpSchema.Error); +const isAcpError = Schema.is(AcpError.AcpError); export const callRpc =
( method: string, @@ -17,11 +18,13 @@ export const callRpc = ( Effect.catchTags({ RpcClientError: (cause) => Effect.fail( - new AcpError.AcpTransportError({ - operation: "call-rpc", - method, - cause, - }), + cause.reason._tag === "RpcClientDefect" && isAcpError(cause.reason.cause) + ? cause.reason.cause + : new AcpError.AcpTransportError({ + operation: "call-rpc", + method, + cause, + }), ), }), ); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 86570bb87..9579373e9 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,6 +1,5 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Stdio from "effect/Stdio"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -24,6 +23,12 @@ import { import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; export interface AcpClientOptions { + readonly transformStdout?: ( + stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], + ) => AcpProtocol.AcpStdio["stdin"]; + readonly transformSessionUpdate?: ( + notification: AcpSchema.SessionNotification, + ) => AcpSchema.SessionNotification; readonly logIncoming?: boolean; readonly logOutgoing?: boolean; readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; @@ -311,7 +316,7 @@ interface BufferedNotificationHandler { } export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( - stdio: Stdio.Stdio, + stdio: AcpProtocol.AcpStdio, options: AcpClientOptions = {}, terminationError?: Effect.Effect, ): Effect.fn.Return { @@ -414,6 +419,9 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } : {}), ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), + ...(options.transformSessionUpdate + ? { transformSessionUpdate: options.transformSessionUpdate } + : {}), onNotification: dispatchNotification, onExtRequest: dispatchExtRequest, }); @@ -582,14 +590,19 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); -export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer => - Layer.effect(AcpClient, make(stdio, options)); +export const layer = ( + stdio: AcpProtocol.AcpStdio, + options: AcpClientOptions = {}, +): Layer.Layer => Layer.effect(AcpClient, make(stdio, options)); export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, ): Layer.Layer => { - const stdio = makeChildStdio(handle); + const stdio = { + ...makeChildStdio(handle), + stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout, + }; const terminationError = makeTerminationError(handle); return Layer.effect(AcpClient, make(stdio, options, terminationError)); }; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 63fb87924..14ff05051 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -42,8 +43,15 @@ export type AcpIncomingNotification = readonly params: unknown; }; +export interface AcpStdio extends Omit { + readonly stdin: Stream.Stream; +} + export interface AcpPatchedProtocolOptions { - readonly stdio: Stdio.Stdio; + readonly stdio: AcpStdio; + readonly transformSessionUpdate?: ( + notification: AcpSchema.SessionNotification, + ) => AcpSchema.SessionNotification; readonly terminationError?: Effect.Effect; readonly serverRequestMethods: ReadonlySet; readonly logIncoming?: boolean; @@ -124,6 +132,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }); const nextRequestId = yield* Ref.make(1); const terminationHandled = yield* Ref.make(false); + const terminationFailure = yield* Deferred.make(); + const ensureActive = Ref.get(terminationHandled).pipe( + Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)), + ); const extPending = yield* Ref.make(new Map()); const logProtocol = (event: AcpProtocolLogEvent) => { @@ -142,6 +154,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const offerOutgoing = Effect.fn("offerOutgoing")(function* ( message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, ) { + yield* ensureActive; yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -193,6 +206,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (options.testHooks?.onOutgoingWriteAdmitted !== undefined) { yield* options.testHooks.onOutgoingWriteAdmitted(); } + yield* ensureActive; yield* Queue.offer(outgoing, { payload: encoded, ...(acknowledgement === undefined ? {} : { acknowledgement }), @@ -293,7 +307,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }), }).pipe(Effect.asVoid); - const handleTermination = (classify: () => Effect.Effect) => + const handleTermination = (classify: () => Effect.Effect) => Ref.modify(terminationHandled, (handled) => { if (handled) { return [Effect.void, true] as const; @@ -302,9 +316,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.gen(function* () { yield* Queue.offer(disconnects, 0); const error = yield* classify(); - if (!error) { - return; - } + yield* Deferred.fail(terminationFailure, error); yield* failAllExtPending(error); yield* emitClientProtocolError(error); if (options.onTermination) { @@ -365,7 +377,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ({ _tag: "SessionUpdate", method: CLIENT_METHODS.session_update, - params, + params: options.transformSessionUpdate?.(params) ?? params, }) satisfies AcpIncomingNotification, ), Effect.mapError((cause) => @@ -750,6 +762,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }); const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { + yield* ensureActive; const requestId = yield* Ref.modify( nextRequestId, (current) => [current, current + 1] as const, diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index 7ff88a2c7..4c28cf9f2 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -6,6 +6,10 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as AcpAgent from "../../src/agent.ts"; +if (process.env.ACP_MOCK_STDOUT_PREFIX !== undefined) { + process.stdout.write(process.env.ACP_MOCK_STDOUT_PREFIX); +} + if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") { process.stdout.write("{not-json}\n"); process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0")); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e672e0fc..6eedec1f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,9 @@ importers: yaml: specifier: ^2.9.0 version: 2.9.0 + yauzl: + specifier: ^3.4.0 + version: 3.4.0 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 @@ -543,6 +546,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + '@types/yauzl': + specifier: ^3.4.0 + version: 3.4.0 effect-acp: specifier: workspace:* version: link:../../packages/effect-acp @@ -5114,6 +5120,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@3.4.0': + resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==} engines: {node: '>=16.20.0'} @@ -8843,6 +8852,9 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -10687,6 +10699,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yjs@13.6.31: resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -15420,6 +15436,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@3.4.0': + dependencies: + '@types/node': 24.12.4 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': optional: true @@ -19876,6 +19896,8 @@ snapshots: pe-library@0.4.1: {} + pend@1.2.0: {} + pg-cloudflare@1.4.0: optional: true @@ -22087,6 +22109,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yjs@13.6.31: dependencies: lib0: 0.2.117 diff --git a/scripts/generate-swift-contract-fixtures.ts b/scripts/generate-swift-contract-fixtures.ts index 52492efb7..cd7a96219 100644 --- a/scripts/generate-swift-contract-fixtures.ts +++ b/scripts/generate-swift-contract-fixtures.ts @@ -186,7 +186,9 @@ const turnItems: OrchestrationV2TurnItem[] = [ id: "q1", header: "Pick", question: "Which one?", - options: [{ label: "A", description: "First" }], + multiSelect: false, + allowCustomAnswer: false, + options: [{ label: "A", description: "First", value: " choice: opaque " }], }, ], },