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.
- package/README.en.md +29 -5
- package/README.md +33 -5
- package/index.mjs +23 -6
- package/lib/backend.mjs +9 -5
- package/lib/task.mjs +87 -0
- package/lib/usage.mjs +118 -16
- 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/patches/argon.patch +231 -51
- package/scripts/backend-argon.mjs +1 -1
|
@@ -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()
|
|
@@ -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)
|