teleportation-cli 1.1.3 → 1.1.5
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/config-loader.mjs +88 -34
- package/.claude/hooks/permission_request.mjs +98 -37
- package/.claude/hooks/post_tool_use.mjs +246 -28
- package/.claude/hooks/pre_tool_use.mjs +40 -17
- package/.claude/hooks/session-register.mjs +8 -4
- package/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- package/lib/config/manager.js +0 -1
- package/lib/daemon/pid-manager.js +34 -11
- package/lib/daemon/response-classifier.js +326 -0
- package/lib/daemon/task-executor.js +1061 -0
- package/lib/daemon/teleportation-daemon.js +218 -13
- package/lib/utils/log-sanitizer.js +111 -0
- package/lib/utils/logger.js +74 -127
- package/package.json +6 -3
- package/teleportation-cli.cjs +173 -9
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// Shared config loader for all hooks
|
|
3
3
|
// Reads from encrypted credentials (~/.teleportation/credentials), then ~/.teleportation-config.json, then env vars
|
|
4
|
+
// PRD-0019: Supports JWT authentication with automatic token refresh
|
|
4
5
|
|
|
5
6
|
import { readFile } from 'node:fs/promises';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
@@ -12,6 +13,33 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
14
|
const __dirname = dirname(__filename);
|
|
14
15
|
|
|
16
|
+
// Path resolution helper for auth modules
|
|
17
|
+
const AUTH_MODULE_PATHS = [
|
|
18
|
+
// If hook is still in project directory
|
|
19
|
+
() => join(process.cwd(), 'lib', 'auth'),
|
|
20
|
+
// If installed globally, try common locations
|
|
21
|
+
() => join(homedir(), '.teleportation', 'lib', 'auth'),
|
|
22
|
+
// Try relative to hook location (if hooks are symlinked)
|
|
23
|
+
() => join(__dirname, '..', '..', 'lib', 'auth')
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Try to import a module from multiple possible paths
|
|
28
|
+
*/
|
|
29
|
+
async function tryImportModule(moduleName) {
|
|
30
|
+
for (const getPath of AUTH_MODULE_PATHS) {
|
|
31
|
+
const modulePath = join(getPath(), moduleName);
|
|
32
|
+
try {
|
|
33
|
+
const module = await import(modulePath);
|
|
34
|
+
return module;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// Try next path
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
export async function loadConfig() {
|
|
16
44
|
// Test override: allow forcing config to be loaded from environment variables only
|
|
17
45
|
// This is useful for unit tests that mock the relay API.
|
|
@@ -20,46 +48,70 @@ export async function loadConfig() {
|
|
|
20
48
|
relayApiUrl: env.RELAY_API_URL || '',
|
|
21
49
|
relayApiKey: env.RELAY_API_KEY || '',
|
|
22
50
|
userToken: env.DETACH_USER_TOKEN || '',
|
|
23
|
-
slackWebhookUrl: env.SLACK_WEBHOOK_URL || ''
|
|
51
|
+
slackWebhookUrl: env.SLACK_WEBHOOK_URL || '',
|
|
52
|
+
authMethod: 'env'
|
|
24
53
|
};
|
|
25
54
|
}
|
|
26
55
|
|
|
27
|
-
// Priority 1: Try
|
|
56
|
+
// Priority 1: Try JWT authentication (PRD-0019)
|
|
57
|
+
// This provides automatic token refresh for short-lived access tokens
|
|
28
58
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const tokenRefreshModule = await tryImportModule('token-refresh.js');
|
|
60
|
+
if (tokenRefreshModule && tokenRefreshModule.getValidAccessToken) {
|
|
61
|
+
const { getValidAccessToken, getAuthMethod } = tokenRefreshModule;
|
|
62
|
+
|
|
63
|
+
// Check if JWT credentials are available
|
|
64
|
+
const authInfo = await getAuthMethod();
|
|
65
|
+
if (authInfo.method === 'jwt') {
|
|
66
|
+
// Get a valid access token (refreshes automatically if needed)
|
|
67
|
+
const accessToken = await getValidAccessToken();
|
|
68
|
+
|
|
69
|
+
// Load credentials for other config values
|
|
70
|
+
const credentialsModule = await tryImportModule('credentials.js');
|
|
71
|
+
let relayApiUrl = '';
|
|
72
|
+
let slackWebhookUrl = '';
|
|
73
|
+
|
|
74
|
+
if (credentialsModule && credentialsModule.CredentialManager) {
|
|
75
|
+
const manager = new credentialsModule.CredentialManager();
|
|
76
|
+
const credentials = await manager.load();
|
|
77
|
+
if (credentials) {
|
|
78
|
+
relayApiUrl = credentials.relayApiUrl || credentials.relay_api_url || '';
|
|
79
|
+
slackWebhookUrl = credentials.slackWebhookUrl || credentials.slack_webhook_url || '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
relayApiUrl,
|
|
85
|
+
relayApiKey: accessToken, // JWT access token used as Bearer token
|
|
86
|
+
userToken: '',
|
|
87
|
+
slackWebhookUrl,
|
|
88
|
+
authMethod: 'jwt'
|
|
89
|
+
};
|
|
50
90
|
}
|
|
51
91
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// JWT auth failed (token refresh error, etc.) - fall back to legacy auth
|
|
94
|
+
// Common errors: session expired, network issues, etc.
|
|
95
|
+
if (env.DEBUG) {
|
|
96
|
+
console.error(`[ConfigLoader] JWT auth failed: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Priority 2: Try to load from encrypted credentials file (legacy API key)
|
|
101
|
+
try {
|
|
102
|
+
const credentialsModule = await tryImportModule('credentials.js');
|
|
103
|
+
|
|
104
|
+
if (credentialsModule && credentialsModule.CredentialManager) {
|
|
105
|
+
const manager = new credentialsModule.CredentialManager();
|
|
55
106
|
const credentials = await manager.load();
|
|
56
|
-
|
|
107
|
+
|
|
57
108
|
if (credentials) {
|
|
58
109
|
return {
|
|
59
110
|
relayApiUrl: credentials.relayApiUrl || credentials.relay_api_url || '',
|
|
60
111
|
relayApiKey: credentials.relayApiKey || credentials.apiKey || credentials.relay_api_key || '',
|
|
61
112
|
userToken: credentials.userToken || credentials.user_token || '',
|
|
62
|
-
slackWebhookUrl: credentials.slackWebhookUrl || credentials.slack_webhook_url || ''
|
|
113
|
+
slackWebhookUrl: credentials.slackWebhookUrl || credentials.slack_webhook_url || '',
|
|
114
|
+
authMethod: 'api-key'
|
|
63
115
|
};
|
|
64
116
|
}
|
|
65
117
|
}
|
|
@@ -67,9 +119,9 @@ export async function loadConfig() {
|
|
|
67
119
|
// Credential manager not available or credentials don't exist, continue to fallback
|
|
68
120
|
}
|
|
69
121
|
|
|
70
|
-
// Priority
|
|
122
|
+
// Priority 3: Try to load from legacy config file
|
|
71
123
|
const configPath = join(homedir(), '.teleportation-config.json');
|
|
72
|
-
|
|
124
|
+
|
|
73
125
|
try {
|
|
74
126
|
const content = await readFile(configPath, 'utf8');
|
|
75
127
|
const config = JSON.parse(content);
|
|
@@ -77,17 +129,19 @@ export async function loadConfig() {
|
|
|
77
129
|
relayApiUrl: config.relay_api_url || '',
|
|
78
130
|
relayApiKey: config.relay_api_key || '',
|
|
79
131
|
userToken: config.user_token || '',
|
|
80
|
-
slackWebhookUrl: config.slack_webhook_url || ''
|
|
132
|
+
slackWebhookUrl: config.slack_webhook_url || '',
|
|
133
|
+
authMethod: 'legacy-config'
|
|
81
134
|
};
|
|
82
135
|
} catch (e) {
|
|
83
136
|
// Config file doesn't exist, continue to fallback
|
|
84
137
|
}
|
|
85
|
-
|
|
86
|
-
// Priority
|
|
138
|
+
|
|
139
|
+
// Priority 4: Fall back to environment variables
|
|
87
140
|
return {
|
|
88
141
|
relayApiUrl: env.RELAY_API_URL || '',
|
|
89
142
|
relayApiKey: env.RELAY_API_KEY || '',
|
|
90
143
|
userToken: env.DETACH_USER_TOKEN || '',
|
|
91
|
-
slackWebhookUrl: env.SLACK_WEBHOOK_URL || ''
|
|
144
|
+
slackWebhookUrl: env.SLACK_WEBHOOK_URL || '',
|
|
145
|
+
authMethod: 'env'
|
|
92
146
|
};
|
|
93
147
|
}
|
|
@@ -84,7 +84,16 @@ const isValidSessionId = (id) => {
|
|
|
84
84
|
|
|
85
85
|
const fetchJson = async (url, opts) => {
|
|
86
86
|
const res = await fetch(url, opts);
|
|
87
|
-
if (!res.ok)
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
let errorBody = '';
|
|
89
|
+
try {
|
|
90
|
+
const errorData = await res.json();
|
|
91
|
+
errorBody = JSON.stringify(errorData);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
errorBody = await res.text();
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`HTTP ${res.status}: ${errorBody}`);
|
|
96
|
+
}
|
|
88
97
|
return res.json();
|
|
89
98
|
};
|
|
90
99
|
|
|
@@ -225,12 +234,14 @@ const fetchJson = async (url, opts) => {
|
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
if (lastUserMessage) {
|
|
237
|
+
const CONTEXT_LIMIT = 2000;
|
|
238
|
+
const truncated = lastUserMessage.length > CONTEXT_LIMIT;
|
|
228
239
|
conversation_context = {
|
|
229
|
-
user_last_message: lastUserMessage.slice(0,
|
|
240
|
+
user_last_message: truncated ? lastUserMessage.slice(0, CONTEXT_LIMIT) + '...' : lastUserMessage,
|
|
230
241
|
claude_reasoning: null, // Phase 2: extract Claude's reasoning
|
|
231
242
|
timestamp: Date.now()
|
|
232
243
|
};
|
|
233
|
-
log(`Extracted conversation context: user_message="${
|
|
244
|
+
log(`Extracted conversation context (truncated: ${truncated}): user_message="${conversation_context.user_last_message.slice(0, 50)}..."`);
|
|
234
245
|
}
|
|
235
246
|
} catch (e) {
|
|
236
247
|
log(`Warning: Failed to extract conversation context: ${e.message}`);
|
|
@@ -238,36 +249,77 @@ const fetchJson = async (url, opts) => {
|
|
|
238
249
|
}
|
|
239
250
|
|
|
240
251
|
// Fallback: Generate tool_use_id if not provided (defensive programming)
|
|
241
|
-
const effective_tool_use_id = tool_use_id || `tool_${
|
|
252
|
+
const effective_tool_use_id = tool_use_id || `tool_${crypto.randomUUID()}`;
|
|
242
253
|
if (!tool_use_id) {
|
|
243
254
|
log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
|
|
244
255
|
}
|
|
245
256
|
|
|
257
|
+
// Fetch session to get owner_id (PRD-0018: Multi-tenancy)
|
|
258
|
+
let owner_id = null;
|
|
259
|
+
try {
|
|
260
|
+
log(`Fetching session ${session_id} to get owner_id for approval`);
|
|
261
|
+
const sessionResponse = await fetch(`${RELAY_API_URL}/api/sessions/${session_id}`, {
|
|
262
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (sessionResponse.ok) {
|
|
266
|
+
const session = await sessionResponse.json();
|
|
267
|
+
owner_id = session.owner_id;
|
|
268
|
+
log(`Retrieved owner_id: ${owner_id} from session`);
|
|
269
|
+
} else {
|
|
270
|
+
log(`Warning: Failed to fetch session (HTTP ${sessionResponse.status}). Creating orphan approval.`);
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {
|
|
273
|
+
log(`Warning: Failed to fetch session owner_id: ${e.message}`);
|
|
274
|
+
// Continue without owner_id - will create orphan approval
|
|
275
|
+
}
|
|
276
|
+
|
|
246
277
|
// Create approval request with metadata and context (PRD-0013)
|
|
247
278
|
let approvalId;
|
|
248
279
|
try {
|
|
249
|
-
const
|
|
280
|
+
const payload = {
|
|
281
|
+
session_id,
|
|
282
|
+
tool_name,
|
|
283
|
+
tool_input,
|
|
284
|
+
meta,
|
|
285
|
+
tool_use_id: effective_tool_use_id,
|
|
286
|
+
owner_id, // PRD-0018: Associate approval with user
|
|
287
|
+
};
|
|
288
|
+
// Only include conversation_context if not null
|
|
289
|
+
// API validation expects it to be an object, not null
|
|
290
|
+
if (conversation_context !== null) {
|
|
291
|
+
payload.conversation_context = conversation_context;
|
|
292
|
+
}
|
|
293
|
+
// Phase 2 fields - not implemented yet, so don't include them
|
|
294
|
+
// if (task_description !== null) payload.task_description = task_description;
|
|
295
|
+
// if (transcript_excerpt !== null) payload.transcript_excerpt = transcript_excerpt;
|
|
296
|
+
|
|
297
|
+
log(`Creating approval with payload: ${JSON.stringify(payload).substring(0, 500)}`);
|
|
298
|
+
const approvalRes = await fetch(`${RELAY_API_URL}/api/approvals`, {
|
|
250
299
|
method: 'POST',
|
|
251
300
|
headers: {
|
|
252
301
|
'Content-Type': 'application/json',
|
|
253
302
|
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
254
303
|
},
|
|
255
|
-
body: JSON.stringify(
|
|
256
|
-
session_id,
|
|
257
|
-
tool_name,
|
|
258
|
-
tool_input,
|
|
259
|
-
meta,
|
|
260
|
-
tool_use_id: effective_tool_use_id,
|
|
261
|
-
conversation_context,
|
|
262
|
-
task_description: null, // Phase 2: infer multi-step tasks
|
|
263
|
-
transcript_excerpt: null // Phase 2: include recent messages
|
|
264
|
-
})
|
|
304
|
+
body: JSON.stringify(payload)
|
|
265
305
|
});
|
|
306
|
+
|
|
307
|
+
const created = await approvalRes.json();
|
|
308
|
+
if (!approvalRes.ok) {
|
|
309
|
+
throw new Error(created.error || `HTTP ${approvalRes.status}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (created.warning === 'orphan_approval') {
|
|
313
|
+
process.stderr.write('⚠️ Warning: Approval created without owner context. It may not be visible in all devices.\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
266
316
|
approvalId = created.id;
|
|
267
|
-
log(`
|
|
317
|
+
log(`Created approval: ${approvalId}`);
|
|
268
318
|
} catch (e) {
|
|
269
|
-
log(`ERROR
|
|
270
|
-
|
|
319
|
+
log(`ERROR: Failed to create approval: ${e.message}`);
|
|
320
|
+
process.stderr.write(`[teleportation] Error: Could not request remote approval: ${e.message}\n`);
|
|
321
|
+
// Fall back to local prompt if approval creation fails
|
|
322
|
+
return process.stdout.write(JSON.stringify({ decision: 'ask', reason: `Remote approval request failed: ${e.message}` }));
|
|
271
323
|
}
|
|
272
324
|
|
|
273
325
|
// If user is PRESENT: return immediately and let Claude Code show its native prompt
|
|
@@ -295,27 +347,36 @@ const fetchJson = async (url, opts) => {
|
|
|
295
347
|
if (status.status === 'allowed') {
|
|
296
348
|
log('Remote approval: ALLOWED');
|
|
297
349
|
// Acknowledge on the fast-path to prevent duplicate daemon execution.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
350
|
+
// PRD-0016: Implement 3-attempt exponential backoff for ACK
|
|
351
|
+
let ackSuccess = false;
|
|
352
|
+
let ackAttempts = 0;
|
|
353
|
+
const MAX_ACK_ATTEMPTS = 3;
|
|
354
|
+
|
|
355
|
+
while (!ackSuccess && ackAttempts < MAX_ACK_ATTEMPTS) {
|
|
356
|
+
ackAttempts++;
|
|
357
|
+
try {
|
|
358
|
+
const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
361
|
+
});
|
|
362
|
+
if (ackRes.ok) {
|
|
363
|
+
ackSuccess = true;
|
|
364
|
+
} else {
|
|
365
|
+
throw new Error(`ACK returned ${ackRes.status}`);
|
|
366
|
+
}
|
|
367
|
+
} catch (e) {
|
|
368
|
+
const delay = Math.pow(2, ackAttempts) * 100;
|
|
369
|
+
log(`Warning: ACK attempt ${ackAttempts} failed: ${e.message}. Retrying in ${delay}ms...`);
|
|
370
|
+
if (ackAttempts < MAX_ACK_ATTEMPTS) {
|
|
371
|
+
await sleep(delay);
|
|
372
|
+
}
|
|
305
373
|
}
|
|
306
|
-
} catch (e) {
|
|
307
|
-
log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
|
|
308
|
-
const out = {
|
|
309
|
-
hookSpecificOutput: {
|
|
310
|
-
hookEventName: 'PermissionRequest',
|
|
311
|
-
permissionDecision: 'deny',
|
|
312
|
-
permissionDecisionReason: '⚠️ Teleportation: Approval received but acknowledgment failed. Request handed to daemon to prevent duplicate execution.'
|
|
313
|
-
},
|
|
314
|
-
suppressOutput: true
|
|
315
|
-
};
|
|
316
|
-
stdout.write(JSON.stringify(out));
|
|
317
|
-
return exit(0);
|
|
318
374
|
}
|
|
375
|
+
|
|
376
|
+
if (!ackSuccess) {
|
|
377
|
+
log(`ERROR: All ${MAX_ACK_ATTEMPTS} ACK attempts failed for ${approvalId}. Allowing locally anyway to prevent total stall, though duplicate execution risk exists.`);
|
|
378
|
+
}
|
|
379
|
+
|
|
319
380
|
const out = {
|
|
320
381
|
hookSpecificOutput: {
|
|
321
382
|
hookEventName: 'PermissionRequest',
|