teleportation-cli 1.4.4 → 1.5.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/.claude/hooks/stop.mjs +114 -33
- package/.gemini/hooks/after_agent.mjs +190 -0
- package/.gemini/hooks/after_tool.mjs +126 -0
- package/.gemini/hooks/before_tool.mjs +276 -0
- package/.gemini/hooks/session_end.mjs +158 -0
- package/.gemini/hooks/session_start.mjs +193 -0
- package/.gemini/hooks/shared/config.mjs +67 -0
- package/lib/daemon/teleportation-daemon.js +93 -1
- package/lib/daemon/transcript-ingestion.js +26 -3
- package/package.json +6 -1
- package/teleportation-cli.cjs +22 -1
- package/teleportation.uhr.json +76 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI BeforeTool Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes BEFORE any tool runs in Gemini CLI.
|
|
6
|
+
* It mirrors the Claude Code pre_tool_use hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { appendFileSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname } from 'path';
|
|
13
|
+
import { loadConfig } from './shared/config.mjs';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Security: Max input size to prevent memory exhaustion
|
|
19
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
20
|
+
|
|
21
|
+
// Read all stdin
|
|
22
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
stdin.on('data', chunk => {
|
|
26
|
+
data += chunk;
|
|
27
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
28
|
+
reject(new Error('Input too large'));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
stdin.on('end', () => resolve(data));
|
|
32
|
+
stdin.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Sleep helper
|
|
36
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:before_tool] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Fetch JSON helper
|
|
51
|
+
const fetchJson = async (url, opts) => {
|
|
52
|
+
const res = await fetch(url, opts);
|
|
53
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
54
|
+
return res.json();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Safe tools that can be auto-approved (read-only operations)
|
|
58
|
+
const SAFE_TOOLS = new Set([
|
|
59
|
+
'read_file',
|
|
60
|
+
'list_directory',
|
|
61
|
+
'search_files',
|
|
62
|
+
'grep_search',
|
|
63
|
+
'file_search',
|
|
64
|
+
'web_search',
|
|
65
|
+
'web_fetch',
|
|
66
|
+
'get_file_info',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Safe shell commands (read-only)
|
|
70
|
+
const SAFE_SHELL_COMMANDS = [
|
|
71
|
+
/^git\s+(status|log|diff|show|branch|remote|config\s+--get)/,
|
|
72
|
+
/^ls(\s|$)/,
|
|
73
|
+
/^pwd(\s|$)/,
|
|
74
|
+
/^cat\s/,
|
|
75
|
+
/^head\s/,
|
|
76
|
+
/^tail\s/,
|
|
77
|
+
/^wc\s/,
|
|
78
|
+
/^echo\s/,
|
|
79
|
+
/^which\s/,
|
|
80
|
+
/^type\s/,
|
|
81
|
+
/^file\s/,
|
|
82
|
+
/^stat\s/,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function isSafeTool(toolName, toolInput) {
|
|
86
|
+
// Check if tool is in safe list
|
|
87
|
+
if (SAFE_TOOLS.has(toolName.toLowerCase())) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check shell commands
|
|
92
|
+
if (toolName === 'run_shell_command' || toolName === 'bash' || toolName === 'shell') {
|
|
93
|
+
const command = toolInput?.command || toolInput?.cmd || '';
|
|
94
|
+
|
|
95
|
+
// Security: Check for command chaining/injection patterns
|
|
96
|
+
if (/[;&|`$()]/.test(command)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Security: Check for redirects that could overwrite files
|
|
101
|
+
if (/[><]/.test(command)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Only allow if matches a safe pattern
|
|
106
|
+
for (const pattern of SAFE_SHELL_COMMANDS) {
|
|
107
|
+
if (pattern.test(command)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Output decision to Gemini CLI
|
|
117
|
+
function outputDecision(action, reason = '') {
|
|
118
|
+
const decision = { action, reason };
|
|
119
|
+
stdout.write(JSON.stringify(decision));
|
|
120
|
+
log(`Decision: ${action} - ${reason}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
(async () => {
|
|
124
|
+
log('=== BeforeTool hook invoked ===');
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const raw = await readStdin();
|
|
128
|
+
let input;
|
|
129
|
+
try {
|
|
130
|
+
input = JSON.parse(raw || '{}');
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
133
|
+
outputDecision('ALLOW', 'Invalid input, allowing by default');
|
|
134
|
+
return exit(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { tool_name, tool_input, session_id } = input;
|
|
138
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
139
|
+
log(`Tool: ${tool_name}, Session (input): ${session_id}, TeleportSession: ${teleportSessionId}`);
|
|
140
|
+
|
|
141
|
+
// Load config from shared module
|
|
142
|
+
const config = await loadConfig(log);
|
|
143
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
144
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
145
|
+
|
|
146
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
147
|
+
|
|
148
|
+
// If no relay configured, allow all (local-only mode)
|
|
149
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
150
|
+
log('No relay configured, allowing tool');
|
|
151
|
+
outputDecision('ALLOW', 'No relay configured');
|
|
152
|
+
return exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check if tool is safe (auto-approve)
|
|
156
|
+
if (isSafeTool(tool_name, tool_input)) {
|
|
157
|
+
log(`Safe tool ${tool_name}, auto-approving`);
|
|
158
|
+
outputDecision('ALLOW', 'Safe tool auto-approved');
|
|
159
|
+
return exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if user is away (should use remote approval)
|
|
163
|
+
let isAway = false;
|
|
164
|
+
try {
|
|
165
|
+
const stateUrl = `${RELAY_API_URL}/api/sessions/${teleportSessionId}/daemon-state`;
|
|
166
|
+
log(`Checking away status at: ${stateUrl}`);
|
|
167
|
+
|
|
168
|
+
const response = await fetch(stateUrl, {
|
|
169
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
|
|
170
|
+
signal: AbortSignal.timeout(5000),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (response.ok) {
|
|
174
|
+
const state = await response.json();
|
|
175
|
+
isAway = !!state.is_away;
|
|
176
|
+
log(`Session away status: ${isAway}`);
|
|
177
|
+
} else {
|
|
178
|
+
log(`Away check failed: HTTP ${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log(`Could not check away status: ${e.message}`);
|
|
182
|
+
// If we can't check, assume user is present
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If user is present, ask them locally
|
|
186
|
+
if (!isAway) {
|
|
187
|
+
log('User is present, asking locally');
|
|
188
|
+
outputDecision('ASK_USER', 'User is present');
|
|
189
|
+
return exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// User is away - create remote approval request
|
|
193
|
+
log('User is away, creating remote approval request');
|
|
194
|
+
|
|
195
|
+
const approvalPayload = {
|
|
196
|
+
session_id: teleportSessionId,
|
|
197
|
+
tool_name,
|
|
198
|
+
tool_input,
|
|
199
|
+
status: 'pending',
|
|
200
|
+
source: 'gemini-cli',
|
|
201
|
+
meta: {
|
|
202
|
+
cwd: process.cwd(),
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let approvalId;
|
|
208
|
+
try {
|
|
209
|
+
const approval = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'Content-Type': 'application/json',
|
|
213
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(approvalPayload),
|
|
216
|
+
signal: AbortSignal.timeout(10000),
|
|
217
|
+
});
|
|
218
|
+
approvalId = approval.id;
|
|
219
|
+
log(`Created approval request: ${approvalId}`);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
log(`Failed to create approval: ${e.message}`);
|
|
222
|
+
// If relay is down, allow (fail-safe for autonomous operation)
|
|
223
|
+
outputDecision('ALLOW', 'Relay unavailable, auto-approved');
|
|
224
|
+
return exit(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Poll for approval decision
|
|
228
|
+
// Handle environment variable with proper parsing and bounds
|
|
229
|
+
const POLL_TIMEOUT_MS = Math.max(5000, Math.min(3600000,
|
|
230
|
+
parseInt(env.GEMINI_APPROVAL_TIMEOUT_MS || '60000', 10) || 60000
|
|
231
|
+
));
|
|
232
|
+
const POLL_INTERVAL_MS = Math.max(100, Math.min(30000,
|
|
233
|
+
parseInt(env.GEMINI_POLL_INTERVAL_MS || '2000', 10) || 2000
|
|
234
|
+
));
|
|
235
|
+
|
|
236
|
+
log(`Polling for approval: timeout=${POLL_TIMEOUT_MS}ms, interval=${POLL_INTERVAL_MS}ms`);
|
|
237
|
+
|
|
238
|
+
const startTime = Date.now();
|
|
239
|
+
|
|
240
|
+
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
|
241
|
+
try {
|
|
242
|
+
const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
|
|
243
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
|
|
244
|
+
signal: AbortSignal.timeout(5000),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (status.status === 'allowed') {
|
|
248
|
+
log(`Approval ${approvalId} allowed`);
|
|
249
|
+
outputDecision('ALLOW', 'Remote approval granted');
|
|
250
|
+
return exit(0);
|
|
251
|
+
} else if (status.status === 'denied') {
|
|
252
|
+
log(`Approval ${approvalId} denied`);
|
|
253
|
+
outputDecision('DENY', 'Remote approval denied');
|
|
254
|
+
return exit(0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Still pending, wait and poll again
|
|
258
|
+
await sleep(POLL_INTERVAL_MS);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
log(`Poll error: ${e.message}`);
|
|
261
|
+
await sleep(POLL_INTERVAL_MS);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Timeout - let daemon handle it
|
|
266
|
+
log(`Approval timeout, handing off to daemon`);
|
|
267
|
+
outputDecision('DENY', 'Approval timeout - daemon will handle');
|
|
268
|
+
return exit(0);
|
|
269
|
+
|
|
270
|
+
} catch (e) {
|
|
271
|
+
log(`Hook error: ${e.message}`);
|
|
272
|
+
// On error, allow (fail-safe)
|
|
273
|
+
outputDecision('ALLOW', `Error: ${e.message}`);
|
|
274
|
+
return exit(0);
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI SessionEnd Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes when a Gemini CLI session ends.
|
|
6
|
+
* It mirrors the Claude Code session_end hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname, join } from 'path';
|
|
13
|
+
import { homedir, tmpdir } from 'node:os';
|
|
14
|
+
import { loadConfig } from './shared/config.mjs';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
|
|
19
|
+
// Security: Session ID validation to prevent command injection
|
|
20
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
// Security: Max input size to prevent memory exhaustion
|
|
22
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
23
|
+
|
|
24
|
+
// Read all stdin
|
|
25
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
26
|
+
let data = '';
|
|
27
|
+
stdin.setEncoding('utf8');
|
|
28
|
+
stdin.on('data', chunk => {
|
|
29
|
+
data += chunk;
|
|
30
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
31
|
+
reject(new Error('Input too large'));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
stdin.on('end', () => resolve(data));
|
|
35
|
+
stdin.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:session_end] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
(async () => {
|
|
51
|
+
log('=== SessionEnd hook invoked ===');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readStdin();
|
|
55
|
+
let input;
|
|
56
|
+
try {
|
|
57
|
+
input = JSON.parse(raw || '{}');
|
|
58
|
+
} catch (e) {
|
|
59
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
60
|
+
return exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { session_id, reason, stats } = input;
|
|
64
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
65
|
+
|
|
66
|
+
log(`Session: ${teleportSessionId}, Reason: ${reason}`);
|
|
67
|
+
|
|
68
|
+
// Load config from shared module
|
|
69
|
+
const config = await loadConfig(log);
|
|
70
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
71
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
72
|
+
|
|
73
|
+
// NOTE: Heartbeat processes are no longer spawned per-session.
|
|
74
|
+
// The daemon handles heartbeats inline (PRD-0025 migration).
|
|
75
|
+
|
|
76
|
+
// If no relay configured, just exit
|
|
77
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
78
|
+
log('No relay configured, exiting');
|
|
79
|
+
return exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Log session end to timeline
|
|
83
|
+
const timelineEvent = {
|
|
84
|
+
session_id: teleportSessionId,
|
|
85
|
+
type: 'session_ended',
|
|
86
|
+
data: {
|
|
87
|
+
reason: reason || 'normal_exit',
|
|
88
|
+
stats: stats || {},
|
|
89
|
+
timestamp: Date.now()
|
|
90
|
+
},
|
|
91
|
+
source: 'gemini-cli',
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify(timelineEvent),
|
|
103
|
+
signal: AbortSignal.timeout(5000),
|
|
104
|
+
});
|
|
105
|
+
log(`Session end logged to timeline`);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
log(`Failed to log session end: ${e.message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Update session state
|
|
111
|
+
try {
|
|
112
|
+
await fetch(`${RELAY_API_URL}/api/sessions/${teleportSessionId}/daemon-state`, {
|
|
113
|
+
method: 'PATCH',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
status: 'stopped',
|
|
120
|
+
is_away: false,
|
|
121
|
+
stopped_reason: 'session_end'
|
|
122
|
+
}),
|
|
123
|
+
signal: AbortSignal.timeout(2000)
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {}
|
|
126
|
+
|
|
127
|
+
// Unregister from local daemon
|
|
128
|
+
const DAEMON_PORT = env.TELEPORTATION_DAEMON_PORT || '3050';
|
|
129
|
+
try {
|
|
130
|
+
await fetch(`http://localhost:${DAEMON_PORT}/sessions/deregister`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({ session_id: teleportSessionId }),
|
|
134
|
+
signal: AbortSignal.timeout(2000),
|
|
135
|
+
});
|
|
136
|
+
log(`Session unregistered from daemon`);
|
|
137
|
+
} catch (e) {}
|
|
138
|
+
|
|
139
|
+
// Deregister session with relay API
|
|
140
|
+
try {
|
|
141
|
+
await fetch(`${RELAY_API_URL}/api/sessions/deregister`, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify({ session_id: teleportSessionId })
|
|
148
|
+
});
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
|
|
151
|
+
stdout.write('{}');
|
|
152
|
+
return exit(0);
|
|
153
|
+
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log(`Hook error: ${e.message}`);
|
|
156
|
+
return exit(0);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI SessionStart Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes when a Gemini CLI session starts.
|
|
6
|
+
* It mirrors the Claude Code session_start hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { dirname, join } from 'path';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { loadConfig } from './shared/config.mjs';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// Security: Max input size to prevent memory exhaustion
|
|
22
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
23
|
+
|
|
24
|
+
// Read all stdin
|
|
25
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
26
|
+
let data = '';
|
|
27
|
+
stdin.setEncoding('utf8');
|
|
28
|
+
stdin.on('data', chunk => {
|
|
29
|
+
data += chunk;
|
|
30
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
31
|
+
reject(new Error('Input too large'));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
stdin.on('end', () => resolve(data));
|
|
35
|
+
stdin.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:session_start] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Lazy-load metadata extraction
|
|
51
|
+
let extractSessionMetadata = null;
|
|
52
|
+
async function getSessionMetadata(cwd) {
|
|
53
|
+
if (!extractSessionMetadata) {
|
|
54
|
+
const possiblePaths = [
|
|
55
|
+
join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
|
|
56
|
+
join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const path of possiblePaths) {
|
|
60
|
+
try {
|
|
61
|
+
const mod = await import('file://' + path);
|
|
62
|
+
extractSessionMetadata = mod.extractSessionMetadata;
|
|
63
|
+
break;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// Try next path
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!extractSessionMetadata) return { cwd };
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return await extractSessionMetadata(cwd);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { cwd };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spawn the heartbeat background process
|
|
81
|
+
* NOTE: Heartbeat processes are no longer spawned per-session.
|
|
82
|
+
* The daemon handles heartbeats inline (PRD-0025 migration).
|
|
83
|
+
* This function is kept as a no-op for backward compatibility.
|
|
84
|
+
*/
|
|
85
|
+
function spawnHeartbeat(sessionId, config) {
|
|
86
|
+
// No-op: daemon handles heartbeats inline now
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
(async () => {
|
|
92
|
+
log('=== SessionStart hook invoked ===');
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readStdin();
|
|
96
|
+
let input;
|
|
97
|
+
try {
|
|
98
|
+
input = JSON.parse(raw || '{}');
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
101
|
+
return exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { session_id, model, cwd, owner_id } = input;
|
|
105
|
+
const workingDir = cwd || process.cwd();
|
|
106
|
+
|
|
107
|
+
// Security: Use UUID instead of timestamp to prevent collisions
|
|
108
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || `gemini-${randomUUID().substring(0, 8)}`;
|
|
109
|
+
|
|
110
|
+
log(`Session: ${teleportSessionId}, Model: ${model}, CWD: ${workingDir}`);
|
|
111
|
+
|
|
112
|
+
// Load config from shared module
|
|
113
|
+
const config = await loadConfig(log);
|
|
114
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
115
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
116
|
+
|
|
117
|
+
// If no relay configured, just log locally
|
|
118
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
119
|
+
log('No relay configured, skipping session registration');
|
|
120
|
+
stdout.write(JSON.stringify({ session_id: teleportSessionId }));
|
|
121
|
+
return exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Extract enhanced session metadata (Parity with Claude)
|
|
125
|
+
const meta = await getSessionMetadata(workingDir);
|
|
126
|
+
meta.session_id = teleportSessionId;
|
|
127
|
+
meta.gemini_session_id = session_id;
|
|
128
|
+
meta.current_model = model;
|
|
129
|
+
|
|
130
|
+
// Register session with relay
|
|
131
|
+
const sessionPayload = {
|
|
132
|
+
session_id: teleportSessionId,
|
|
133
|
+
meta: {
|
|
134
|
+
...meta,
|
|
135
|
+
source: 'gemini-cli' // Ensure source is in meta for relay
|
|
136
|
+
},
|
|
137
|
+
owner_id: owner_id || null,
|
|
138
|
+
registered_at: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await fetch(`${RELAY_API_URL}/api/sessions/register`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify(sessionPayload),
|
|
149
|
+
signal: AbortSignal.timeout(10000),
|
|
150
|
+
});
|
|
151
|
+
log(`Session ${teleportSessionId} registered with relay`);
|
|
152
|
+
|
|
153
|
+
// Start heartbeat process
|
|
154
|
+
spawnHeartbeat(teleportSessionId, config);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log(`Failed to register session: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Also register with local daemon if running
|
|
160
|
+
const DAEMON_PORT = env.TELEPORTATION_DAEMON_PORT || '3050';
|
|
161
|
+
try {
|
|
162
|
+
await fetch(`http://localhost:${DAEMON_PORT}/sessions/register`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
session_id: teleportSessionId,
|
|
167
|
+
gemini_session_id: session_id,
|
|
168
|
+
cwd: workingDir,
|
|
169
|
+
meta: {
|
|
170
|
+
...meta,
|
|
171
|
+
source: 'gemini-cli' // Pass source to daemon too
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
signal: AbortSignal.timeout(2000),
|
|
175
|
+
});
|
|
176
|
+
log(`Session registered with local daemon`);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
log(`Daemon not available: ${e.message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Output session ID for Gemini CLI to use
|
|
182
|
+
stdout.write(JSON.stringify({
|
|
183
|
+
session_id: teleportSessionId,
|
|
184
|
+
registered: true,
|
|
185
|
+
}));
|
|
186
|
+
return exit(0);
|
|
187
|
+
|
|
188
|
+
} catch (e) {
|
|
189
|
+
log(`Hook error: ${e.message}`);
|
|
190
|
+
stdout.write('{}');
|
|
191
|
+
return exit(0);
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration loader for Gemini hooks
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { env } from 'node:process';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load configuration for Gemini hooks
|
|
16
|
+
* Priority:
|
|
17
|
+
* 1. Environment variables
|
|
18
|
+
* 2. Claude's config-loader
|
|
19
|
+
* 3. Legacy config file (~/.teleportation-config.json)
|
|
20
|
+
*/
|
|
21
|
+
export async function loadConfig(log = () => {}) {
|
|
22
|
+
// Priority 1: Environment variables (Crucial for testing/overrides)
|
|
23
|
+
if (env.RELAY_API_URL && env.RELAY_API_KEY) {
|
|
24
|
+
return {
|
|
25
|
+
relayApiUrl: env.RELAY_API_URL,
|
|
26
|
+
relayApiKey: env.RELAY_API_KEY,
|
|
27
|
+
slackWebhookUrl: env.SLACK_WEBHOOK_URL || '',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Priority 2: Claude's config-loader (Shared system config)
|
|
32
|
+
try {
|
|
33
|
+
// Shared module is in .gemini/hooks/shared/
|
|
34
|
+
// config-loader is in .claude/hooks/
|
|
35
|
+
const configLoaderPath = join(__dirname, '..', '..', '..', '.claude', 'hooks', 'config-loader.mjs');
|
|
36
|
+
if (existsSync(configLoaderPath)) {
|
|
37
|
+
const { loadConfig } = await import('file://' + configLoaderPath);
|
|
38
|
+
const config = await loadConfig();
|
|
39
|
+
if (config.relayApiUrl && config.relayApiKey) {
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
log(`Config loader failed: ${e.message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Priority 3: Legacy config file
|
|
48
|
+
const legacyConfigPath = join(homedir(), '.teleportation-config.json');
|
|
49
|
+
if (existsSync(legacyConfigPath)) {
|
|
50
|
+
try {
|
|
51
|
+
const config = JSON.parse(readFileSync(legacyConfigPath, 'utf8'));
|
|
52
|
+
return {
|
|
53
|
+
relayApiUrl: config.relay_api_url || '',
|
|
54
|
+
relayApiKey: config.relay_api_key || '',
|
|
55
|
+
slackWebhookUrl: config.slack_webhook_url || '',
|
|
56
|
+
};
|
|
57
|
+
} catch (e) {
|
|
58
|
+
log(`Legacy config failed: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
relayApiUrl: '',
|
|
64
|
+
relayApiKey: '',
|
|
65
|
+
slackWebhookUrl: '',
|
|
66
|
+
};
|
|
67
|
+
}
|