teleportation-cli 1.0.0 → 1.0.2

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.
@@ -4,26 +4,33 @@
4
4
  * Teleportation Daemon
5
5
  *
6
6
  * Persistent background service that:
7
- * - Polls relay API for approved tool requests
8
- * - Spawns child Claude Code processes via `claude --resume <session_id> -p "<prompt>"`
7
+ * - Polls relay API for approved tool requests and inbox messages
8
+ * - Routes all remote messages to Claude Code via the machine coder interface
9
9
  * - Executes approved tools asynchronously when user is away
10
10
  * - Maintains session registry and approval queue
11
11
  * - Provides HTTP server for hook communication
12
12
  *
13
13
  * SECURITY ARCHITECTURE:
14
14
  * ----------------------
15
- * This daemon executes shell commands via spawn('sh', ['-c', command]) which bypasses
16
- * Claude CLI's built-in security controls. This is an intentional architectural decision
17
- * to enable remote approval/execution, but requires defense-in-depth measures:
15
+ * Remote message execution is delegated to Claude Code's security model:
18
16
  *
19
- * 1. COMMAND WHITELIST: Only pre-approved command prefixes are allowed (see ALLOWED_COMMAND_PREFIXES)
20
- * 2. SHELL INJECTION BLOCKING: Commands containing metacharacters (;|&`$() etc.) are rejected
21
- * 3. APPROVAL FLOW: All commands must be explicitly approved via the relay API
22
- * 4. DEVELOPMENT BYPASS: ALLOW_ALL_COMMANDS requires TELEPORTATION_DANGER_ZONE confirmation
17
+ * 1. CLAUDE CODE PERMISSIONS: All tool calls go through Claude Code's permission system
18
+ * - When hooks are active, tool approvals are routed via Teleportation Relay
19
+ * - When using --dangerously-skip-permissions, Claude auto-approves tool calls
20
+ * 2. SESSION VALIDATION: Each execution validates the session is still active via Relay API
21
+ * 3. APPROVAL CONTEXT: All executions require an approvalContext (inbox_message, approval_queue)
22
+ * for audit trail
23
+ * 4. MACHINE CODER INTERFACE: Unified interface supports Claude Code, Gemini CLI, and future backends
23
24
  *
24
- * For production deployments requiring Claude CLI integration, consider:
25
- * - Using the CLAUDE_CLI_PATH environment variable to specify a custom Claude CLI wrapper
26
- * - Implementing additional command validation in a proxy layer
25
+ * LEGACY SHELL EXECUTION:
26
+ * The daemon also supports direct shell command execution via executeCommand() for specific
27
+ * use cases like approval queue processing. This path uses:
28
+ * - COMMAND WHITELIST: Only pre-approved command prefixes (see ALLOWED_COMMAND_PREFIXES)
29
+ * - SHELL INJECTION BLOCKING: Commands with metacharacters (;|&`$() etc.) are rejected
30
+ *
31
+ * For production deployments, consider:
32
+ * - Using --dangerously-skip-permissions only in sandboxed environments
33
+ * - Implementing rate limiting on the relay API
27
34
  * - Enabling audit logging by setting DEBUG=1
28
35
  */
29
36
 
@@ -54,6 +61,11 @@ const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude'; // Configurable Clau
54
61
  const ALLOW_ALL_COMMANDS = process.env.TELEPORTATION_DAEMON_ALLOW_ALL_COMMANDS === 'true';
55
62
  const HEARTBEAT_INTERVAL_MS = parseInt(process.env.DAEMON_HEARTBEAT_INTERVAL_MS || '30000', 10); // 30 sec default
56
63
 
64
+ // Message routing configuration
65
+ // REQUIRE_COMMAND_WHITELIST: If true, use legacy shell execution with command whitelist
66
+ // Default: false (all messages route to Claude Code)
67
+ const REQUIRE_COMMAND_WHITELIST = process.env.TELEPORTATION_REQUIRE_COMMAND_WHITELIST === 'true';
68
+
57
69
  // Machine coder configuration
58
70
  // PREFERRED_CODER: 'claude-code' | 'gemini-cli' | 'auto' (default: auto)
59
71
  // 'auto' will use Claude Code if available, otherwise Gemini CLI
@@ -68,6 +80,10 @@ const ROUTER_ENABLED = process.env.TELEPORTATION_ROUTER_ENABLED !== 'false' && !
68
80
  const ROUTER_VERBOSE = process.env.TELEPORTATION_ROUTER_VERBOSE === 'true';
69
81
  const ROUTER_MAX_ESCALATIONS = parseInt(process.env.TELEPORTATION_ROUTER_MAX_ESCALATIONS || '2', 10);
