magpie-gemini-web 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -26,15 +26,15 @@ From then on, Magpie starts the backend automatically every time it launches. To
26
26
 
27
27
  ## Plugin options
28
28
 
29
- Tool calls and image input are off by default. Turn them on if a coding agent needs tools:
29
+ Tool calls are on by default (coding agents send tools with every request); image input is off. To allow images:
30
30
 
31
31
  ```bash
32
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
32
+ magpie plugin options magpie-gemini-web '{"allowImages":true}'
33
33
  ```
34
34
 
35
35
  | Option | Default | Description |
36
36
  | :--- | :--- | :--- |
37
- | `allowTools` | `false` | Allows tool calls (experimental). When off, requests with tools get a 400 |
37
+ | `allowTools` | `true` | Allows tool calls (experimental; upstream emulates them with prompts). When `false`, requests with tools get a 400 |
38
38
  | `allowImages` | `false` | Allows image input |
39
39
  | `baseURL` | `http://127.0.0.1:8083/v1` | Default backend URL |
40
40
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | Context and output limits reported to clients |
@@ -75,6 +75,12 @@ So with a proxy pool, an account just added by one-click setup has no email yet.
75
75
  - Upstream emulates tool calls with prompts, so they aren't always reliable. Flash models often don't call tools.
76
76
  - Only text models whose names start with `gemini-` are exposed. A `cost` of 0 doesn't reflect your actual Google quota.
77
77
 
78
+ ## Native Task on macOS (experimental)
79
+
80
+ On macOS, sign-in offers one more option, "macOS 原生 Task(实验性)": requests skip the Gemini Web backend and go to a local daemon that runs them in Gemini.app's own Task mode, with tool calls on its native Function Calling channel, as the model `gemini-task-argon`. Windows and Linux don't offer it; keep using one-click setup there.
81
+
82
+ This is an alpha: it supports only the verified Gemini.app build, needs a local setup first (Gemini.app signed in, plus session templates), hasn't passed an end-to-end check against real Argon yet, and shows no usage. See [native-task/README.md](native-task/README.md) for setup, session requirements and limitations.
83
+
78
84
  ## Argon patched build (optional)
79
85
 
80
86
  The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. It also makes the backend's requests look more like a browser's: on a direct connection every request carries the full set of Chrome headers (the official build's direct chat requests say `Go-http-client/2.0`), and refreshing the cookies to keep a session alive no longer fetches the `/app` page right away. Building it requires Go 1.26+:
package/README.md CHANGED
@@ -26,15 +26,15 @@
26
26
 
27
27
  ## 插件选项
28
28
 
29
- 工具调用和图片输入默认关闭,Coding Agent 需要工具时请开启:
29
+ 工具调用默认开启(Coding Agent 每个请求都带工具),图片输入默认关闭。需要图片输入时:
30
30
 
31
31
  ```bash
32
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
32
+ magpie plugin options magpie-gemini-web '{"allowImages":true}'
33
33
  ```
34
34
 
35
35
  | 选项 | 默认值 | 说明 |
36
36
  | :--- | :--- | :--- |
37
- | `allowTools` | `false` | 允许工具调用(实验性)。关闭时带工具的请求会返回 400 |
37
+ | `allowTools` | `true` | 允许工具调用(实验性,由上游用提示词模拟)。设为 `false` 时带工具的请求会返回 400 |
38
38
  | `allowImages` | `false` | 允许图片输入 |
39
39
  | `baseURL` | `http://127.0.0.1:8083/v1` | 默认后端地址 |
40
40
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | 向客户端声明的上下文 / 输出上限 |
@@ -75,6 +75,12 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
75
75
  - 工具调用由上游用提示词模拟,不一定可靠,Flash 系列经常不调用工具。
76
76
  - 只开放 `gemini-` 开头的文本模型;`cost` 显示为 0 不代表 Google 的实际配额。
77
77
 
78
+ ## macOS 原生 Task(实验性)
79
+
80
+ 在 macOS 上登录时会多出一个「macOS 原生 Task(实验性)」选项:请求不走 Gemini Web 后端,而是交给本机常驻服务,由 Gemini.app 自己的 Task 模式执行,工具调用走原生的 Function Calling 通道,模型为 `gemini-task-argon`。Windows 和 Linux 上不会出现这个选项,请继续使用上面的一键配置。
81
+
82
+ 当前为 Alpha:仅支持已验证的 Gemini.app 版本,选这个选项之前需要先在本机完成 setup(需要 Gemini.app 已登录,以及会话模板),尚未完成真实 Argon 的端到端验收,读不到额度。配置步骤、会话要求和限制见 [native-task/README.md](native-task/README.md)。
83
+
78
84
  ## Argon 增强版(可选)
79
85
 
80
86
  官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。它也让后端的请求更像浏览器:直连时每个请求都带齐 Chrome 请求头(官方版直连时,对话请求的 UA 是 `Go-http-client/2.0`),保活刷新 Cookie 后也不再立即重新抓取 `/app` 页面。需要 Go 1.26+:
package/index.mjs CHANGED
@@ -2,6 +2,7 @@ import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHAB
2
2
  import { ensureBackend, localBackend } from './lib/native.mjs';
3
3
  import { parseCookieInput, importCookie } from './lib/setup.mjs';
4
4
  import { usage, poolChoices, poolAccounts, accountEmail } from './lib/usage.mjs';
5
+ import { taskSupported, taskMethod, taskLoader, taskModels, isTaskAuth } from './lib/task.mjs';
5
6
 
6
7
  const ID = 'gemini-web';
7
8
  const GEMINI_URL = 'https://gemini.google.com/app';
@@ -158,9 +159,10 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
158
159
  callback: async () => signInFor(local, chosen)
159
160
  };
