@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/README.md +96 -0
- package/dist/adapters/copilot.js +3 -0
- package/dist/adapters/copilot.js.map +1 -1
- package/dist/cli.js +10 -9
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -4
- package/dist/config.js +36 -24
- package/dist/config.js.map +1 -1
- package/dist/daemon-worker.js +8 -7
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +12 -39
- package/dist/daemon.js.map +1 -1
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +4 -6
- package/dist/logger.js.map +1 -1
- package/dist/relay-client.d.ts +29 -9
- package/dist/relay-client.js +191 -58
- package/dist/relay-client.js.map +1 -1
- package/dist/session-manager.d.ts +4 -0
- package/dist/session-manager.js +10 -1
- package/dist/session-manager.js.map +1 -1
- package/dist/setup.js +5 -2
- package/dist/setup.js.map +1 -1
- package/package.json +12 -3
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
|
|
5
|
+
* Its PID is tracked under the current Kraki home.
|
|
6
6
|
*/
|
|
7
|
-
import { spawn
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
98
|
-
|
|
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 ${
|
|
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
|
package/dist/daemon.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.js","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,KAAK,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
17
|
+
const logDir = getLogsDir();
|
|
20
18
|
const transport = pino.transport({
|
|
21
19
|
target: 'pino-roll',
|
|
22
20
|
options: {
|
|
23
|
-
file: join(
|
|
21
|
+
file: join(logDir, `${name}.log`),
|
|
24
22
|
size: '5m',
|
|
25
23
|
limit: { count: 5 },
|
|
26
24
|
},
|
package/dist/logger.js.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/relay-client.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay client — connects the tentacle to the
|
|
2
|
+
* Relay client — connects the tentacle to the relay via WebSocket.
|
|
3
3
|
*
|
|
4
|
-
* Translates adapter events into protocol messages and
|
|
5
|
-
* Receives consumer actions from
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
*/
|
package/dist/relay-client.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay client — connects the tentacle to the
|
|
2
|
+
* Relay client — connects the tentacle to the relay via WebSocket.
|
|
3
3
|
*
|
|
4
|
-
* Translates adapter events into protocol messages and
|
|
5
|
-
* Receives consumer actions from
|
|
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
|
-
|
|
30
|
-
|
|
28
|
+
/** Monotonic seq counter — per 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
|
|
33
|
-
static STALE_THRESHOLD = 60_000;
|
|
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.
|
|
66
|
+
this.lastActivityAt = Date.now();
|
|
62
67
|
this.startStaleCheck();
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
94
|
+
// Track any incoming frames as activity for stale detection
|
|
93
95
|
ws.on('ping', () => {
|
|
94
|
-
this.
|
|
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.
|
|
130
|
+
this.preferChallengeAuth = true;
|
|
129
131
|
// Cache consumer device public keys for E2E
|
|
130
|
-
if (this.
|
|
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
|
-
|
|
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 === '
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
532
|
+
const { blob, keys } = encryptToBlob(plaintext, recipients);
|
|
444
533
|
const envelope = {
|
|
445
|
-
type: '
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
ciphertext: encrypted.ciphertext,
|
|
449
|
-
tag: encrypted.tag,
|
|
450
|
-
keys: encrypted.keys,
|
|
534
|
+
type: 'broadcast',
|
|
535
|
+
blob,
|
|
536
|
+
keys,
|
|
451
537
|
};
|
|
452
|
-
//
|
|
453
|
-
if (msg.type === '
|
|
454
|
-
envelope.
|
|
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.
|
|
625
|
+
const elapsed = Date.now() - this.lastActivityAt;
|
|
493
626
|
if (elapsed > RelayClient.STALE_THRESHOLD) {
|
|
494
|
-
logger.warn(`No
|
|
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);
|