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.
@@ -0,0 +1,189 @@
1
+ import child_process from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const BRIDGE_ROOT = fileURLToPath(new URL('../bridge', import.meta.url));
8
+ const startupMap = new Map();
9
+ const MAX_BODY = 1024 * 1024;
10
+
11
+ export function getDefaultDataDir() {
12
+ return path.resolve(process.env.GEMINI_NATIVE_TASK_DATA_DIR || path.join(os.homedir(), '.local/share/gemini-native-task'));
13
+ }
14
+
15
+ export function ensurePrivateDataDir(dataDir, { create = false } = {}) {
16
+ if (typeof dataDir !== 'string' || !dataDir) throw new Error('Invalid data directory.');
17
+ const dir = path.resolve(dataDir);
18
+ if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
19
+ const st = fs.statSync(dir);
20
+ if (!st.isDirectory() || (typeof process.getuid === 'function' && st.uid !== process.getuid()) || (st.mode & 0o077)) {
21
+ throw new Error('Data directory must be private and owned by the current user.');
22
+ }
23
+ return fs.realpathSync(dir);
24
+ }
25
+
26
+ export function loadLocalConfig(dataDir) {
27
+ const dir = ensurePrivateDataDir(dataDir);
28
+ const cfgPath = path.join(dir, 'config.json');
29
+ const st = fs.lstatSync(cfgPath);
30
+ if (!st.isFile() || st.size > 16384 || (st.mode & 0o077) ||
31
+ (typeof process.getuid === 'function' && st.uid !== process.getuid())) {
32
+ throw new Error('Configuration must be a private, owned, regular file.');
33
+ }
34
+ const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
35
+ if (raw?.version !== 1 || typeof raw.api_key !== 'string' || raw.api_key.length < 16 ||
36
+ typeof raw.instance_id !== 'string' || !raw.instance_id || raw.instance_id.length > 128 ||
37
+ !Number.isInteger(raw.port) || raw.port < 1024 || raw.port > 65535 ||
38
+ typeof raw.subject_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(raw.subject_sha256)) {
39
+ throw new Error('Invalid local bridge configuration.');
40
+ }
41
+ return { ...raw, dataDir: dir };
42
+ }
43
+
44
+ // Bound consumption, including streams without Content-Length. Cancel upstream
45
+ // immediately on overflow or abort rather than buffering the rest of the body.
46
+ export async function boundedBytes(stream, limit, signal) {
47
+ signal?.throwIfAborted();
48
+ if (!stream) return Buffer.alloc(0);
49
+ const reader = stream.getReader();
50
+ const parts = [];
51
+ let size = 0;
52
+ const abort = () => { void reader.cancel().catch(() => {}); };
53
+ signal?.addEventListener('abort', abort, { once: true });
54
+ try {
55
+ while (true) {
56
+ signal?.throwIfAborted();
57
+ const { value, done } = await reader.read();
58
+ signal?.throwIfAborted();
59
+ if (done) return Buffer.concat(parts, size);
60
+ size += value.byteLength;
61
+ if (size > limit) throw Object.assign(new Error('Body size limit exceeded.'), { code: 'body_too_large' });
62
+ parts.push(Buffer.from(value));
63
+ }
64
+ } catch (error) {
65
+ void reader.cancel().catch(() => {});
66
+ throw error;
67
+ } finally {
68
+ signal?.removeEventListener('abort', abort);
69
+ reader.releaseLock();
70
+ }
71
+ }
72
+
73
+ export async function probeHealth(baseURL, apiKey, instanceId, timeoutMs = 2000) {
74
+ const controller = new AbortController();
75
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
76
+ try {
77
+ const res = await fetch(`${baseURL}/health`, {
78
+ headers: { Authorization: `Bearer ${apiKey}` }, signal: controller.signal, redirect: 'error',
79
+ });
80
+ if (res.status !== 200) {
81
+ void res.body?.cancel();
82
+ throw new Error(`Health check returned unexpected status ${res.status}`);
83
+ }
84
+ let body;
85
+ const bytes = await boundedBytes(res.body, 16384, controller.signal);
86
+ try { body = JSON.parse(bytes.toString('utf8')); }
87
+ catch { throw new Error('Health check response is not valid JSON.'); }
88
+ if (body?.service !== 'gemini-native-task') throw new Error('Port is in use by an unexpected service.');
89
+ if (body.instance_id !== instanceId) throw new Error('Port is in use by a mismatching instance ID.');
90
+ return body;
91
+ } catch (error) {
92
+ if (controller.signal.aborted) throw new Error('Health check timed out.');
93
+ if (error.code === 'body_too_large') throw new Error('Health check response exceeds 16KiB limit.');
94
+ if (error.code === 'ECONNREFUSED' || error.cause?.code === 'ECONNREFUSED') return null;
95
+ throw error;
96
+ } finally { clearTimeout(timer); }
97
+ }
98
+
99
+ export async function ensureService(options = {}, deps = {}) {
100
+ if (!options || typeof options !== 'object' || Array.isArray(options) ||
101
+ Object.keys(options).some(k => !['dataDir', 'startupTimeoutMs'].includes(k)) ||
102
+ (options.dataDir !== undefined && (typeof options.dataDir !== 'string' || !options.dataDir)) ||
103
+ (options.startupTimeoutMs !== undefined && (!Number.isFinite(options.startupTimeoutMs) ||
104
+ options.startupTimeoutMs < 1000 || options.startupTimeoutMs > 60000))) {
105
+ throw new Error('Invalid bridge runtime options.');
106
+ }
107
+ const dir = ensurePrivateDataDir(options.dataDir ?? getDefaultDataDir());
108
+ if (!startupMap.has(dir)) {
109
+ const pending = startService(dir, options.startupTimeoutMs ?? 15000, deps);
110
+ startupMap.set(dir, pending);
111
+ void pending.finally(() => startupMap.delete(dir)).catch(() => {});
112
+ }
113
+ return startupMap.get(dir);
114
+ }
115
+
116
+ async function startService(dataDir, timeoutMs, deps) {
117
+ const config = loadLocalConfig(dataDir);
118
+ const baseURL = `http://127.0.0.1:${config.port}`;
119
+ const probe = deps.probeHealth ?? probeHealth;
120
+ const result = { baseURL, apiKey: config.api_key, instance_id: config.instance_id, dataDir };
121
+ const deadline = Date.now() + timeoutMs;
122
+ if (await probe(baseURL, config.api_key, config.instance_id, Math.min(2000, timeoutMs))) return result;
123
+ const pythonPath = path.join(dataDir, '.venv/bin/python');
124
+ if (!deps.spawn && !fs.existsSync(pythonPath)) throw new Error('Run setup to install the bridge virtual environment.');
125
+ const logPath = path.join(dataDir, 'daemon.log');
126
+ const logFd = fs.openSync(logPath, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600);
127
+ let child;
128
+ try {
129
+ const st = fs.fstatSync(logFd);
130
+ if (!st.isFile() || (st.mode & 0o077)) throw new Error('Daemon log must be private.');
131
+ child = (deps.spawn ?? child_process.spawn)(pythonPath,
132
+ ['-m', 'gemini_native_bridge', 'serve', '--data-dir', dataDir], {
133
+ detached: true, stdio: ['ignore', logFd, logFd], cwd: BRIDGE_ROOT,
134
+ env: { ...process.env, PYTHONPATH: BRIDGE_ROOT },
135
+ });
136
+ } catch { throw new Error('Could not start the local bridge process.'); }
137
+ finally { fs.closeSync(logFd); }
138
+ let exited = false;
139
+ child.on('error', () => { exited = true; });
140
+ child.on('exit', () => { exited = true; });
141
+ child.unref();
142
+ while (Date.now() < deadline) {
143
+ await new Promise(resolve => setTimeout(resolve, 100));
144
+ if (exited) throw new Error('Local bridge process exited before becoming ready.');
145
+ const remaining = deadline - Date.now();
146
+ if (remaining <= 0) break;
147
+ if (await probe(baseURL, config.api_key, config.instance_id, Math.min(1000, remaining))) return result;
148
+ }
149
+ throw new Error('Timed out waiting for the local bridge.');
150
+ }
151
+
152
+ function errorResponse(status, code, message) {
153
+ return new Response(JSON.stringify({ error: { code, type: 'invalid_request_error', message, param: null } }), {
154
+ status, headers: { 'Content-Type': 'application/json', 'X-Magpie-Sign-In': 'kept' },
155
+ });
156
+ }
157
+
158
+ export function createForwarder(serviceInfo, fetchImpl = globalThis.fetch) {
159
+ return async (input, init = {}) => {
160
+ try {
161
+ const original = input instanceof Request ? input : null;
162
+ const parsed = new URL(original?.url ?? String(input), serviceInfo.baseURL);
163
+ if (!['/v1/chat/completions', '/v1/models', '/health'].includes(parsed.pathname)) {
164
+ return errorResponse(404, 'not_found', 'Unsupported bridge path.');
165
+ }
166
+ const method = (init.method ?? original?.method ?? 'GET').toUpperCase();
167
+ const headers = new Headers(original?.headers);
168
+ new Headers(init.headers).forEach((v, k) => headers.set(k, v));
169
+ headers.delete('content-length');
170
+ headers.delete('transfer-encoding');
171
+ headers.set('authorization', `Bearer ${serviceInfo.apiKey}`);
172
+ const signal = init.signal ?? original?.signal;
173
+ signal?.throwIfAborted();
174
+ let body;
175
+ if (!['GET', 'HEAD'].includes(method)) {
176
+ const source = init.body !== undefined ? init.body : original?.body;
177
+ if (source != null) {
178
+ body = await boundedBytes(new Response(source).body, MAX_BODY, signal);
179
+ }
180
+ }
181
+ return await fetchImpl(`${serviceInfo.baseURL}${parsed.pathname}${parsed.search}`, {
182
+ method, headers, body, signal, redirect: 'error',
183
+ });
184
+ } catch (error) {
185
+ if (error.code === 'body_too_large') return errorResponse(400, 'request_too_large', 'Request exceeds 1 MiB limit.');
186
+ return errorResponse(409, 'bridge_network_error', 'Failed to communicate with local gemini-native-task bridge.');
187
+ }
188
+ };
189
+ }
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gemini-native-bridge"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "h2==4.3.0",
11
+ "jsonschema==4.25.1",
12
+ ]
13
+
14
+ [tool.setuptools.packages.find]
15
+ where = ["bridge"]
16
+
17
+ [tool.setuptools.package-data]
18
+ gemini_native_bridge = ["native_auth_keychain.swift"]
@@ -0,0 +1,2 @@
1
+ h2==4.3.0
2
+ jsonschema==4.25.1
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import child_process from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { boundedBytes, ensurePrivateDataDir, ensureService, getDefaultDataDir, loadLocalConfig, probeHealth } from '../lib/runtime.mjs';
7
+
8
+ const ROOT_DIR = fileURLToPath(new URL('..', import.meta.url));
9
+ const BRIDGE_DIR = path.join(ROOT_DIR, 'bridge');
10
+ const SETUP_FLAGS = ['data-dir', 'auth-file', 'task-headers', 'tool-headers', 'task-template', 'turn-template', 'binary', 'port'];
11
+ const REQUIRED = ['auth-file', 'task-headers', 'tool-headers', 'task-template', 'turn-template'];
12
+ function safeError(message) { return Object.assign(new Error(message), { safeMessage: true }); }
13
+
14
+ function validateFlags(command, flags) {
15
+ if (!['setup', 'start', 'status', 'stop'].includes(command)) throw safeError('Usage: bridge.mjs setup|start|status|stop [options]');
16
+ for (const [key, value] of Object.entries(flags)) {
17
+ if (!(command === 'setup' ? SETUP_FLAGS : ['data-dir']).includes(key) || typeof value !== 'string' || !value) {
18
+ throw safeError('Unsupported option or missing option value.');
19
+ }
20
+ }
21
+ if (command === 'setup') {
22
+ if (REQUIRED.some(key => !flags[key])) throw safeError('Setup requires auth-file, task-headers, tool-headers, task-template and turn-template.');
23
+ if (flags.port !== undefined && (!/^[0-9]{4,5}$/.test(flags.port) || Number(flags.port) < 1024 || Number(flags.port) > 65535)) {
24
+ throw safeError('Port must be an integer between 1024 and 65535.');
25
+ }
26
+ }
27
+ }
28
+
29
+ export function parseArgs(args) {
30
+ const [command, ...rest] = args;
31
+ const flags = {};
32
+ for (let i = 0; i < rest.length; i += 2) {
33
+ const flag = rest[i];
34
+ const value = rest[i + 1];
35
+ if (!flag.startsWith('--') || typeof value !== 'string' || value.startsWith('--') || Object.hasOwn(flags, flag.slice(2))) {
36
+ throw safeError('Expected distinct --option value pairs.');
37
+ }
38
+ flags[flag.slice(2)] = value;
39
+ }
40
+ validateFlags(command, flags);
41
+ return { command, flags };
42
+ }
43
+
44
+ export async function setup(flags, deps = {}) {
45
+ validateFlags('setup', flags);
46
+ const dataDir = ensurePrivateDataDir(flags['data-dir'] ?? getDefaultDataDir(), { create: true });
47
+ if (fs.existsSync(path.join(dataDir, 'config.json'))) throw safeError('Configuration already exists. Preserve it or choose a new data directory.');
48
+ const exec = deps.execFileSync ?? child_process.execFileSync;
49
+ const venvDir = path.join(dataDir, '.venv');
50
+ if (!fs.existsSync(venvDir)) exec('python3', ['-m', 'venv', venvDir], { stdio: 'pipe', timeout: 60000 });
51
+ exec(path.join(venvDir, 'bin/pip'), ['install', '-r', path.join(ROOT_DIR, 'requirements.txt')], { stdio: 'pipe', timeout: 120000 });
52
+ const args = ['-m', 'gemini_native_bridge', 'bootstrap', '--data-dir', dataDir];
53
+ for (const key of [...REQUIRED, 'binary', 'port']) if (flags[key] !== undefined) args.push(`--${key}`, flags[key]);
54
+ const output = exec(path.join(venvDir, 'bin/python'), args, {
55
+ env: { ...process.env, PYTHONPATH: BRIDGE_DIR }, cwd: BRIDGE_DIR, stdio: 'pipe', timeout: 120000,
56
+ });
57
+ // Only publish the known, nonsecret bootstrap fields.
58
+ const result = JSON.parse(output.toString());
59
+ if (result.configured !== true) throw safeError('Bootstrap did not complete.');
60
+ return JSON.stringify({ configured: true, port: result.port, account_label: result.account_label, instance_id: result.instance_id }) + '\n';
61
+ }
62
+
63
+ export async function main(args = process.argv.slice(2)) {
64
+ const { command, flags } = parseArgs(args);
65
+ const dataDir = flags['data-dir'] ?? getDefaultDataDir();
66
+ if (command === 'setup') return process.stdout.write(await setup(flags));
67
+ if (command === 'start') {
68
+ const service = await ensureService({ dataDir });
69
+ return process.stdout.write(JSON.stringify({ status: 'running', baseURL: service.baseURL, instance_id: service.instance_id, dataDir: service.dataDir }) + '\n');
70
+ }
71
+ const config = loadLocalConfig(dataDir);
72
+ const baseURL = `http://127.0.0.1:${config.port}`;
73
+ const health = await probeHealth(baseURL, config.api_key, config.instance_id);
74
+ if (command === 'status') {
75
+ return process.stdout.write(JSON.stringify({ status: health ? 'running' : 'stopped', port: config.port, active_tasks: health?.active_tasks, instance_id: config.instance_id }) + '\n');
76
+ }
77
+ if (!health) return process.stdout.write('{"status":"already_stopped"}\n');
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), 5000);
80
+ try {
81
+ const res = await fetch(`${baseURL}/v1/native/shutdown`, {
82
+ method: 'POST', headers: { Authorization: `Bearer ${config.api_key}`, 'Content-Type': 'application/json' },
83
+ body: '{}', signal: controller.signal, redirect: 'error',
84
+ });
85
+ if (res.status !== 202) throw safeError('Local bridge rejected shutdown.');
86
+ const result = JSON.parse((await boundedBytes(res.body, 16384, controller.signal)).toString());
87
+ if (result.status !== 'shutting_down') throw safeError('Invalid shutdown acknowledgement.');
88
+ process.stdout.write('{"status":"shutting_down"}\n');
89
+ } finally { clearTimeout(timer); }
90
+ }
91
+
92
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
93
+ main().catch(error => {
94
+ process.stderr.write((error.safeMessage ? error.message : 'Bridge command failed. Check configuration, private file permissions and local service availability.') + '\n');
95
+ process.exitCode = 1;
96
+ });
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Gemini Web provider for Magpie, backed by gemini-web2api-go",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -20,8 +20,16 @@
20
20
  "files": [
21
21
  "index.mjs",
22
22
  "lib/",
23
- "scripts/",
23
+ "scripts/backend.mjs",
24
+ "scripts/backend-argon.mjs",
24
25
  "patches/",
26
+ "native-task/lib/",
27
+ "native-task/bridge/",
28
+ "native-task/scripts/bridge.mjs",
29
+ "native-task/requirements.txt",
30
+ "native-task/pyproject.toml",
31
+ "native-task/README.md",
32
+ "native-task/LICENSE",
25
33
  "README.md",
26
34
  "README.en.md",
27
35
  "LICENSE",
@@ -40,7 +48,7 @@
40
48
  },
41
49
  "license": "MIT",
42
50
  "scripts": {
43
- "test": "node --test test/*.test.mjs",
51
+ "test": "node --test test/*.test.mjs native-task/test/*.test.mjs",
44
52
  "backend:install": "node scripts/backend.mjs install",
45
53
  "backend:start": "node scripts/backend.mjs start",
46
54
  "backend:status": "node scripts/backend.mjs status",