70
82
 
83
+ // Test helper: allows mocking executeWithMachineCoder in tests
84
+ // In production, this just holds null and the real function is called
85
+ const _executeWithMachineCoderRef = { fn: null };
86
+
71
87
  // Lazy-initialized router instance
72
88
  let _router = null;
73
89
  function getRouter() {
@@ -184,8 +200,17 @@ const MAX_EXECUTIONS = 1000; // Maximum executions to keep in memory (LRU cache)
184
200
  // Maximum output size to prevent memory issues
185
201
  const MAX_OUTPUT_SIZE = 100_000; // 100KB
186
202
 
187
- // Command whitelist for inbox execution (security: prevents arbitrary command execution)
188
- // Only commands starting with these prefixes are allowed
203
+ // LEGACY SHELL EXECUTION:
204
+ // These constants and functions are kept for:
205
+ // 1. Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
206
+ // 2. Direct shell execution via approval queue processing
207
+ // 3. Backward compatibility with existing tests
208
+ //
209
+ // For inbox messages, all prompts are now routed to Claude Code by default.
210
+ // See handleInboxMessage() for the current flow.
211
+
212
+ // Command whitelist for legacy shell execution (security: prevents arbitrary command execution)
213
+ // Only commands starting with these prefixes are allowed when TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true
189
214
  const ALLOWED_COMMAND_PREFIXES = [
190
215
  'git ', // Git operations
191
216
  'npm ', // NPM package management
@@ -253,7 +278,15 @@ function sanitizeCommand(command) {
253
278
  }
254
279
 
255
280
  /**
256
- * Check if a command is allowed based on the whitelist
281
+ * LEGACY: Check if a command is allowed based on the whitelist
282
+ *
283
+ * This function is kept for:
284
+ * - Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
285
+ * - executeCommand() which is used by approval queue processing
286
+ * - Backward compatibility with existing tests
287
+ *
288
+ * For inbox messages, all prompts are now routed to Claude Code by default.
289
+ *
257
290
  * @param {string} command - The command to validate
258
291
  * @returns {{ allowed: boolean, reason?: string }}
259
292
  */
@@ -685,9 +718,17 @@ async function checkIdleTimeout() {
685
718
  }
686
719
 
687
720
  /**
688
- * Execute a shell command in the session's working directory
721
+ * LEGACY: Execute a shell command in the session's working directory
689
722
  * Returns { success, stdout, stderr, exit_code, error }
690
723
  *
724
+ * This function is kept for:
725
+ * - Approval queue processing (executing approved tool calls)
726
+ * - Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
727
+ * - Backward compatibility with existing tests
728
+ *
729
+ * For inbox messages, all prompts are now routed to Claude Code by default.
730
+ * See handleInboxMessage() and executeWithMachineCoder() for the current flow.
731
+ *
691
732
  * Security: Commands must be in the ALLOWED_COMMAND_PREFIXES whitelist
692
733
  */
693
734
  async function executeCommand(session_id, command) {
@@ -755,7 +796,18 @@ async function handleInboxMessage(session_id, message) {
755
796
  // For command messages, execute the command and post result back to the main agent inbox
756
797
  if (meta.type === 'command') {
757
798
  const replyAgentId = meta.reply_agent_id || 'main';
758
- const commandText = message.text || '';
799
+ const commandText = (message.text || '').trim();
800
+
801
+ // Validate non-empty message before processing
802
+ if (!commandText) {
803
+ console.warn(`[daemon] Received empty message ${message.id}, skipping execution`);
804
+ // Still acknowledge the message to prevent re-delivery
805
+ await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
806
+ method: 'POST',
807
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
808
+ });
809
+ return;
810
+ }
759
811
 
760
812
  // Invalidate pending approvals BEFORE executing new command
761
813
  // This prevents race conditions where stale approvals could be acted upon
@@ -783,39 +835,57 @@ async function handleInboxMessage(session_id, message) {
783
835
  // Continue with execution - this is not critical
784
836
  }
785
837
 
786
- // Hybrid Execution Logic:
787
- // 1. Check if it's a valid whitelisted shell command
788
- const validation = isCommandAllowed(commandText);
838
+ // Route message to Claude Code (default) or use legacy shell execution (with feature flag)
789
839
  let executionResult;
790
- let executionType = 'shell';
791
-
792
- if (validation.allowed) {
793
- // Fast path: Execute shell command directly
794
- console.log(`[daemon] Executing direct shell command: ${commandText}`);
795
- executionResult = await executeCommand(session_id, commandText);
840
+ let executionType;
841
+
842
+ // Feature flag: TELEPORTATION_REQUIRE_COMMAND_WHITELIST enables legacy whitelist-based execution
843
+ if (REQUIRE_COMMAND_WHITELIST) {
844
+ // Legacy mode: check if command is in whitelist, execute via shell if allowed
845
+ const validation = isCommandAllowed(commandText);
846
+ if (validation.allowed) {
847
+ executionType = 'shell';
848
+ console.log(`[daemon] Legacy shell execution: ${commandText.substring(0, 100)}`);
849
+ executionResult = await executeCommand(session_id, commandText);
850
+ } else {
851
+ // Command not in whitelist - reject in legacy mode
852
+ console.log(`[daemon] Command rejected by whitelist: ${commandText.substring(0, 100)}`);
853
+ executionResult = {
854
+ success: false,
855
+ exit_code: -1,
856
+ stdout: '',
857
+ stderr: '',
858
+ error: validation.reason
859
+ };
860
+ executionType = 'rejected';
861
+ }
796
862
  } else {
797
- // Fallback: Natural language prompt via machine coder (Claude/Gemini/etc.)
798
- console.log(`[daemon] Command not in whitelist, handing off to machine coder: ${commandText}`);
863
+ // Default mode: route all messages to Claude Code
799
864
  executionType = 'agent';
800
-
801
- // Stream output callback for inbox message execution
865
+ console.log(`[daemon] Routing message to Claude Code: ${commandText.substring(0, 100)}`);
866
+
867
+ // Stream output callback for message execution
802
868
  const onOutput = createStreamingCallback(session_id, message.id, {
803
869
  message_id: message.id
804
870
  });
805
-
871
+
806
872
  // Use the unified machine coder interface
807
873
  // This supports Claude Code, Gemini CLI, and future backends
874
+ // Note: _executeWithMachineCoderRef.fn is used for test mocking
808
875
  try {
809
- executionResult = await executeWithMachineCoder(session_id, commandText, {
876
+ const executeFn = _executeWithMachineCoderRef.fn || executeWithMachineCoder;
877
+ executionResult = await executeFn(session_id, commandText, {
810
878
  onOutput,
811
879
  approvalContext: { type: 'inbox_message', id: message.id },
812
880
  });
813
-
881
+
814
882
  // Track which coder was used
815
883
  if (executionResult.coder_used) {
816
884
  console.log(`[daemon] Executed via ${executionResult.coder_used}`);
817
885
  }
818
886
  } catch (error) {
887
+ const preview = commandText.substring(0, 50);
888
+ console.error(`[daemon] Machine coder execution failed for "${preview}...":`, error.message);
819
889
  executionResult = {
820
890
  success: false,
821
891
  exit_code: -1,
@@ -838,13 +908,17 @@ async function handleInboxMessage(session_id, message) {
838
908
  );
839
909
 
840
910
  // Build result message with execution details
911
+ // Note: executeWithMachineCoder returns 'output', legacy executeCommand returns 'stdout'
912
+ // Using ?? to handle empty string correctly (|| would skip empty string)
841
913
  let resultText = '';
842
914
  if (executionResult.success) {
843
- const header = executionType === 'agent' ? 'Claude executed your request:\n\n' : 'Command executed successfully:\n\n';
844
- resultText = `${header}${executionResult.stdout}`;
915
+ const resultOutput = executionResult.output ?? executionResult.stdout ?? '';
916
+ resultText = `Claude completed your request:\n\n${resultOutput}`;
845
917
  } else {
846
- const header = executionType === 'agent' ? 'Claude failed to execute request:\n\n' : `Command failed with exit code ${executionResult.exit_code}:\n\n`;
847
- resultText = `${header}Error: ${executionResult.error}\n\nStderr:\n${executionResult.stderr}`;
918
+ resultText = `Claude failed to complete request:\n\nError: ${executionResult.error}`;
919
+ if (executionResult.stderr) {
920
+ resultText += `\n\nDetails:\n${executionResult.stderr}`;
921
+ }
848
922
  }
849
923
 
850
924
  try {
@@ -1525,11 +1599,19 @@ async function executeWithMachineCoder(session_id, prompt, options = {}) {
1525
1599
 
1526
1600
  // Execute via the coder
1527
1601
  const startedAt = Date.now();
1528
-
1602
+
1529
1603
  try {
1530
- // For Claude Code with resume support, use resume if we have a claude_session_id
1604
+ // Always use execute() for remote inbox messages since our claude_session_id
1605
+ // is actually the teleportation UUID, not a real Claude Code session ID.
1606
+ // Passing UUID to claude --resume causes it to fail.
1607
+ // Use UUID v4 regex pattern for robust detection instead of fragile hyphen check.
1608
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1531
1609
  let result;
1532
- if (coder.name === 'claude-code' && session.claude_session_id) {
1610
+ const isValidClaudeSession = session.claude_session_id &&
1611
+ session.claude_session_id !== session.session_id &&
1612
+ !UUID_V4_PATTERN.test(session.claude_session_id);
1613
+
1614
+ if (coder.name === 'claude-code' && isValidClaudeSession) {
1533
1615
  result = await coder.resume(session.claude_session_id, prompt, execOptions);
1534
1616
  } else {
1535
1617
  result = await coder.execute(execOptions);
@@ -1975,7 +2057,14 @@ const __test = {
1975
2057
  _setLastSessionActivityAt: (value) => {
1976
2058
  lastSessionActivityAt = value;
1977
2059
  },
1978
- _getSessionsMap: () => sessions
2060
+ _getSessionsMap: () => sessions,
2061
+ // Test helper: inject mock for executeWithMachineCoder
2062
+ _setExecuteWithMachineCoder: (mockFn) => {
2063
+ _executeWithMachineCoderRef.fn = mockFn;
2064
+ },
2065
+ _resetExecuteWithMachineCoder: () => {
2066
+ _executeWithMachineCoderRef.fn = null;
2067
+ }
1979
2068
  };
1980
2069
 
1981
2070
  export {
@@ -2000,6 +2089,7 @@ export {
2000
2089
  sendStreamingMessage,
2001
2090
  MAX_EXECUTIONS,
2002
2091
  PREFERRED_CODER, // New: machine coder preference
2092
+ REQUIRE_COMMAND_WHITELIST, // Feature flag for legacy whitelist mode
2003
2093
  // Router integration for cost-aware LLM calls
2004
2094
  routedCompletion,
2005
2095
  classifyTaskTier,
@@ -40,7 +40,7 @@ function getProjectSettings() {
40
40
 
41
41
  // Claude Code reads settings from ~/.claude/settings.json (user-level)
42
42
  function getUserSettings() {
43
- return join(HOME_DIR, '.claude', 'settings.json');
43
+ return process.env.TEST_USER_SETTINGS || join(HOME_DIR, '.claude', 'settings.json');
44
44
  }
45
45
 
46
46
  /**
@@ -133,7 +133,11 @@ export async function verifyHooks(sourceHooksDir) {
133
133
  try {
134
134
  await stat(hookPath);
135
135
  // Set executable permissions (755)
136
- await chmod(hookPath, 0o755);
136
+ try {
137
+ await chmod(hookPath, 0o755);
138
+ } catch (_) {
139
+ // Ignore chmod errors
140
+ }
137
141
  found.push(hook);
138
142
  } catch (e) {
139
143
  if (e.code === 'ENOENT') {
@@ -154,6 +158,14 @@ export async function installHooks(sourceHooksDir) {
154
158
  const destHooksDir = getProjectHooksDir();
155
159
  const copyFailed = [];
156
160
 
161
+ try {
162
+ await mkdir(destHooksDir, { recursive: true });
163
+ } catch (e) {
164
+ if (e.code !== 'EEXIST') {
165
+ throw e;
166
+ }
167
+ }
168
+
157
169
  // Copy hooks from source to destination if they're different
158
170
  if (sourceHooksDir !== destHooksDir) {
159
171
  for (const hook of result.found) {
@@ -298,7 +310,8 @@ export async function installLibFiles() {
298
310
  const libFiles = [
299
311
  { subdir: 'auth', files: ['credentials.js', 'api-key.js'] },
300
312
  { subdir: 'session', files: ['metadata.js'] },
301
- { subdir: 'config', files: ['manager.js'] }
313
+ { subdir: 'config', files: ['manager.js'] },
314
+ { subdir: 'daemon', files: ['lifecycle.js', 'pid-manager.js'] } // Required for heartbeat daemon auto-start
302
315
  ];
303
316
 
304
317
  const installed = [];
@@ -425,11 +438,13 @@ export async function createSettings() {
425
438
 
426
439
  // IMPORTANT: Also write to user-level settings (~/.claude/settings.json)
427
440
  // Claude Code reads from this location, not from the project directory
428
- const userSettings = getUserSettings();
429
- await mkdir(dirname(userSettings), { recursive: true });
430
- await writeFile(userSettings, JSON.stringify(settings, null, 2));
441
+ if (!process.env.TEST_SETTINGS) {
442
+ const userSettings = getUserSettings();
443
+ await mkdir(dirname(userSettings), { recursive: true });
444
+ await writeFile(userSettings, JSON.stringify(settings, null, 2));
445
+ }
431
446
 
432
- return { projectSettings, userSettings };
447
+ return projectSettings;
433
448
  }
434
449
 
435
450
  /**