160
161
  }
161
- }],
162
+ }, ...(taskSupported() ? [taskMethod] : [])],
162
163
  async loader(getAuth) {
163
164
  const auth = await getAuth();
165
+ if (isTaskAuth(auth)) return taskLoader(auth);
164
166
  if (auth?.type !== 'api') return {};
165
167
  const c = credentials(auth, opts);
166
168
  return {
@@ -176,6 +178,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
176
178
  async usage(getAuth) {
177
179
  const auth = await getAuth();
178
180
  if (auth?.type !== 'api') return { error: 'Gemini Web 还没有登录', windows: [], signIn: 'kept' };
181
+ if (isTaskAuth(auth)) return { error: '原生 Task 登录读不到额度', windows: [], signIn: 'kept' };
179
182
  let c;
180
183
  try {
181
184
  c = credentials(auth, opts);
@@ -193,6 +196,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
193
196
  id: ID,
194
197
  async models(provider, { auth } = {}) {
195
198
  if (auth?.type !== 'api') return provider.models;
199
+ if (isTaskAuth(auth)) return taskModels(auth, ID);
196
200
  const c = credentials(auth, opts);
197
201
  try {
198
202
  return await listModels(c, opts);
@@ -202,6 +206,16 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
202
206
  return listModels(c, opts);
203
207
  }
204
208
  }
209
+ },
210
+ // The Task bridge keeps a conversation's state by its session, so each request carries the
211
+ // host's session id. The Web backend's forward() passes on only the headers it needs, so
212
+ // this doesn't reach it.
213
+ async 'chat.headers'(input, output) {
214
+ if (input?.model?.providerID !== ID && input?.provider?.info?.id !== ID) return;
215
+ if (typeof input.sessionID === 'string' && input.sessionID) {
216
+ output.headers ??= {};
217
+ output.headers['X-Gemini-Session'] = input.sessionID;
218
+ }
205
219
  }
206
220
  };
207
221
  };
package/lib/backend.mjs CHANGED
@@ -115,7 +115,9 @@ export function settings(options = {}) {
115
115
  baseURL = normalizeBaseURL(options.baseURL);
116
116
  }
117
117
 
118
- let allowTools = false;
118
+ // On by default: coding agents send tools with every request and don't read toolcall,
119
+ // so turning this off only means every one of their requests gets a 400.
120
+ let allowTools = true;
119
121
  if (options.allowTools !== undefined) {
120
122
  if (typeof options.allowTools !== 'boolean') {
121
123
  throw new Error('Invalid option allowTools: must be a boolean');
@@ -440,7 +442,7 @@ export async function forward(input, init, getAuth, opts) {
440
442
  const hasTools = (Array.isArray(body.tools) && body.tools.length > 0) ||
441
443
  (Array.isArray(body.functions) && body.functions.length > 0);
442
444
  if (!opts.allowTools && hasTools) {
443
- return makeOpenAIError(400, 'Experimental tools are disabled; set allowTools: true');
445
+ return makeOpenAIError(400, `Tool calls are turned off for magpie-gemini-web. Turn them on with: magpie plugin options magpie-gemini-web '{"allowTools":true}'`);
444
446
  }
445
447
 
446
448
  if (body.messages !== undefined) {
@@ -462,7 +464,7 @@ export async function forward(input, init, getAuth, opts) {
462
464
  }
463
465
  } else if (part.type === 'image_url') {
464
466
  if (!opts.allowImages) {
465
- return makeOpenAIError(400, 'Image input is disabled; set allowImages: true');
467
+ return makeOpenAIError(400, `Image input is turned off for magpie-gemini-web. Turn it on with: magpie plugin options magpie-gemini-web '{"allowImages":true}'`);
466
468
  }
467
469
  if (!part.image_url || typeof part.image_url.url !== 'string' || !part.image_url.url.trim()) {
468
470
  return makeOpenAIError(400, 'Image content must contain an image_url URL');
package/lib/task.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createForwarder, ensureService, getDefaultDataDir } from '../native-task/lib/runtime.mjs';
5
+
6
+ // The experimental Task route: on macOS, a sign-in can go to the native-task bridge (see
7
+ // native-task/), which runs Gemini.app's own Task mode, instead of the Gemini Web backend.
8
+ // The sign-in says which with metadata.mode.
9
+
10
+ export const TASK_MODE = 'task';
11
+ export const TASK_MODEL = 'gemini-task-argon';
12
+
13
+ const BRIDGE_CLI = fileURLToPath(new URL('../native-task/scripts/bridge.mjs', import.meta.url));
14
+
15
+ // taskSupported is whether this computer can run the bridge: it drives Gemini.app for macOS.
16
+ export function taskSupported(platform = process.platform) {
17
+ return platform === 'darwin';
18
+ }
19
+
20
+ export function isTaskAuth(auth) {
21
+ return auth?.type === 'api' && auth.metadata?.mode === TASK_MODE;
22
+ }
23
+
24
+ // startTask starts the bridge set up in dataDir. The bridge's setup needs files only a capture
25
+ // gives, so the plugin can't run it: without one, the error says how.
26
+ async function startTask(dataDir = getDefaultDataDir()) {
27
+ if (!fs.existsSync(path.join(dataDir, 'config.json'))) {
28
+ throw new Error(`还没有配置本机 Task 服务(${dataDir})。先在终端运行 node "${BRIDGE_CLI}" setup,需要的文件见 native-task/README.md`);
29
+ }
30
+ return ensureService({ dataDir });
31
+ }
32
+
33
+ // taskMethod is the Magpie sign-in method for the bridge.
34
+ export const taskMethod = {
35
+ type: 'oauth',
36
+ label: 'macOS 原生 Task(实验性,需先完成本机 setup)',
37
+ async authorize() {
38
+ const service = await startTask();
39
+ return {
40
+ url: '',
41
+ method: 'auto',
42
+ instructions: '',
43
+ callback: async () => ({
44
+ type: 'success',
45
+ key: service.apiKey,
46
+ accountId: `native:${service.instance_id}`,
47
+ metadata: { mode: TASK_MODE, baseURL: `${service.baseURL}/v1`, dataDir: service.dataDir }
48
+ })
49
+ };
50
+ }
51
+ };
52
+
53
+ // taskLoader is the auth loader for a Task sign-in: requests go to the bridge, starting it when
54
+ // it isn't running.
55
+ export async function taskLoader(auth) {
56
+ const service = await startTask(auth.metadata?.dataDir);
57
+ if (auth.key !== service.apiKey) {
58
+ throw Object.assign(new Error('本机 Task 服务的密钥变了,请重新登录'), { signIn: 'expired' });
59
+ }
60
+ return { apiKey: auth.key, baseURL: `${service.baseURL}/v1`, fetch: createForwarder(service) };
61
+ }
62
+
63
+ // taskModels is the one model the bridge serves, in the shape listModels gives.
64
+ export function taskModels(auth, providerID) {
65
+ return {
66
+ [TASK_MODEL]: {
67
+ id: TASK_MODEL,
68
+ providerID,
69
+ name: 'Argon Task',
70
+ api: { id: TASK_MODEL, url: auth.metadata?.baseURL, npm: '@ai-sdk/openai-compatible' },
71
+ limit: { context: 65536, output: 8192 },
72
+ capabilities: {
73
+ temperature: false,
74
+ reasoning: false,
75
+ attachment: false,
76
+ toolcall: true,
77
+ input: { text: true, image: false },
78
+ output: { text: true }
79
+ },
80
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
81
+ options: {},
82
+ headers: {},
83
+ variants: {},
84
+ status: 'active'
85
+ }
86
+ };
87
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 magpie-gemini-web contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,87 @@
1
+ # Gemini Native Task 桥接与 Magpie 扩展
2
+
3
+ 本目录包含针对 macOS 原生 Gemini App 任务模式(Task Mode / Argon)的本地常驻守护进程与 Magpie 扩展插件。此实现为 Alpha 预览版本,提供文本对话及串行 OpenAI Function Calling 工具调用能力。
4
+
5
+ ## 前置要求与环境限制
6
+
7
+ 1. **操作系统**:仅支持 macOS(arm64),需已安装 `/Applications/Gemini.app`(版本 `1.128.14.1166`,SHA-256 `8cd89234b23182234e8e211e1449ae44e0a831b2185e98f4d1371a83c66f8964`)。
8
+ 2. **已登录账户**:需在 Gemini.app 中已登录可用账户,桥接器通过只读 Keychain API 读取原生凭据并执行 OAuth 续期。
9
+ 3. **App 必须退出**:在运行原生 Task 任务时,本机运行的 Gemini.app 进程必须处于退出状态(`pgrep -x Gemini` 必须为空),避免长连接与设备状态冲突。
10
+ 4. **抓取模板**:首次配置需要有效的会话头与消息模板(`task-headers.json`、`tool-headers.json`、`task-template.bin`、`turn-template.bin`)。`x-server-token` 与设备凭据暂未实现自动续期,需由已抓取模板手工提供。
11
+
12
+ ## 架构与行为特性
13
+
14
+ - **模型标识**:网关模型 ID 为 `gemini-web/gemini-task-argon`(本地服务模型 ID 为 `gemini-task-argon`)。
15
+ - **工具调用**:支持 OpenAI Function Calling 格式的串行工具调用(单轮至多 1 个工具调用),由客户端执行并回传结果。工具声明映射至 `agent_bridge` 服务。单个 Task 上限 32 次工具调用(非整个会话全局上限)。
16
+ - **并发与超时**:全局同一时刻仅允许 1 个活跃 Task。任务全局超时为 240 秒,单次工具等待超时为 50 秒。无断线自动重连或重放机制。
17
+ - **会话与持久化**:调用 Magpie 网关时必须提供稳定的 `X-Session-ID`,由插件的 `chat.headers` 将它转换为本机服务使用的 `X-Gemini-Session`。直接访问本机服务时使用 `X-Gemini-Session`。仅在网关传 `X-Magpie-Session` 不足以保证重试和续轮使用同一 ID。会话状态持久化于私有目录(`~/.local/share/gemini-native-task/sessions/*.json`,权限 `0600`)。
18
+ - **容量与历史限制**:单个会话最多缓存 256 次请求记录,本地最多存储 128 个会话。不支持透明的历史压缩、修改或分支分叉(修改历史需开启新会话)。
19
+ - **中断恢复与取消**:守护进程重启时,未完成的会话将标记为 `interrupted`,不会继续重放或悬挂。本地取消仅中止本地工作线程与流,服务端不保证原生任务取消。
20
+ - **协议与参数说明**:
21
+ - 允许传入 `system` / `developer` 角色消息,通过精确的前置提示词进行转译兼容,但服务端无原生系统角色通道。
22
+ - `max_tokens` / `max_completion_tokens` 仅作为兼容性提示词建议注入,服务端无硬性切断。
23
+ - 不提供 Token 用量统计(Token Usage)。
24
+ - 流式响应(SSE)按阶段(工具调用 / 最终答案)缓冲输出,非逐字 Token 流。
25
+ - 不支持采样参数(temperature / top_p)或结构化输出(JSON Schema response format)。
26
+ - 原生对话记录保留在 Google 服务端。
27
+
28
+ ## 验证状态
29
+
30
+ 2026-10-15:已执行通过 41 项 Python 测试和 12 项 Node 测试;真实 Magpie `0.1.1244` 的隔离环境完成了插件安装、本地连接、模型发现及 8 个 HTTP 请求的工具往返测试。该网关测试使用模拟原生上游,包含两次串行工具、最终答案重试、错误会话/调用 ID 拒绝、旧工具阶段拒绝和下一轮用户消息。
31
+
32
+ 当前实现已使用现有登录完成 OAuth 续期及保存轮次读取,但真实 Task 请求被 Google 返回的 `Remy quota exhausted`(gRPC 8)拒绝,尚未完成本版真实 Argon 工具端到端验收。服务将该错误映射为 `native_quota_exhausted`,不会自动重发 Task。
33
+
34
+ ## Magpie 插件安装与登录
35
+
36
+ Task 已并入 `magpie-gemini-web` 插件,不需要单独安装。先完成下面的本地服务 Setup,再登录并选择「macOS 原生 Task(实验性)」:
37
+
38
+ ```bash
39
+ magpie plugin login gemini-web
40
+ ```
41
+
42
+ 登录后模型为 `gemini-web/gemini-task-argon`。没有完成 Setup 时,这个选项会报错并给出 setup 命令。
43
+
44
+ ## 本地桥接守护进程 CLI
45
+
46
+ 默认数据目录为 `~/.local/share/gemini-native-task`,默认监听端口为 `8084`。
47
+
48
+ ### 1. 引导配置(Setup)
49
+
50
+ ```bash
51
+ node native-task/scripts/bridge.mjs setup \
52
+ --data-dir ~/.local/share/gemini-native-task \
53
+ --auth-file /path/to/encrypted_auth_file \
54
+ --task-headers /path/to/task-headers.json \
55
+ --tool-headers /path/to/tool-headers.json \
56
+ --task-template /path/to/task-template.bin \
57
+ --turn-template /path/to/turn-template.bin
58
+ ```
59
+
60
+ ### 2. 启动服务(Start)
61
+
62
+ ```bash
63
+ node native-task/scripts/bridge.mjs start --data-dir ~/.local/share/gemini-native-task
64
+ ```
65
+
66
+ ### 3. 查看状态(Status)
67
+
68
+ ```bash
69
+ node native-task/scripts/bridge.mjs status --data-dir ~/.local/share/gemini-native-task
70
+ ```
71
+
72
+ ### 4. 停止服务(Stop)
73
+
74
+ ```bash
75
+ node native-task/scripts/bridge.mjs stop --data-dir ~/.local/share/gemini-native-task
76
+ ```
77
+
78
+ ## 离线开发与回归测试命令
79
+
80
+ 在仓库根目录下执行(Python 3.10+、Node 22+):
81
+
82
+ ```bash
83
+ python3 -m venv native-task/.venv
84
+ native-task/.venv/bin/pip install -e ./native-task
85
+ native-task/.venv/bin/python -m unittest discover -s native-task/tests -v
86
+ node --test native-task/test/*.test.mjs
87
+ ```
@@ -0,0 +1,3 @@
1
+ """Gemini Native Task Bridge package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,235 @@
1
+ """CLI entrypoint for bootstrap and daemon serving."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ import signal
9
+ import sys
10
+ import threading
11
+ from pathlib import Path
12
+
13
+ from .config import (
14
+ RuntimeConfig,
15
+ acquire_bootstrap_lock,
16
+ acquire_daemon_lock,
17
+ atomic_write_file,
18
+ ensure_data_dir,
19
+ get_default_data_dir,
20
+ load_config,
21
+ require_macos,
22
+ save_config,
23
+ validate_config_values,
24
+ )
25
+ from .contract import BridgeError
26
+ from .oauth import extract_client, read_credential, refresh_access
27
+ from .protocol import (
28
+ TASK_RPC,
29
+ TOOLS_RPC,
30
+ TURN_RPC,
31
+ fields,
32
+ messages,
33
+ one,
34
+ turn_request,
35
+ validate_headers,
36
+ validate_task_template,
37
+ )
38
+ from .server import BridgeServer
39
+ from .state import Manager
40
+ from .transport import unary
41
+
42
+
43
+ def sanitize_headers(raw_headers: list) -> list[list[str]]:
44
+ out = []
45
+ for item in raw_headers:
46
+ if isinstance(item, list) and len(item) == 2:
47
+ k, v = str(item[0]), str(item[1])
48
+ if k.lower() not in ("authorization", "grpc-encoding", "grpc-accept-encoding"):
49
+ out.append([k, v])
50
+ out.append(["grpc-accept-encoding", "identity"])
51
+ return out
52
+
53
+
54
+ def cmd_bootstrap(args: argparse.Namespace):
55
+ require_macos()
56
+ data_dir = Path(args.data_dir).expanduser().resolve()
57
+ ensure_data_dir(data_dir, create=True)
58
+
59
+ # Acquire bootstrap lock before checking or creating configuration
60
+ boot_lock = acquire_bootstrap_lock(data_dir)
61
+ try:
62
+ cfg_file = data_dir / "config.json"
63
+ if cfg_file.exists():
64
+ raise BridgeError("config_exists", f"Configuration already exists at {data_dir}. Preserve config or use a new directory.", 409)
65
+
66
+ # Validate numeric options before live refresh
67
+ validate_config_values(
68
+ api_key="a" * 32,
69
+ instance_id="init",
70
+ subject_sha256="0" * 64,
71
+ port=args.port,
72
+ task_timeout=args.task_timeout,
73
+ tool_timeout=args.tool_timeout,
74
+ max_tool_calls=args.max_tool_calls,
75
+ )
76
+
77
+ source_dir = data_dir / "source"
78
+ source_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
79
+
80
+ auth_file = Path(args.auth_file).expanduser().resolve()
81
+ if not auth_file.exists():
82
+ raise BridgeError("file_not_found", f"Auth file {auth_file} does not exist.", 409)
83
+
84
+ binary = Path(args.binary).expanduser().resolve()
85
+ if not binary.exists():
86
+ raise BridgeError("file_not_found", f"App binary {binary} does not exist.", 409)
87
+
88
+ task_h_path = Path(args.task_headers).expanduser().resolve()
89
+ task_raw = json.loads(task_h_path.read_text(encoding="utf-8"))
90
+ task_entries = task_raw.get("out_headers") if isinstance(task_raw, dict) else task_raw
91
+ task_clean = sanitize_headers(task_entries)
92
+ validate_headers(task_clean, TASK_RPC)
93
+ atomic_write_file(source_dir / "task-headers.json", json.dumps({"out_headers": task_clean}, indent=2).encode(), mode=0o600)
94
+
95
+ tool_h_path = Path(args.tool_headers).expanduser().resolve()
96
+ tool_raw = json.loads(tool_h_path.read_text(encoding="utf-8"))
97
+ tool_entries = tool_raw.get("out_headers") if isinstance(tool_raw, dict) else tool_raw
98
+ tool_clean = sanitize_headers(tool_entries)
99
+ validate_headers(tool_clean, TOOLS_RPC)
100
+ atomic_write_file(source_dir / "tool-headers.json", json.dumps({"out_headers": tool_clean}, indent=2).encode(), mode=0o600)
101
+
102
+ task_tmpl_path = Path(args.task_template).expanduser().resolve()
103
+ task_tmpl_bytes = task_tmpl_path.read_bytes()
104
+ validate_task_template(task_tmpl_bytes)
105
+ atomic_write_file(source_dir / "task-template.bin", task_tmpl_bytes, mode=0o600)
106
+
107
+ turn_tmpl_path = Path(args.turn_template).expanduser().resolve()
108
+ turn_tmpl_bytes = turn_tmpl_path.read_bytes()
109
+ initial_conv = one(messages(turn_tmpl_bytes)[0], 1)
110
+ turn_req = turn_request(turn_tmpl_bytes, initial_conv)
111
+ atomic_write_file(source_dir / "turn-template.bin", turn_tmpl_bytes, mode=0o600)
112
+
113
+ cred = read_credential(auth_file, data_dir)
114
+ subject_hash = hashlib.sha256(cred.subject.encode()).hexdigest()
115
+ account_label = auth_file.parent.name
116
+ client = extract_client(binary)
117
+ token_data = refresh_access(client, cred)
118
+
119
+ atomic_write_file(data_dir / "token-cache.json", json.dumps(token_data, indent=2).encode(), mode=0o600)
120
+
121
+ pseudo = []
122
+ regular = []
123
+ for k, v in task_clean:
124
+ k_lower = k.lower()
125
+ if k == ":path":
126
+ pseudo.append([":path", TURN_RPC])
127
+ elif k.startswith(":"):
128
+ pseudo.append([k, v])
129
+ elif k_lower in ("grpc-encoding", "grpc-accept-encoding"):
130
+ continue
131
+ elif k_lower == "grpc-timeout":
132
+ regular.append([k, f"{args.task_timeout * 1000}m"])
133
+ else:
134
+ regular.append([k, v])
135
+ regular.insert(0, ["authorization", f"Bearer {token_data['access_token']}"])
136
+ regular.append(["grpc-accept-encoding", "identity"])
137
+ verify_headers = pseudo + regular
138
+ validate_headers(verify_headers, TURN_RPC)
139
+
140
+ _, resp_data = unary(verify_headers, turn_req, rpc_path=TURN_RPC, timeout=15.0)
141
+ matched = False
142
+ for msg in messages(resp_data):
143
+ for n, w, turn, _ in fields(msg):
144
+ if n == 1 and w == 2:
145
+ turn_ids = one(turn, 1)
146
+ if one(turn_ids, 1) == initial_conv:
147
+ matched = True
148
+ break
149
+ if not matched:
150
+ raise BridgeError("turn_verification_failed", "Turn verification failed for configured account.", 409)
151
+
152
+ api_key = secrets.token_urlsafe(32)
153
+ instance_id = secrets.token_hex(16)
154
+ config = RuntimeConfig(
155
+ data_dir=data_dir,
156
+ api_key=api_key,
157
+ instance_id=instance_id,
158
+ auth_file=auth_file,
159
+ binary=binary,
160
+ subject_sha256=subject_hash,
161
+ account_label=account_label,
162
+ port=args.port,
163
+ task_timeout=args.task_timeout,
164
+ tool_timeout=args.tool_timeout,
165
+ max_tool_calls=args.max_tool_calls,
166
+ )
167
+ save_config(config)
168
+
169
+ print(json.dumps({
170
+ "configured": True,
171
+ "port": config.port,
172
+ "account_label": config.account_label,
173
+ "instance_id": config.instance_id,
174
+ }))
175
+ finally:
176
+ boot_lock.close()
177
+
178
+
179
+ def cmd_serve(args: argparse.Namespace):
180
+ data_dir = Path(args.data_dir).expanduser().resolve()
181
+ config = load_config(data_dir)
182
+ lock = acquire_daemon_lock(data_dir)
183
+ manager = Manager(config)
184
+ server = BridgeServer(config, manager)
185
+
186
+ def trigger_shutdown(*_):
187
+ threading.Thread(target=server.stop, daemon=True).start()
188
+
189
+ signal.signal(signal.SIGINT, trigger_shutdown)
190
+ signal.signal(signal.SIGTERM, trigger_shutdown)
191
+
192
+ try:
193
+ server.serve_forever()
194
+ finally:
195
+ manager.close()
196
+ server.server_close()
197
+ lock.close()
198
+
199
+
200
+ def main():
201
+ parser = argparse.ArgumentParser(description="Gemini Native Task Bridge")
202
+ subparsers = parser.add_subparsers(dest="command", required=True)
203
+
204
+ p_boot = subparsers.add_parser("bootstrap", help="Bootstrap configuration")
205
+ p_boot.add_argument("--data-dir", required=True)
206
+ p_boot.add_argument("--auth-file", required=True)
207
+ p_boot.add_argument("--task-headers", required=True)
208
+ p_boot.add_argument("--tool-headers", required=True)
209
+ p_boot.add_argument("--task-template", required=True)
210
+ p_boot.add_argument("--turn-template", required=True)
211
+ p_boot.add_argument("--binary", default="/Applications/Gemini.app/Contents/MacOS/Gemini")
212
+ p_boot.add_argument("--port", type=int, default=8084)
213
+ p_boot.add_argument("--task-timeout", type=int, default=240)
214
+ p_boot.add_argument("--tool-timeout", type=int, default=50)
215
+ p_boot.add_argument("--max-tool-calls", type=int, default=32)
216
+
217
+ p_serve = subparsers.add_parser("serve", help="Run HTTP server")
218
+ p_serve.add_argument("--data-dir", default=str(get_default_data_dir()))
219
+
220
+ args = parser.parse_args()
221
+ try:
222
+ if args.command == "bootstrap":
223
+ cmd_bootstrap(args)
224
+ elif args.command == "serve":
225
+ cmd_serve(args)
226
+ except BridgeError as be:
227
+ sys.stderr.write(f"Error ({be.code}): {be.message}\n")
228
+ sys.exit(1)
229
+ except Exception:
230
+ sys.stderr.write("Error (internal_failure): Unexpected error during command execution.\n")
231
+ sys.exit(1)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()