magpie-gemini-web 0.3.2 → 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.
@@ -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()
@@ -0,0 +1,143 @@
1
+ """Authentication management, credential renewal, and header injection."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from .config import RuntimeConfig, atomic_write_file
13
+ from .contract import BridgeError
14
+ from .oauth import extract_client, read_credential, refresh_access
15
+ from .protocol import TASK_RPC, TOOLS_RPC, validate_headers
16
+
17
+
18
+ class AuthManager:
19
+ def __init__(self, config: RuntimeConfig):
20
+ self.config = config
21
+ self._lock = threading.Lock()
22
+ self.source_dir = self.config.data_dir / "source"
23
+ self.cache_file = self.config.data_dir / "token-cache.json"
24
+
25
+ def _read_cache(self) -> dict | None:
26
+ if not self.cache_file.exists():
27
+ return None
28
+ try:
29
+ data = json.loads(self.cache_file.read_text(encoding="utf-8"))
30
+ except Exception as e:
31
+ raise BridgeError("corrupt_cache", "Token cache cannot be decoded.", 409) from e
32
+
33
+ if not isinstance(data, dict):
34
+ raise BridgeError("corrupt_cache", "Token cache is corrupt.", 409)
35
+
36
+ # Well-formed cache with wrong or missing identity is a terminal account_mismatch before read_credential
37
+ if data.get("subject_sha256") != self.config.subject_sha256:
38
+ raise BridgeError("account_mismatch", "Token cache account binding is missing or mismatched.", 409)
39
+
40
+ expires_at_raw = data.get("expires_at")
41
+ if not isinstance(expires_at_raw, (int, float)) or isinstance(expires_at_raw, bool) or not math.isfinite(expires_at_raw):
42
+ return None
43
+
44
+ expires_at = float(expires_at_raw)
45
+ if expires_at <= time.time() + 300:
46
+ return None
47
+
48
+ if not isinstance(data.get("access_token"), str) or not data["access_token"]:
49
+ return None
50
+
51
+ return data
52
+
53
+ def _read_prior_rotation(self) -> str | None:
54
+ if not self.cache_file.exists():
55
+ return None
56
+ try:
57
+ data = json.loads(self.cache_file.read_text(encoding="utf-8"))
58
+ if not isinstance(data, dict):
59
+ return None
60
+ if data.get("subject_sha256") != self.config.subject_sha256:
61
+ raise BridgeError("account_mismatch", "Token cache account binding is missing or mismatched.", 409)
62
+ rot = data.get("rotated_refresh_token")
63
+ if isinstance(rot, str) and rot.startswith("1//"):
64
+ return rot
65
+ except BridgeError:
66
+ raise
67
+ except Exception:
68
+ return None
69
+ return None
70
+
71
+ def get_token_data(self) -> dict:
72
+ with self._lock:
73
+ cached = self._read_cache()
74
+ if cached is not None:
75
+ return cached
76
+
77
+ # Only load prior rotation from a validated cache of this configured account
78
+ rotated_refresh = self._read_prior_rotation()
79
+
80
+ cred = read_credential(self.config.auth_file, self.config.data_dir)
81
+ subject_hash = hashlib.sha256(cred.subject.encode()).hexdigest()
82
+ if subject_hash != self.config.subject_sha256:
83
+ raise BridgeError("account_mismatch", "The local login account does not match the configured subject.", 409)
84
+
85
+ client = extract_client(self.config.binary)
86
+ token_data = refresh_access(client, cred, rotated_refresh)
87
+ if token_data.get("subject_sha256") != self.config.subject_sha256:
88
+ raise BridgeError("account_mismatch", "Renewed token subject does not match configured account.", 409)
89
+
90
+ atomic_write_file(self.cache_file, json.dumps(token_data, indent=2).encode(), mode=0o600)
91
+ return token_data
92
+
93
+ def _load_base_headers(self, filename: str) -> list[list[str]]:
94
+ path = self.source_dir / filename
95
+ if not path.exists():
96
+ raise BridgeError("missing_source_headers", f"Missing template headers file: {path}")
97
+ try:
98
+ content = json.loads(path.read_text(encoding="utf-8"))
99
+ raw = content.get("out_headers") if isinstance(content, dict) else content
100
+ if not isinstance(raw, list):
101
+ raise ValueError()
102
+ return [[str(k), str(v)] for k, v in raw]
103
+ except Exception as e:
104
+ raise BridgeError("corrupt_source_headers", "Header template cannot be decoded.") from e
105
+
106
+ def _inject_headers(self, base_headers: list[list[str]], access_token: str, expected_path: str) -> list[list[str]]:
107
+ pseudo = []
108
+ regular = []
109
+ grpc_timeout_set = False
110
+
111
+ for k, v in base_headers:
112
+ k_lower = k.lower()
113
+ if k_lower in ("authorization", "grpc-encoding", "grpc-accept-encoding"):
114
+ continue
115
+ if k_lower == "grpc-timeout":
116
+ regular.append(["grpc-timeout", f"{self.config.task_timeout * 1000}m"])
117
+ grpc_timeout_set = True
118
+ continue
119
+ if k.startswith(":"):
120
+ pseudo.append([k, v])
121
+ else:
122
+ regular.append([k, v])
123
+
124
+ if not grpc_timeout_set:
125
+ regular.append(["grpc-timeout", f"{self.config.task_timeout * 1000}m"])
126
+
127
+ regular.insert(0, ["authorization", f"Bearer {access_token}"])
128
+ regular.append(["grpc-accept-encoding", "identity"])
129
+
130
+ combined = pseudo + regular
131
+ validate_headers(combined, expected_path)
132
+ return combined
133
+
134
+ def headers(self) -> tuple[list[list[str]], list[list[str]]]:
135
+ token_data = self.get_token_data()
136
+ access_token = token_data["access_token"]
137
+
138
+ task_base = self._load_base_headers("task-headers.json")
139
+ tool_base = self._load_base_headers("tool-headers.json")
140
+
141
+ task_h = self._inject_headers(task_base, access_token, TASK_RPC)
142
+ tool_h = self._inject_headers(tool_base, access_token, TOOLS_RPC)
143
+ return copy.deepcopy(task_h), copy.deepcopy(tool_h)