@kraki/tentacle 0.1.0 → 0.2.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/dist/daemon.js CHANGED
@@ -2,19 +2,16 @@
2
2
  * Daemon process management for Kraki tentacle.
3
3
  *
4
4
  * The daemon runs as a detached child process executing daemon-worker.js.
5
- * Its PID is tracked in ~/.kraki/daemon.pid.
5
+ * Its PID is tracked under the current Kraki home.
6
6
  */
7
- import { spawn, execSync } from 'node:child_process';
7
+ import { spawn } from 'node:child_process';
8
8
  import { closeSync, mkdirSync, openSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
9
  import { join, dirname, resolve } from 'node:path';
11
10
  import { fileURLToPath } from 'node:url';
12
- import { getLogVerbosity, saveDaemonPid, loadDaemonPid, clearDaemonPid, } from './config.js';
13
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ import { getLogsDir, getLogVerbosity, saveDaemonPid, loadDaemonPid, clearDaemonPid, } from './config.js';
14
12
  const STARTUP_GRACE_MS = 1500;
15
- const BOOTSTRAP_LOG_PATH = join(homedir(), '.kraki', 'logs', 'daemon-bootstrap.log');
16
13
  export function getDaemonBootstrapLogPath() {
17
- return BOOTSTRAP_LOG_PATH;
14
+ return join(getLogsDir(), 'daemon-bootstrap.log');
18
15
  }
19
16
  export function resolveDaemonLaunch(currentUrl = import.meta.url) {
20
17
  const moduleDir = dirname(fileURLToPath(currentUrl));
@@ -38,7 +35,7 @@ export function resolveDaemonLaunch(currentUrl = import.meta.url) {
38
35
  workerPath,
39
36
  };
40
37
  }
41
- function waitForDaemonBootstrap(child, timeoutMs = STARTUP_GRACE_MS) {
38
+ function waitForDaemonBootstrap(child, bootstrapLogPath, timeoutMs = STARTUP_GRACE_MS) {
42
39
  return new Promise((resolve, reject) => {
43
40
  const cleanup = () => {
44
41
  clearTimeout(timer);
@@ -47,11 +44,11 @@ function waitForDaemonBootstrap(child, timeoutMs = STARTUP_GRACE_MS) {
47
44
  };
48
45
  const onError = (err) => {
49
46
  cleanup();
50
- reject(new Error(`Kraki failed to start: ${err.message}. Check ${getDaemonBootstrapLogPath()}`));
47
+ reject(new Error(`Kraki failed to start: ${err.message}. Check ${bootstrapLogPath}`));
51
48
  };
52
49
  const onExit = (code, signal) => {
53
50
  cleanup();
54
- reject(new Error(`Kraki exited during startup (code ${code ?? 'null'}, signal ${signal ?? 'none'}). Check ${getDaemonBootstrapLogPath()}`));
51
+ reject(new Error(`Kraki exited during startup (code ${code ?? 'null'}, signal ${signal ?? 'none'}). Check ${bootstrapLogPath}`));
55
52
  };
56
53
  const timer = setTimeout(() => {
57
54
  cleanup();
@@ -94,8 +91,9 @@ export async function startDaemon(config) {
94
91
  stopDaemon();
95
92
  const launch = resolveDaemonLaunch();
96
93
  launch.env.LOG_LEVEL = getLogVerbosity(config) === 'verbose' ? 'debug' : 'info';
97
- mkdirSync(dirname(BOOTSTRAP_LOG_PATH), { recursive: true });
98
- const bootstrapFd = openSync(BOOTSTRAP_LOG_PATH, 'w');
94
+ const bootstrapLogPath = getDaemonBootstrapLogPath();
95
+ mkdirSync(dirname(bootstrapLogPath), { recursive: true });
96
+ const bootstrapFd = openSync(bootstrapLogPath, 'w');
99
97
  const child = spawn(launch.runtime, launch.args, {
100
98
  detached: true,
101
99
  stdio: ['ignore', bootstrapFd, bootstrapFd],
@@ -104,10 +102,10 @@ export async function startDaemon(config) {
104
102
  });
105
103
  closeSync(bootstrapFd);
106
104
  if (!child.pid) {
107
- throw new Error(`Kraki failed to start: no daemon PID returned. Check ${getDaemonBootstrapLogPath()}`);
105
+ throw new Error(`Kraki failed to start: no daemon PID returned. Check ${bootstrapLogPath}`);
108
106
  }
109
107
  try {
110
- await waitForDaemonBootstrap(child);
108
+ await waitForDaemonBootstrap(child, bootstrapLogPath);
111
109
  }
112
110
  catch (err) {
113
111
  try {
@@ -133,31 +131,6 @@ export function stopDaemon() {
133
131
  }
134
132
  clearDaemonPid();
135
133
  }
136
- // Kill any orphaned daemon-worker processes (missed by PID tracking)
137
- killOrphanedWorkers();
138
134
  return pid !== null;
139
135
  }
140
- /**
141
- * Find and kill any daemon-worker processes not tracked by the PID file.
142
- */
143
- function killOrphanedWorkers() {
144
- try {
145
- const output = execSync('ps -eo pid,command', { encoding: 'utf8' });
146
- for (const line of output.split('\n')) {
147
- if (line.includes('daemon-worker') && !line.includes('grep')) {
148
- const pidStr = line.trim().split(/\s+/)[0];
149
- const orphanPid = parseInt(pidStr, 10);
150
- if (orphanPid && orphanPid !== process.pid) {
151
- try {
152
- process.kill(orphanPid, 'SIGTERM');
153
- }
154
- catch { /* already gone */ }
155
- }
156
- }
157
- }
158
- }
159
- catch {
160
- // ps not available — skip orphan cleanup
161
- }
162
- }
163
136
  //# sourceMappingURL=daemon.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"daemon.js","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"daemon.js","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"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"}
package/dist/logger.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Structured logging for Kraki tentacle.
3
3
  *
4
4
  * - Development: pretty-prints to stdout
5
- * - Production: writes to rotating log files under ~/.kraki/logs/
5
+ * - Production: writes to rotating log files under the current Kraki home
6
6
  */
7
7
  import pino from 'pino';
8
8
  export declare function createLogger(name: string): pino.Logger;
package/dist/logger.js CHANGED
@@ -2,13 +2,11 @@
2
2
  * Structured logging for Kraki tentacle.
3
3
  *
4
4
  * - Development: pretty-prints to stdout
5
- * - Production: writes to rotating log files under ~/.kraki/logs/
5
+ * - Production: writes to rotating log files under the current Kraki home
6
6
  */
7
7
  import pino from 'pino';
8
- import { mkdirSync } from 'node:fs';
9
8
  import { join } from 'node:path';
10
- import { homedir } from 'node:os';
11
- const LOG_DIR = join(homedir(), '.kraki', 'logs');
9
+ import { getLogsDir } from './config.js';
12
10
  export function createLogger(name) {
13
11
  const level = process.env.LOG_LEVEL ?? 'info';
14
12
  const isDev = process.env.NODE_ENV !== 'production';
@@ -16,11 +14,11 @@ export function createLogger(name) {
16
14
  return pino({ name, level });
17
15
  }
18
16
  // Production: rotate log files via pino-roll
19
- mkdirSync(LOG_DIR, { recursive: true });
17
+ const logDir = getLogsDir();
20
18
  const transport = pino.transport({
21
19
  target: 'pino-roll',
22
20
  options: {
23
- file: join(LOG_DIR, `${name}.log`),
21
+ file: join(logDir, `${name}.log`),
24
22
  size: '5m',
25
23
  limit: { count: 5 },
26
24
  },
@@ -1 +1 @@
1
- {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;AAElD,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,MAAM,CAAC;IAC9C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC;IAEpD,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC/B,CAAC;IAED,6CAA6C;IAC7C,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAExC,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,MAAM,EAAE,WAAW;QACnB,OAAO,EAAE;YACP,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,GAAG,IAAI,MAAM,CAAC;YAClC,IAAI,EAAE,IAAI;YACV,KAAK,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE;SACpB;KACF,CAAC,CAAC;IAEH,OAAO,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,SAAS,CAAC,CAAC;AAC1C,CAAC"}
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,MAAM,CAAC;IAC9C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC;IAEpD,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC/B,CAAC;IAED,6CAA6C;IAC7C,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAE5B,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,MAAM,EAAE,WAAW;QACnB,OAAO,EAAE;YACP,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,MAAM,CAAC;YACjC,IAAI,EAAE,IAAI;YACV,KAAK,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE;SACpB;KACF,CAAC,CAAC;IAEH,OAAO,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,SAAS,CAAC,CAAC;AAC1C,CAAC"}
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Relay client — connects the tentacle to the head via WebSocket.
2
+ * Relay client — connects the tentacle to the relay via WebSocket.
3
3
  *
4
- * Translates adapter events into protocol messages and sends them to the head.
5
- * Receives consumer actions from the head and routes them to the adapter.
6
- * Handles auth, reconnection, and session lifecycle.
4
+ * Translates adapter events into protocol messages and broadcasts them to apps.
5
+ * Receives unicast consumer actions from apps and routes them to the adapter.
6
+ * Handles auth, E2E encryption, reconnection, and session lifecycle.
7
7
  */
8
8
  import type { DeviceInfo, AuthOkMessage } from '@kraki/protocol';
9
9
  import type { AgentAdapter } from './adapters/base.js';
@@ -14,7 +14,9 @@ export interface RelayClientOptions {
14
14
  relayUrl: string;
15
15
  /** Device info for auth */
16
16
  device: DeviceInfo;
17
- /** GitHub token or channel key */
17
+ /** How the relay should authenticate this device */
18
+ authMethod: 'github' | 'channel-key' | 'open';
19
+ /** Auth token, such as a GitHub token or channel/shared key */
18
20
  token?: string;
19
21
  /** Reconnect delay in ms. Default: 3000 */
20
22
  reconnectDelay?: number;
@@ -32,16 +34,21 @@ export declare class RelayClient {
32
34
  private reconnectAttempts;
33
35
  private reconnectTimer;
34
36
  private authInfo;
35
- private e2eEnabled;
36
37
  /** Cached consumer public keys for E2E encryption */
37
38
  private consumerKeys;
38
39
  /** Messages queued when E2E is enabled but no consumer keys are available yet */
39
40
  private pendingE2eQueue;
40
41
  /** Maps pre-generated sessionId → requestId for concurrent create_session correlation */
41
42
  private pendingRequestIds;
42
- private lastServerPingAt;
43
+ /** Monotonic seq counter — per tentacle, shared across all sessions. */
44
+ private seqCounter;
45
+ /** Tool kinds auto-approved via always_allow from apps */
46
+ private allowedTools;
47
+ /** Prefer challenge auth when the relay already knows this device */
48
+ private preferChallengeAuth;
49
+ private lastActivityAt;
43
50
  private staleCheckTimer;
44
- /** How long without a server ping before we consider the connection stale (ms) */
51
+ /** How long without any activity before we consider the connection stale (ms) */
45
52
  private static readonly STALE_THRESHOLD;
46
53
  /** How often to check for stale connection (ms) */
47
54
  private static readonly STALE_CHECK_INTERVAL;
@@ -69,15 +76,28 @@ export declare class RelayClient {
69
76
  */
70
77
  getAuthInfo(): AuthOkMessage | null;
71
78
  private handleMessage;
79
+ private buildAuthPayload;
72
80
  private handleConsumerMessage;
73
81
  private handleCreateSession;
74
82
  private wireAdapterEvents;
75
83
  private resumeDisconnectedSessions;
76
84
  private send;
77
85
  /**
78
- * Encrypt and send a message to the relay.
86
+ * Encrypt and send a message to the relay as a BroadcastEnvelope.
79
87
  */
80
88
  private sendEncrypted;
89
+ /**
90
+ * Encrypt and send a message to a single device as a UnicastEnvelope.
91
+ */
92
+ private sendUnicastTo;
93
+ /**
94
+ * Broadcast a device_greeting to all connected apps (used on auth_ok).
95
+ */
96
+ private sendGreetingBroadcast;
97
+ /**
98
+ * Send a device_greeting unicast to a newly joined app.
99
+ */
100
+ private sendGreetingTo;
81
101
  /**
82
102
  * Flush queued E2E messages once consumer keys become available.
83
103
  */
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Relay client — connects the tentacle to the head via WebSocket.
2
+ * Relay client — connects the tentacle to the relay via WebSocket.
3
3
  *
4
- * Translates adapter events into protocol messages and sends them to the head.
5
- * Receives consumer actions from the head and routes them to the adapter.
6
- * Handles auth, reconnection, and session lifecycle.
4
+ * Translates adapter events into protocol messages and broadcasts them to apps.
5
+ * Receives unicast consumer actions from apps and routes them to the adapter.
6
+ * Handles auth, E2E encryption, reconnection, and session lifecycle.
7
7
  */
8
8
  import { WebSocket } from 'ws';
9
- import { importPublicKey } from '@kraki/crypto';
9
+ import { importPublicKey, encryptToBlob, decryptFromBlob, signChallenge } from '@kraki/crypto';
10
10
  import { createLogger } from './logger.js';
11
11
  const logger = createLogger('relay-client');
12
12
  export class RelayClient {
@@ -19,18 +19,23 @@ export class RelayClient {
19
19
  reconnectAttempts = 0;
20
20
  reconnectTimer = null;
21
21
  authInfo = null;
22
- e2eEnabled = false;
23
22
  /** Cached consumer public keys for E2E encryption */
24
23
  consumerKeys = new Map();
25
24
  /** Messages queued when E2E is enabled but no consumer keys are available yet */
26
25
  pendingE2eQueue = [];
27
26
  /** Maps pre-generated sessionId → requestId for concurrent create_session correlation */
28
27
  pendingRequestIds = new Map();
29
- // Stale connection detectiontracks server pings to detect sleep/network changes
30
- lastServerPingAt = 0;
28
+ /** Monotonic seq counterper tentacle, shared across all sessions. */
29
+ seqCounter = 0;
30
+ /** Tool kinds auto-approved via always_allow from apps */
31
+ allowedTools = new Set();
32
+ /** Prefer challenge auth when the relay already knows this device */
33
+ preferChallengeAuth = true;
34
+ // Stale connection detection — tracks last incoming message to detect sleep/network changes
35
+ lastActivityAt = 0;
31
36
  staleCheckTimer = null;
32
- /** How long without a server ping before we consider the connection stale (ms) */
33
- static STALE_THRESHOLD = 60_000; // 60s (server pings every 30s, so 2 missed pings)
37
+ /** How long without any activity before we consider the connection stale (ms) */
38
+ static STALE_THRESHOLD = 60_000;
34
39
  /** How often to check for stale connection (ms) */
35
40
  static STALE_CHECK_INTERVAL = 10_000;
36
41
  /** Called when relay state changes */
@@ -58,20 +63,17 @@ export class RelayClient {
58
63
  ws.on('open', () => {
59
64
  this.setState('authenticating');
60
65
  this.reconnectAttempts = 0;
61
- this.lastServerPingAt = Date.now();
66
+ this.lastActivityAt = Date.now();
62
67
  this.startStaleCheck();
63
- const authMsg = {
64
- type: 'auth',
65
- token: this.options.token,
66
- device: {
67
- ...this.options.device,
68
- publicKey: this.keyManager?.getCompactPublicKey(),
69
- },
68
+ const device = {
69
+ ...this.options.device,
70
+ publicKey: this.keyManager?.getCompactPublicKey(),
70
71
  };
71
- ws.send(JSON.stringify(authMsg));
72
+ const auth = this.buildAuthPayload(device);
73
+ ws.send(JSON.stringify({ type: 'auth', auth, device }));
72
74
  });
73
75
  ws.on('message', (data) => {
74
- this.lastServerPingAt = Date.now();
76
+ this.lastActivityAt = Date.now();
75
77
  try {
76
78
  const msg = JSON.parse(data.toString());
77
79
  this.handleMessage(msg);
@@ -89,9 +91,9 @@ export class RelayClient {
89
91
  ws.on('error', () => {
90
92
  // Error triggers close, which handles reconnect
91
93
  });
92
- // Track server pings for stale connection detection
94
+ // Track any incoming frames as activity for stale detection
93
95
  ws.on('ping', () => {
94
- this.lastServerPingAt = Date.now();
96
+ this.lastActivityAt = Date.now();
95
97
  });
96
98
  }
97
99
  /**
@@ -125,43 +127,77 @@ export class RelayClient {
125
127
  handleMessage(msg) {
126
128
  if (msg.type === 'auth_ok') {
127
129
  this.authInfo = msg;
128
- this.e2eEnabled = this.authInfo.e2e && !!this.keyManager;
130
+ this.preferChallengeAuth = true;
129
131
  // Cache consumer device public keys for E2E
130
- if (this.e2eEnabled && this.authInfo.devices) {
132
+ if (this.authInfo.devices) {
131
133
  this.updateConsumerKeys(this.authInfo.devices);
132
134
  }
133
135
  this.setState('connected');
134
136
  this.onAuthenticated?.(this.authInfo);
135
137
  this.resumeDisconnectedSessions();
138
+ this.sendGreetingBroadcast();
136
139
  return;
137
140
  }
138
141
  if (msg.type === 'auth_error') {
139
- this.onFatalError?.(msg.message);
142
+ const authError = msg;
143
+ if (authError.code === 'unknown_device' && this.preferChallengeAuth && this.options.device.deviceId && this.keyManager) {
144
+ logger.warn('Challenge auth rejected for unknown device; retrying with full auth');
145
+ this.preferChallengeAuth = false;
146
+ this.ws?.close();
147
+ return;
148
+ }
149
+ this.onFatalError?.(authError.message);
140
150
  this.disconnect();
141
151
  return;
142
152
  }
153
+ if (msg.type === 'auth_challenge') {
154
+ if (this.keyManager && this.ws && this.ws.readyState === WebSocket.OPEN) {
155
+ try {
156
+ const signature = signChallenge(msg.nonce, this.keyManager.getKeyPair().privateKey);
157
+ this.ws.send(JSON.stringify({ type: 'auth_response', signature }));
158
+ }
159
+ catch (err) {
160
+ logger.error({ err }, 'Failed to sign auth challenge');
161
+ }
162
+ }
163
+ return;
164
+ }
165
+ if (msg.type === 'server_error') {
166
+ logger.error({ message: msg.message, ref: msg.ref }, 'Server error');
167
+ return;
168
+ }
143
169
  if (msg.type === 'pong') {
144
170
  return;
145
171
  }
146
- if (msg.type === 'head_notice') {
147
- // Update consumer keys when devices change
148
- if (this.e2eEnabled && msg.event === 'device_online') {
149
- const dev = msg.data?.device;
150
- const key = dev?.encryptionKey ?? dev?.publicKey;
151
- if (dev && key) {
152
- this.consumerKeys.set(dev.id, key);
172
+ if (msg.type === 'ping') {
173
+ if (this.ws?.readyState === WebSocket.OPEN) {
174
+ this.ws.send(JSON.stringify({ type: 'pong' }));
175
+ }
176
+ return;
177
+ }
178
+ // Device presence notifications — update consumer keys dynamically
179
+ if (msg.type === 'device_joined') {
180
+ const device = msg.device;
181
+ if (device.role === 'app') {
182
+ const key = device.encryptionKey ?? device.publicKey;
183
+ if (key) {
184
+ this.consumerKeys.set(device.id, key);
153
185
  this.flushE2eQueue();
186
+ // Send a greeting unicast so the app learns our capabilities
187
+ this.sendGreetingTo(device.id, key);
154
188
  }
155
189
  }
156
- if (msg.event === 'device_offline' || msg.event === 'device_removed') {
157
- this.consumerKeys.delete(msg.data?.deviceId);
158
- }
159
190
  return;
160
191
  }
161
- // In E2E mode, incoming consumer messages may be encrypted
162
- if (msg.type === 'encrypted' && this.keyManager && this.authInfo) {
192
+ if (msg.type === 'device_left') {
193
+ const deviceId = msg.deviceId;
194
+ this.consumerKeys.delete(deviceId);
195
+ return;
196
+ }
197
+ // Incoming unicast messages from apps — decrypt and handle inner message
198
+ if (msg.type === 'unicast' && this.keyManager && this.authInfo) {
163
199
  try {
164
- const decrypted = this.keyManager.decryptForMe({ iv: msg.iv, ciphertext: msg.ciphertext, tag: msg.tag, keys: msg.keys }, this.authInfo.deviceId);
200
+ const decrypted = decryptFromBlob({ blob: msg.blob, keys: msg.keys }, this.authInfo.deviceId, this.keyManager.getKeyPair().privateKey);
165
201
  const inner = JSON.parse(decrypted);
166
202
  this.handleConsumerMessage(inner);
167
203
  }
@@ -170,9 +206,37 @@ export class RelayClient {
170
206
  }
171
207
  return;
172
208
  }
173
- // Plaintext consumer messages
209
+ // Plaintext consumer messages (fallback when no keyManager)
174
210
  this.handleConsumerMessage(msg);
175
211
  }
212
+ buildAuthPayload(device) {
213
+ if (this.preferChallengeAuth && device.deviceId && this.keyManager) {
214
+ return {
215
+ method: 'challenge',
216
+ deviceId: device.deviceId,
217
+ };
218
+ }
219
+ switch (this.options.authMethod) {
220
+ case 'github':
221
+ if (!this.options.token) {
222
+ throw new Error('GitHub auth requires a token or an already-known device for challenge auth');
223
+ }
224
+ return {
225
+ method: 'github_token',
226
+ token: this.options.token,
227
+ };
228
+ case 'channel-key':
229
+ return {
230
+ method: 'open',
231
+ sharedKey: this.options.token,
232
+ };
233
+ case 'open':
234
+ default:
235
+ return this.options.token
236
+ ? { method: 'open', sharedKey: this.options.token }
237
+ : { method: 'open' };
238
+ }
239
+ }
176
240
  handleConsumerMessage(msg) {
177
241
  // create_session is special — no sessionId yet
178
242
  if (msg.type === 'create_session') {
@@ -203,6 +267,10 @@ export class RelayClient {
203
267
  .catch((err) => logger.error({ err, sessionId }, 'respondToPermission failed'));
204
268
  break;
205
269
  case 'always_allow':
270
+ // Track auto-approved tool kinds for future permissions
271
+ if (msg.payload.toolKind) {
272
+ this.allowedTools.add(msg.payload.toolKind);
273
+ }
206
274
  this.adapter.respondToPermission(sessionId, msg.payload.permissionId, 'always_allow')
207
275
  .catch((err) => logger.error({ err, sessionId }, 'respondToPermission failed'));
208
276
  break;
@@ -218,6 +286,12 @@ export class RelayClient {
218
286
  this.adapter.abortSession(sessionId)
219
287
  .catch((err) => logger.error({ err, sessionId }, 'abortSession failed'));
220
288
  break;
289
+ case 'delete_session':
290
+ this.adapter.killSession(sessionId)
291
+ .catch((err) => logger.error({ err, sessionId }, 'killSession on delete failed'));
292
+ this.sessionManager.deleteSession(sessionId);
293
+ this.send({ type: 'session_deleted', sessionId, payload: {} });
294
+ break;
221
295
  default: {
222
296
  // Handle extended message types (e.g. set_session_mode)
223
297
  const ext = msg;
@@ -302,6 +376,12 @@ export class RelayClient {
302
376
  });
303
377
  };
304
378
  this.adapter.onPermissionRequest = (sessionId, event) => {
379
+ // Auto-approve if tool kind is in the always-allowed set
380
+ if (this.allowedTools.has(event.toolArgs.toolName)) {
381
+ this.adapter.respondToPermission(sessionId, event.id, 'approve')
382
+ .catch((err) => logger.error({ err, sessionId }, 'auto-approve failed'));
383
+ return;
384
+ }
305
385
  this.send({
306
386
  type: 'permission',
307
387
  sessionId,
@@ -408,7 +488,16 @@ export class RelayClient {
408
488
  send(msg) {
409
489
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
410
490
  return;
411
- if (this.e2eEnabled && this.keyManager) {
491
+ // Outbound messages also prove connectivity
492
+ this.lastActivityAt = Date.now();
493
+ // Tentacle assigns seq and timestamp before encryption
494
+ const enriched = msg;
495
+ enriched.seq = ++this.seqCounter;
496
+ enriched.timestamp = new Date().toISOString();
497
+ if (this.authInfo) {
498
+ enriched.deviceId = this.authInfo.deviceId;
499
+ }
500
+ if (this.keyManager) {
412
501
  if (this.consumerKeys.size === 0) {
413
502
  // No consumers online — queue (bounded to prevent memory growth)
414
503
  if (this.pendingE2eQueue.length < 1000) {
@@ -430,7 +519,7 @@ export class RelayClient {
430
519
  }
431
520
  }
432
521
  /**
433
- * Encrypt and send a message to the relay.
522
+ * Encrypt and send a message to the relay as a BroadcastEnvelope.
434
523
  */
435
524
  sendEncrypted(msg) {
436
525
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.keyManager)
@@ -440,26 +529,70 @@ export class RelayClient {
440
529
  recipients.push({ deviceId, publicKey: importPublicKey(compactKey) });
441
530
  }
442
531
  const plaintext = JSON.stringify(msg);
443
- const encrypted = this.keyManager.encryptForRecipients(plaintext, recipients);
532
+ const { blob, keys } = encryptToBlob(plaintext, recipients);
444
533
  const envelope = {
445
- type: 'encrypted',
446
- sessionId: msg.sessionId,
447
- iv: encrypted.iv,
448
- ciphertext: encrypted.ciphertext,
449
- tag: encrypted.tag,
450
- keys: encrypted.keys,
534
+ type: 'broadcast',
535
+ blob,
536
+ keys,
451
537
  };
452
- // Expose agent/model for session_created so the head can register properly
453
- if (msg.type === 'session_created' && msg.payload) {
454
- envelope.agent = msg.payload.agent;
455
- envelope.model = msg.payload.model;
456
- }
457
- // Mark ephemeral messages — head forwards but doesn't persist
458
- if (msg.type === 'agent_message_delta' || msg.type === 'idle') {
459
- envelope.ephemeral = true;
538
+ // Send push notifications for permission and question messages
539
+ if (msg.type === 'permission' || msg.type === 'question') {
540
+ envelope.notify = true;
460
541
  }
461
542
  this.ws.send(JSON.stringify(envelope));
462
543
  }
544
+ /**
545
+ * Encrypt and send a message to a single device as a UnicastEnvelope.
546
+ */
547
+ sendUnicastTo(targetDeviceId, compactPubKey, msg) {
548
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.keyManager)
549
+ return;
550
+ const recipientPubKey = importPublicKey(compactPubKey);
551
+ const plaintext = JSON.stringify(msg);
552
+ const { blob, keys } = encryptToBlob(plaintext, [
553
+ { deviceId: targetDeviceId, publicKey: recipientPubKey },
554
+ ]);
555
+ const envelope = {
556
+ type: 'unicast',
557
+ to: targetDeviceId,
558
+ blob,
559
+ keys,
560
+ };
561
+ this.ws.send(JSON.stringify(envelope));
562
+ }
563
+ /**
564
+ * Broadcast a device_greeting to all connected apps (used on auth_ok).
565
+ */
566
+ sendGreetingBroadcast() {
567
+ this.sendEncrypted({
568
+ type: 'device_greeting',
569
+ deviceId: this.authInfo?.deviceId ?? '',
570
+ seq: ++this.seqCounter,
571
+ timestamp: new Date().toISOString(),
572
+ payload: {
573
+ name: this.options.device.name,
574
+ kind: this.options.device.kind,
575
+ models: this.options.device.capabilities?.models,
576
+ },
577
+ });
578
+ }
579
+ /**
580
+ * Send a device_greeting unicast to a newly joined app.
581
+ */
582
+ sendGreetingTo(targetDeviceId, compactPubKey) {
583
+ const greeting = {
584
+ type: 'device_greeting',
585
+ deviceId: this.authInfo?.deviceId ?? '',
586
+ seq: ++this.seqCounter,
587
+ timestamp: new Date().toISOString(),
588
+ payload: {
589
+ name: this.options.device.name,
590
+ kind: this.options.device.kind,
591
+ models: this.options.device.capabilities?.models,
592
+ },
593
+ };
594
+ this.sendUnicastTo(targetDeviceId, compactPubKey, greeting);
595
+ }
463
596
  /**
464
597
  * Flush queued E2E messages once consumer keys become available.
465
598
  */
@@ -489,9 +622,9 @@ export class RelayClient {
489
622
  this.staleCheckTimer = setInterval(() => {
490
623
  if (this.state !== 'connected' && this.state !== 'authenticating')
491
624
  return;
492
- const elapsed = Date.now() - this.lastServerPingAt;
625
+ const elapsed = Date.now() - this.lastActivityAt;
493
626
  if (elapsed > RelayClient.STALE_THRESHOLD) {
494
- logger.warn(`No server ping for ${Math.round(elapsed / 1000)}s — connection stale, reconnecting`);
627
+ logger.warn(`No activity for ${Math.round(elapsed / 1000)}s — connection stale, reconnecting`);
495
628
  this.ws?.close();
496
629
  }
497
630
  }, RelayClient.STALE_CHECK_INTERVAL);