teleportation-cli 1.1.4 → 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 +70 -27
- package/.claude/hooks/pre_tool_use.mjs +40 -17
- package/.claude/hooks/session-register.mjs +6 -2
- package/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- package/lib/daemon/pid-manager.js +34 -11
- package/lib/daemon/response-classifier.js +15 -1
- package/lib/daemon/{agentic-executor.js → task-executor.js} +380 -122
- package/lib/daemon/teleportation-daemon.js +165 -3
- package/package.json +3 -1
- package/teleportation-cli.cjs +155 -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
|
}
|
|
@@ -234,12 +234,14 @@ const fetchJson = async (url, opts) => {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
if (lastUserMessage) {
|
|
237
|
+
const CONTEXT_LIMIT = 2000;
|
|
238
|
+
const truncated = lastUserMessage.length > CONTEXT_LIMIT;
|
|
237
239
|
conversation_context = {
|
|
238
|
-
user_last_message: lastUserMessage.slice(0,
|
|
240
|
+
user_last_message: truncated ? lastUserMessage.slice(0, CONTEXT_LIMIT) + '...' : lastUserMessage,
|
|
239
241
|
claude_reasoning: null, // Phase 2: extract Claude's reasoning
|
|
240
242
|
timestamp: Date.now()
|
|
241
243
|
};
|
|
242
|
-
log(`Extracted conversation context: user_message="${
|
|
244
|
+
log(`Extracted conversation context (truncated: ${truncated}): user_message="${conversation_context.user_last_message.slice(0, 50)}..."`);
|
|
243
245
|
}
|
|
244
246
|
} catch (e) {
|
|
245
247
|
log(`Warning: Failed to extract conversation context: ${e.message}`);
|
|
@@ -247,11 +249,31 @@ const fetchJson = async (url, opts) => {
|
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
// Fallback: Generate tool_use_id if not provided (defensive programming)
|
|
250
|
-
const effective_tool_use_id = tool_use_id || `tool_${
|
|
252
|
+
const effective_tool_use_id = tool_use_id || `tool_${crypto.randomUUID()}`;
|
|
251
253
|
if (!tool_use_id) {
|
|
252
254
|
log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
|
|
253
255
|
}
|
|
254
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
|
+
|
|
255
277
|
// Create approval request with metadata and context (PRD-0013)
|
|
256
278
|
let approvalId;
|
|
257
279
|
try {
|
|
@@ -261,6 +283,7 @@ const fetchJson = async (url, opts) => {
|
|
|
261
283
|
tool_input,
|
|
262
284
|
meta,
|
|
263
285
|
tool_use_id: effective_tool_use_id,
|
|
286
|
+
owner_id, // PRD-0018: Associate approval with user
|
|
264
287
|
};
|
|
265
288
|
// Only include conversation_context if not null
|
|
266
289
|
// API validation expects it to be an object, not null
|
|
@@ -272,7 +295,7 @@ const fetchJson = async (url, opts) => {
|
|
|
272
295
|
// if (transcript_excerpt !== null) payload.transcript_excerpt = transcript_excerpt;
|
|
273
296
|
|
|
274
297
|
log(`Creating approval with payload: ${JSON.stringify(payload).substring(0, 500)}`);
|
|
275
|
-
const
|
|
298
|
+
const approvalRes = await fetch(`${RELAY_API_URL}/api/approvals`, {
|
|
276
299
|
method: 'POST',
|
|
277
300
|
headers: {
|
|
278
301
|
'Content-Type': 'application/json',
|
|
@@ -280,12 +303,23 @@ const fetchJson = async (url, opts) => {
|
|
|
280
303
|
},
|
|
281
304
|
body: JSON.stringify(payload)
|
|
282
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
|
+
|
|
283
316
|
approvalId = created.id;
|
|
284
|
-
log(`
|
|
317
|
+
log(`Created approval: ${approvalId}`);
|
|
285
318
|
} catch (e) {
|
|
286
|
-
log(`ERROR
|
|
287
|
-
|
|
288
|
-
|
|
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}` }));
|
|
289
323
|
}
|
|
290
324
|
|
|
291
325
|
// If user is PRESENT: return immediately and let Claude Code show its native prompt
|
|
@@ -313,27 +347,36 @@ const fetchJson = async (url, opts) => {
|
|
|
313
347
|
if (status.status === 'allowed') {
|
|
314
348
|
log('Remote approval: ALLOWED');
|
|
315
349
|
// Acknowledge on the fast-path to prevent duplicate daemon execution.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
373
|
}
|
|
324
|
-
} catch (e) {
|
|
325
|
-
log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
|
|
326
|
-
const out = {
|
|
327
|
-
hookSpecificOutput: {
|
|
328
|
-
hookEventName: 'PermissionRequest',
|
|
329
|
-
permissionDecision: 'deny',
|
|
330
|
-
permissionDecisionReason: '⚠️ Teleportation: Approval received but acknowledgment failed. Request handed to daemon to prevent duplicate execution.'
|
|
331
|
-
},
|
|
332
|
-
suppressOutput: true
|
|
333
|
-
};
|
|
334
|
-
stdout.write(JSON.stringify(out));
|
|
335
|
-
return exit(0);
|
|
336
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
|
+
|
|
337
380
|
const out = {
|
|
338
381
|
hookSpecificOutput: {
|
|
339
382
|
hookEventName: 'PermissionRequest',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { stdin, stdout, exit, env } from 'node:process';
|
|
4
|
-
import { appendFileSync } from 'node:fs';
|
|
4
|
+
import fs, { appendFileSync } from 'node:fs';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { dirname, join } from 'path';
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
@@ -68,20 +68,33 @@ const fetchJson = async (url, opts) => {
|
|
|
68
68
|
}
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
log(`ERROR: Invalid JSON: ${e.message}`);
|
|
78
|
-
return exit(0);
|
|
71
|
+
const rawInput = await readStdin();
|
|
72
|
+
let input = {};
|
|
73
|
+
try {
|
|
74
|
+
input = JSON.parse(rawInput || '{}');
|
|
75
|
+
} catch (e) {
|
|
76
|
+
log(`Warning: Failed to parse input JSON: ${e.message}`);
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
let { session_id, tool_name, tool_input } = input || {};
|
|
82
80
|
tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
|
|
83
81
|
let claude_session_id = session_id; // Keep original ID
|
|
84
82
|
|
|
83
|
+
// 1. Recursion Guard (Critical Stability Fix)
|
|
84
|
+
// Prevent infinite hook-triggered tool loops
|
|
85
|
+
// Whitelist safe tools at higher depths to avoid breaking workflows
|
|
86
|
+
const SAFE_TOOLS = ['read', 'glob', 'grep', 'websearch', 'bashoutput', 'ls', 'pwd', 'git status'];
|
|
87
|
+
const RECURSION_DEPTH = parseInt(env.TELEPORTATION_HOOK_DEPTH || '0', 10) || 0;
|
|
88
|
+
|
|
89
|
+
if (RECURSION_DEPTH > 5 && !SAFE_TOOLS.includes(tool_name?.toLowerCase())) {
|
|
90
|
+
log(`[RECURSION] Depth limit reached (${RECURSION_DEPTH}) for tool "${tool_name}", auto-allowing to prevent infinite loop.`);
|
|
91
|
+
process.stdout.write(JSON.stringify({ decision: 'allow', reason: 'Recursion guard depth limit' }));
|
|
92
|
+
return exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Update environment for child processes
|
|
96
|
+
process.env.TELEPORTATION_HOOK_DEPTH = (RECURSION_DEPTH + 1).toString();
|
|
97
|
+
|
|
85
98
|
log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
|
|
86
99
|
|
|
87
100
|
// Check for /away and /back commands (user typing in Claude Code)
|
|
@@ -299,15 +312,19 @@ const fetchJson = async (url, opts) => {
|
|
|
299
312
|
const meta = await getSessionMetadata(cwd);
|
|
300
313
|
log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
|
|
301
314
|
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const
|
|
315
|
+
// Detect model change (PRD-0014)
|
|
316
|
+
// Store model in ~/.teleportation/sessions/ to persist across reboots
|
|
317
|
+
const sessionDir = join(homedir(), '.teleportation', 'sessions');
|
|
318
|
+
if (!fs.existsSync(sessionDir)) {
|
|
319
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
320
|
+
}
|
|
321
|
+
const modelFile = join(sessionDir, `model_${session_id}.txt`);
|
|
305
322
|
let modelChanged = false;
|
|
306
323
|
try {
|
|
307
324
|
const { readFile, writeFile } = await import('fs/promises');
|
|
308
325
|
let lastModel = null;
|
|
309
326
|
try {
|
|
310
|
-
lastModel = (await readFile(
|
|
327
|
+
lastModel = (await readFile(modelFile, 'utf8')).trim();
|
|
311
328
|
} catch (e) {
|
|
312
329
|
// File doesn't exist yet - first tool use
|
|
313
330
|
}
|
|
@@ -344,7 +361,7 @@ const fetchJson = async (url, opts) => {
|
|
|
344
361
|
|
|
345
362
|
// Update last known model
|
|
346
363
|
if (meta.current_model) {
|
|
347
|
-
await writeFile(
|
|
364
|
+
await writeFile(modelFile, meta.current_model, { mode: 0o600 });
|
|
348
365
|
}
|
|
349
366
|
} catch (e) {
|
|
350
367
|
log(`Model change detection error: ${e.message}`);
|
|
@@ -355,11 +372,17 @@ const fetchJson = async (url, opts) => {
|
|
|
355
372
|
try {
|
|
356
373
|
log(`Registering session with relay: ${session_id}`);
|
|
357
374
|
const { ensureSessionRegistered } = await import('./session-register.mjs');
|
|
358
|
-
await ensureSessionRegistered(session_id, cwd, config);
|
|
359
|
-
|
|
375
|
+
const regResult = await ensureSessionRegistered(session_id, cwd, config);
|
|
376
|
+
|
|
377
|
+
if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
|
|
378
|
+
console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
|
|
379
|
+
console.error(' Visit https://app.teleportation.dev/api-keys to claim your key.\n');
|
|
380
|
+
} else {
|
|
381
|
+
log(`Session registered with relay successfully`);
|
|
382
|
+
}
|
|
360
383
|
|
|
361
384
|
// If model changed, update session metadata immediately
|
|
362
|
-
if (modelChanged) {
|
|
385
|
+
if (modelChanged && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
|
|
363
386
|
const { updateSessionMetadata } = await import('./session-register.mjs');
|
|
364
387
|
await updateSessionMetadata(session_id, cwd, config);
|
|
365
388
|
log(`Session metadata updated with new model`);
|
|
@@ -34,7 +34,7 @@ async function loadVersionInfo() {
|
|
|
34
34
|
* @param {string} session_id - Session ID
|
|
35
35
|
* @param {string} cwd - Current working directory
|
|
36
36
|
* @param {object} config - Config object with relayApiUrl and relayApiKey
|
|
37
|
-
* @returns {Promise<boolean>} - True if registered successfully
|
|
37
|
+
* @returns {Promise<boolean|object>} - True if registered successfully, or error object if orphan
|
|
38
38
|
*/
|
|
39
39
|
export async function ensureSessionRegistered(session_id, cwd, config) {
|
|
40
40
|
const RELAY_API_URL = config.relayApiUrl || '';
|
|
@@ -242,6 +242,11 @@ export async function ensureSessionRegistered(session_id, cwd, config) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
return true;
|
|
245
|
+
} else if (response.status === 403) {
|
|
246
|
+
const data = await response.json().catch(() => ({}));
|
|
247
|
+
if (data.error === 'orphan_api_key') {
|
|
248
|
+
return { success: false, error: 'orphan_api_key', message: data.message };
|
|
249
|
+
}
|
|
245
250
|
}
|
|
246
251
|
} catch (e) {
|
|
247
252
|
// Registration failed - this is okay, we'll try again next time
|
|
@@ -330,4 +335,3 @@ export async function updateSessionMetadata(session_id, cwd, config) {
|
|
|
330
335
|
|
|
331
336
|
return false;
|
|
332
337
|
}
|
|
333
|
-
|
package/README.md
CHANGED
|
@@ -199,6 +199,13 @@ Configuration is stored in `~/.teleportation/`:
|
|
|
199
199
|
└── teleportation # CLI symlink
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
## Documentation
|
|
203
|
+
|
|
204
|
+
- `RUNBOOK.md` - What must be running (local/prod) + smoke checks
|
|
205
|
+
- `docs/E2E_TEST_PLAN.md` - End-to-end test plan and minimum regression matrix
|
|
206
|
+
- `DEPLOYMENT_GUIDE.md` - Fly.io / Cloudflare deployment steps
|
|
207
|
+
- `CHANGELOG.md` - Versioned change history
|
|
208
|
+
|
|
202
209
|
Environment variables:
|
|
203
210
|
- `TELEPORTATION_RELAY_URL` - Custom relay server URL
|
|
204
211
|
- `TELEPORTATION_API_KEY` - API key for authentication
|
package/lib/auth/api-key.js
CHANGED
|
@@ -70,6 +70,18 @@ export async function testApiKey(apiKey, relayApiUrl, retryOptions = {}) {
|
|
|
70
70
|
return { valid: true, data };
|
|
71
71
|
} else if (response.status === 401) {
|
|
72
72
|
return { valid: false, error: 'Invalid API key - authentication failed. Please check your API key and try again.' };
|
|
73
|
+
} else if (response.status === 403) {
|
|
74
|
+
// PRD-0018: Handle orphan API keys
|
|
75
|
+
const data = await response.json().catch(() => ({}));
|
|
76
|
+
if (data.error === 'orphan_api_key') {
|
|
77
|
+
return {
|
|
78
|
+
valid: false,
|
|
79
|
+
error: 'Orphan API key - not linked to an account.',
|
|
80
|
+
isOrphan: true,
|
|
81
|
+
message: data.message
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { valid: false, error: `Forbidden: ${data.message || 'You do not have permission to access this resource.'}` };
|
|
73
85
|
} else if (response.status >= 500) {
|
|
74
86
|
return { valid: false, error: `Relay API server error (${response.status}). The server may be temporarily unavailable. Please try again later.` };
|
|
75
87
|
} else {
|