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 +9 -3
- package/README.md +9 -3
- package/index.mjs +15 -1
- package/lib/backend.mjs +5 -3
- package/lib/task.mjs +87 -0
- package/native-task/LICENSE +21 -0
- package/native-task/README.md +87 -0
- package/native-task/bridge/gemini_native_bridge/__init__.py +3 -0
- package/native-task/bridge/gemini_native_bridge/__main__.py +235 -0
- package/native-task/bridge/gemini_native_bridge/auth.py +143 -0
- package/native-task/bridge/gemini_native_bridge/config.py +222 -0
- package/native-task/bridge/gemini_native_bridge/contract.py +283 -0
- package/native-task/bridge/gemini_native_bridge/native_auth_keychain.swift +111 -0
- package/native-task/bridge/gemini_native_bridge/oauth.py +154 -0
- package/native-task/bridge/gemini_native_bridge/protocol.py +363 -0
- package/native-task/bridge/gemini_native_bridge/server.py +441 -0
- package/native-task/bridge/gemini_native_bridge/state.py +806 -0
- package/native-task/bridge/gemini_native_bridge/transport.py +531 -0
- package/native-task/index.mjs +76 -0
- package/native-task/lib/runtime.mjs +189 -0
- package/native-task/pyproject.toml +18 -0
- package/native-task/requirements.txt +2 -0
- package/native-task/scripts/bridge.mjs +97 -0
- package/package.json +11 -3
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
|
|
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 '{"
|
|
32
|
+
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
| Option | Default | Description |
|
|
36
36
|
| :--- | :--- | :--- |
|
|
37
|
-
| `allowTools` | `
|
|
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
|
-
|
|
29
|
+
工具调用默认开启(Coding Agent 每个请求都带工具),图片输入默认关闭。需要图片输入时:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
magpie plugin options magpie-gemini-web '{"
|
|
32
|
+
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
| 选项 | 默认值 | 说明 |
|
|
36
36
|
| :--- | :--- | :--- |
|
|
37
|
-
| `allowTools` | `
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,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()
|