@askalf/dario 3.26.0 → 3.28.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/cli.js +78 -1
- package/dist/mcp/protocol.d.ts +112 -0
- package/dist/mcp/protocol.js +135 -0
- package/dist/mcp/server.d.ts +29 -0
- package/dist/mcp/server.js +63 -0
- package/dist/mcp/tools.d.ts +69 -0
- package/dist/mcp/tools.js +205 -0
- package/dist/proxy.d.ts +4 -0
- package/dist/proxy.js +71 -15
- package/dist/session-rotation.d.ts +153 -0
- package/dist/session-rotation.js +214 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -220,7 +220,16 @@ async function proxy() {
|
|
|
220
220
|
// read-to-completion pattern. Costs tokens (the response is fully
|
|
221
221
|
// generated even if nobody reads it), so it's opt-in.
|
|
222
222
|
const drainOnClose = args.includes('--drain-on-close') || undefined;
|
|
223
|
-
|
|
223
|
+
// --session-* knobs (v3.28, direction #1). Control the single-account
|
|
224
|
+
// session-id lifecycle: idle threshold, jitter on that threshold, hard
|
|
225
|
+
// max-age, and whether to give each upstream client its own session.
|
|
226
|
+
// All defaults preserve v3.27 behaviour exactly. Logic lives in
|
|
227
|
+
// src/session-rotation.ts; these flags just feed resolveSessionRotationConfig.
|
|
228
|
+
const sessionIdleRotateMs = parsePositiveIntFlag('--session-idle-rotate=');
|
|
229
|
+
const sessionRotateJitterMs = parsePositiveIntFlag('--session-rotate-jitter=');
|
|
230
|
+
const sessionMaxAgeMs = parsePositiveIntFlag('--session-max-age=');
|
|
231
|
+
const sessionPerClient = args.includes('--session-per-client') || undefined;
|
|
232
|
+
await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient });
|
|
224
233
|
}
|
|
225
234
|
function parsePositiveIntFlag(prefix) {
|
|
226
235
|
const found = args.find(a => a.startsWith(prefix));
|
|
@@ -461,6 +470,14 @@ async function help() {
|
|
|
461
470
|
operations to a named sub-agent (v3.26)
|
|
462
471
|
dario subagent remove Remove the registered sub-agent file
|
|
463
472
|
dario subagent status Show whether the sub-agent is installed
|
|
473
|
+
dario mcp Run dario as an MCP (Model Context Protocol)
|
|
474
|
+
server on stdio. Exposes read-only tools
|
|
475
|
+
(doctor, status, accounts_list, backends_list,
|
|
476
|
+
subagent_status, fingerprint_info) so MCP
|
|
477
|
+
clients (Claude Desktop, IDEs, etc.) can
|
|
478
|
+
inspect dario's state. No destructive ops
|
|
479
|
+
are exposed — mutations still require the
|
|
480
|
+
CLI. (v3.27)
|
|
464
481
|
dario doctor Print a health report: dario / Node / CC /
|
|
465
482
|
template / drift / OAuth / pool / backends
|
|
466
483
|
|
|
@@ -505,6 +522,26 @@ async function help() {
|
|
|
505
522
|
is fully generated even if nobody reads
|
|
506
523
|
it) for fingerprint fidelity. Bounded by
|
|
507
524
|
the 5-minute upstream timeout. (v3.25)
|
|
525
|
+
--session-idle-rotate=MS Idle ms before the single-account session
|
|
526
|
+
id rotates (default: 900000 = 15 min).
|
|
527
|
+
Real CC rotates once per conversation, not
|
|
528
|
+
per call; the default matches its observed
|
|
529
|
+
cadence. Pool mode is unaffected. (v3.28)
|
|
530
|
+
--session-rotate-jitter=MS
|
|
531
|
+
Max additional uniform-random jitter (ms)
|
|
532
|
+
added to the idle threshold, sampled once
|
|
533
|
+
per session at creation. Default: 0 (off).
|
|
534
|
+
Hides the exact threshold from long-run
|
|
535
|
+
rotation statistics. (v3.28)
|
|
536
|
+
--session-max-age=MS Hard cap on a session id's lifetime
|
|
537
|
+
regardless of activity. Default: off. Set
|
|
538
|
+
for always-on pipelines where an idle
|
|
539
|
+
window would never trigger. (v3.28)
|
|
540
|
+
--session-per-client Give each upstream client (keyed by
|
|
541
|
+
x-session-id / x-client-session-id
|
|
542
|
+
header) its own rotated session id.
|
|
543
|
+
Default: off (single session across all
|
|
544
|
+
clients, v3.27 behaviour). (v3.28)
|
|
508
545
|
--port=PORT Port to listen on (default: 3456)
|
|
509
546
|
--host=ADDRESS Address to bind to (default: 127.0.0.1)
|
|
510
547
|
Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
|
|
@@ -647,6 +684,45 @@ async function subagent() {
|
|
|
647
684
|
console.error('');
|
|
648
685
|
process.exit(1);
|
|
649
686
|
}
|
|
687
|
+
async function mcp() {
|
|
688
|
+
// MCP-over-stdio: protocol frames on stdout ONLY. Any stray console.log
|
|
689
|
+
// from downstream modules (doctor / oauth / accounts helpers) would
|
|
690
|
+
// corrupt the frame stream, so redirect them to stderr defensively for
|
|
691
|
+
// the lifetime of the server. Restored in the finally block for tests /
|
|
692
|
+
// embedders that re-use the process after `dario mcp`.
|
|
693
|
+
const origLog = console.log;
|
|
694
|
+
const origInfo = console.info;
|
|
695
|
+
console.log = (...a) => console.error(...a);
|
|
696
|
+
console.info = (...a) => console.error(...a);
|
|
697
|
+
try {
|
|
698
|
+
const [{ buildDefaultToolRegistry }, { runMcpServer }] = await Promise.all([
|
|
699
|
+
import('./mcp/tools.js'),
|
|
700
|
+
import('./mcp/server.js'),
|
|
701
|
+
]);
|
|
702
|
+
const { readFile } = await import('node:fs/promises');
|
|
703
|
+
const { fileURLToPath } = await import('node:url');
|
|
704
|
+
const here = join(fileURLToPath(import.meta.url), '..', '..');
|
|
705
|
+
let pkgVersion = 'unknown';
|
|
706
|
+
try {
|
|
707
|
+
const pkg = JSON.parse(await readFile(join(here, 'package.json'), 'utf-8'));
|
|
708
|
+
if (typeof pkg.version === 'string')
|
|
709
|
+
pkgVersion = pkg.version;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// package.json missing or malformed — fall back to 'unknown' but let
|
|
713
|
+
// the server keep running so tool responses are still usable.
|
|
714
|
+
}
|
|
715
|
+
const tools = await buildDefaultToolRegistry();
|
|
716
|
+
await runMcpServer({
|
|
717
|
+
tools,
|
|
718
|
+
server: { name: 'dario', version: pkgVersion },
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
console.log = origLog;
|
|
723
|
+
console.info = origInfo;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
650
726
|
async function doctor() {
|
|
651
727
|
const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
|
|
652
728
|
console.log('');
|
|
@@ -686,6 +762,7 @@ const commands = {
|
|
|
686
762
|
backend,
|
|
687
763
|
shim,
|
|
688
764
|
subagent,
|
|
765
|
+
mcp,
|
|
689
766
|
doctor,
|
|
690
767
|
help,
|
|
691
768
|
version,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP (Model Context Protocol) implementation — JSON-RPC 2.0 + the
|
|
3
|
+
* subset of methods dario needs to expose as an MCP server (direction #4,
|
|
4
|
+
* v3.27). Zero-runtime-deps policy means we don't pull in
|
|
5
|
+
* `@modelcontextprotocol/sdk`; the protocol surface we need is small enough
|
|
6
|
+
* to hand-roll correctly.
|
|
7
|
+
*
|
|
8
|
+
* MCP over stdio uses newline-delimited JSON — each line on stdin is one
|
|
9
|
+
* complete JSON-RPC message; each line on stdout is one complete response
|
|
10
|
+
* or notification. That's what `parseLine` and `encodeMessage` handle.
|
|
11
|
+
*
|
|
12
|
+
* We implement three methods from the MCP spec:
|
|
13
|
+
* initialize — handshake, server replies with capabilities.
|
|
14
|
+
* tools/list — enumerate exposed tools + their JSON schemas.
|
|
15
|
+
* tools/call — invoke a named tool with structured arguments.
|
|
16
|
+
*
|
|
17
|
+
* Plus the standard JSON-RPC error shapes. Notifications (no-`id` messages)
|
|
18
|
+
* are accepted and, for the only one we care about (`notifications/initialized`),
|
|
19
|
+
* acknowledged silently.
|
|
20
|
+
*
|
|
21
|
+
* Kept pure on purpose so the tests can exercise every branch without any
|
|
22
|
+
* stdio — `handleMessage` takes a raw JSON-RPC payload and a tool registry
|
|
23
|
+
* and returns either a response string or null (for notifications).
|
|
24
|
+
*/
|
|
25
|
+
/** MCP spec revisions ship with different wire quirks; pin the one we test against. */
|
|
26
|
+
export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
27
|
+
/** JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification). */
|
|
28
|
+
export declare const JSONRPC_ERRORS: {
|
|
29
|
+
readonly PARSE_ERROR: -32700;
|
|
30
|
+
readonly INVALID_REQUEST: -32600;
|
|
31
|
+
readonly METHOD_NOT_FOUND: -32601;
|
|
32
|
+
readonly INVALID_PARAMS: -32602;
|
|
33
|
+
readonly INTERNAL_ERROR: -32603;
|
|
34
|
+
};
|
|
35
|
+
export interface JsonRpcRequest {
|
|
36
|
+
jsonrpc: '2.0';
|
|
37
|
+
id: string | number;
|
|
38
|
+
method: string;
|
|
39
|
+
params?: unknown;
|
|
40
|
+
}
|
|
41
|
+
export interface JsonRpcNotification {
|
|
42
|
+
jsonrpc: '2.0';
|
|
43
|
+
method: string;
|
|
44
|
+
params?: unknown;
|
|
45
|
+
}
|
|
46
|
+
export interface JsonRpcResponse {
|
|
47
|
+
jsonrpc: '2.0';
|
|
48
|
+
id: string | number | null;
|
|
49
|
+
result?: unknown;
|
|
50
|
+
error?: {
|
|
51
|
+
code: number;
|
|
52
|
+
message: string;
|
|
53
|
+
data?: unknown;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification;
|
|
57
|
+
/** Shape of an MCP tool's content block — we only emit text content. */
|
|
58
|
+
export interface McpToolContent {
|
|
59
|
+
type: 'text';
|
|
60
|
+
text: string;
|
|
61
|
+
}
|
|
62
|
+
export interface McpToolResult {
|
|
63
|
+
content: McpToolContent[];
|
|
64
|
+
/** If a tool ran but the operation itself failed (e.g. upstream error), set isError: true. */
|
|
65
|
+
isError?: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface McpTool {
|
|
68
|
+
name: string;
|
|
69
|
+
description: string;
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object';
|
|
72
|
+
properties: Record<string, unknown>;
|
|
73
|
+
required?: string[];
|
|
74
|
+
};
|
|
75
|
+
handler: (args: Record<string, unknown>) => Promise<McpToolResult>;
|
|
76
|
+
}
|
|
77
|
+
/** Server identity the client sees on `initialize`. */
|
|
78
|
+
export interface ServerInfo {
|
|
79
|
+
name: string;
|
|
80
|
+
version: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse one newline-delimited JSON line into a JSON-RPC message.
|
|
84
|
+
* Returns `{ ok: true, msg }` on success or `{ ok: false, error }` so the
|
|
85
|
+
* caller can emit the canonical -32700 parse-error response. Blank lines
|
|
86
|
+
* are reported as ok=false with no error — they're legal stdio framing
|
|
87
|
+
* noise, not protocol violations.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseLine(line: string): {
|
|
90
|
+
ok: true;
|
|
91
|
+
msg: JsonRpcMessage;
|
|
92
|
+
} | {
|
|
93
|
+
ok: false;
|
|
94
|
+
error: string | null;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Shape a successful response. `id` is echoed from the request.
|
|
98
|
+
*/
|
|
99
|
+
export declare function successResponse(id: string | number, result: unknown): JsonRpcResponse;
|
|
100
|
+
/**
|
|
101
|
+
* Shape an error response with a JSON-RPC error code + message. When the
|
|
102
|
+
* originating message was unparseable (no id extractable), pass `null`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse;
|
|
105
|
+
/** Encode a response as a newline-terminated string ready for stdout. */
|
|
106
|
+
export declare function encodeMessage(msg: JsonRpcResponse): string;
|
|
107
|
+
/**
|
|
108
|
+
* Core request-dispatch routine. Pure: given a parsed message + a tool
|
|
109
|
+
* registry + server identity, returns the JSON-RPC response (or null for
|
|
110
|
+
* a notification that expects no reply).
|
|
111
|
+
*/
|
|
112
|
+
export declare function handleMessage(msg: JsonRpcMessage, tools: McpTool[], server: ServerInfo): Promise<JsonRpcResponse | null>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP (Model Context Protocol) implementation — JSON-RPC 2.0 + the
|
|
3
|
+
* subset of methods dario needs to expose as an MCP server (direction #4,
|
|
4
|
+
* v3.27). Zero-runtime-deps policy means we don't pull in
|
|
5
|
+
* `@modelcontextprotocol/sdk`; the protocol surface we need is small enough
|
|
6
|
+
* to hand-roll correctly.
|
|
7
|
+
*
|
|
8
|
+
* MCP over stdio uses newline-delimited JSON — each line on stdin is one
|
|
9
|
+
* complete JSON-RPC message; each line on stdout is one complete response
|
|
10
|
+
* or notification. That's what `parseLine` and `encodeMessage` handle.
|
|
11
|
+
*
|
|
12
|
+
* We implement three methods from the MCP spec:
|
|
13
|
+
* initialize — handshake, server replies with capabilities.
|
|
14
|
+
* tools/list — enumerate exposed tools + their JSON schemas.
|
|
15
|
+
* tools/call — invoke a named tool with structured arguments.
|
|
16
|
+
*
|
|
17
|
+
* Plus the standard JSON-RPC error shapes. Notifications (no-`id` messages)
|
|
18
|
+
* are accepted and, for the only one we care about (`notifications/initialized`),
|
|
19
|
+
* acknowledged silently.
|
|
20
|
+
*
|
|
21
|
+
* Kept pure on purpose so the tests can exercise every branch without any
|
|
22
|
+
* stdio — `handleMessage` takes a raw JSON-RPC payload and a tool registry
|
|
23
|
+
* and returns either a response string or null (for notifications).
|
|
24
|
+
*/
|
|
25
|
+
/** MCP spec revisions ship with different wire quirks; pin the one we test against. */
|
|
26
|
+
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
27
|
+
/** JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification). */
|
|
28
|
+
export const JSONRPC_ERRORS = {
|
|
29
|
+
PARSE_ERROR: -32700,
|
|
30
|
+
INVALID_REQUEST: -32600,
|
|
31
|
+
METHOD_NOT_FOUND: -32601,
|
|
32
|
+
INVALID_PARAMS: -32602,
|
|
33
|
+
INTERNAL_ERROR: -32603,
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Parse one newline-delimited JSON line into a JSON-RPC message.
|
|
37
|
+
* Returns `{ ok: true, msg }` on success or `{ ok: false, error }` so the
|
|
38
|
+
* caller can emit the canonical -32700 parse-error response. Blank lines
|
|
39
|
+
* are reported as ok=false with no error — they're legal stdio framing
|
|
40
|
+
* noise, not protocol violations.
|
|
41
|
+
*/
|
|
42
|
+
export function parseLine(line) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (trimmed.length === 0)
|
|
45
|
+
return { ok: false, error: null };
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(trimmed);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { ok: false, error: e.message };
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== 'object')
|
|
54
|
+
return { ok: false, error: 'top-level not an object' };
|
|
55
|
+
const obj = parsed;
|
|
56
|
+
if (obj.jsonrpc !== '2.0')
|
|
57
|
+
return { ok: false, error: 'missing or wrong jsonrpc version' };
|
|
58
|
+
if (typeof obj.method !== 'string')
|
|
59
|
+
return { ok: false, error: 'method must be a string' };
|
|
60
|
+
return { ok: true, msg: obj };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Shape a successful response. `id` is echoed from the request.
|
|
64
|
+
*/
|
|
65
|
+
export function successResponse(id, result) {
|
|
66
|
+
return { jsonrpc: '2.0', id, result };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Shape an error response with a JSON-RPC error code + message. When the
|
|
70
|
+
* originating message was unparseable (no id extractable), pass `null`.
|
|
71
|
+
*/
|
|
72
|
+
export function errorResponse(id, code, message, data) {
|
|
73
|
+
const err = { code, message };
|
|
74
|
+
if (data !== undefined)
|
|
75
|
+
err.data = data;
|
|
76
|
+
return { jsonrpc: '2.0', id, error: err };
|
|
77
|
+
}
|
|
78
|
+
/** Encode a response as a newline-terminated string ready for stdout. */
|
|
79
|
+
export function encodeMessage(msg) {
|
|
80
|
+
return JSON.stringify(msg) + '\n';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Core request-dispatch routine. Pure: given a parsed message + a tool
|
|
84
|
+
* registry + server identity, returns the JSON-RPC response (or null for
|
|
85
|
+
* a notification that expects no reply).
|
|
86
|
+
*/
|
|
87
|
+
export async function handleMessage(msg, tools, server) {
|
|
88
|
+
const isNotification = !('id' in msg) || msg.id === undefined;
|
|
89
|
+
const id = isNotification ? null : msg.id;
|
|
90
|
+
// Notifications — no response expected. Only handle the ones we care about;
|
|
91
|
+
// silently ignore others (per JSON-RPC spec).
|
|
92
|
+
if (isNotification) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const reqId = id;
|
|
96
|
+
switch (msg.method) {
|
|
97
|
+
case 'initialize':
|
|
98
|
+
return successResponse(reqId, {
|
|
99
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
100
|
+
capabilities: { tools: {} },
|
|
101
|
+
serverInfo: server,
|
|
102
|
+
});
|
|
103
|
+
case 'tools/list':
|
|
104
|
+
return successResponse(reqId, {
|
|
105
|
+
tools: tools.map((t) => ({
|
|
106
|
+
name: t.name,
|
|
107
|
+
description: t.description,
|
|
108
|
+
inputSchema: t.inputSchema,
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
case 'tools/call': {
|
|
112
|
+
const params = (msg.params ?? {});
|
|
113
|
+
if (typeof params.name !== 'string') {
|
|
114
|
+
return errorResponse(reqId, JSONRPC_ERRORS.INVALID_PARAMS, 'tools/call requires string `name`');
|
|
115
|
+
}
|
|
116
|
+
const tool = tools.find((t) => t.name === params.name);
|
|
117
|
+
if (!tool) {
|
|
118
|
+
return errorResponse(reqId, JSONRPC_ERRORS.METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
|
|
119
|
+
}
|
|
120
|
+
const argsVal = params.arguments;
|
|
121
|
+
const args = (argsVal && typeof argsVal === 'object' && !Array.isArray(argsVal))
|
|
122
|
+
? argsVal
|
|
123
|
+
: {};
|
|
124
|
+
try {
|
|
125
|
+
const result = await tool.handler(args);
|
|
126
|
+
return successResponse(reqId, result);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return errorResponse(reqId, JSONRPC_ERRORS.INTERNAL_ERROR, `tool handler threw: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return errorResponse(reqId, JSONRPC_ERRORS.METHOD_NOT_FOUND, `unknown method: ${msg.method}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server runtime — bridges newline-delimited JSON-RPC on stdio to the
|
|
3
|
+
* pure `handleMessage` dispatcher in `./protocol.ts`. Everything stateful
|
|
4
|
+
* (stream I/O, serialization, logging) lives here; the protocol module
|
|
5
|
+
* stays pure so its tests don't need fake streams.
|
|
6
|
+
*
|
|
7
|
+
* Why serial: for await (const line of rl) processes one line at a time
|
|
8
|
+
* and we await the handler before reading the next. MCP clients tolerate
|
|
9
|
+
* both ordered and out-of-order responses, but ordered keeps the stdio
|
|
10
|
+
* frame sequence deterministic, avoids interleaving partial writes, and
|
|
11
|
+
* — the boring reason — matches what the tests can easily assert on.
|
|
12
|
+
*
|
|
13
|
+
* Why `runMcpServer` takes injectable streams: makes the event loop
|
|
14
|
+
* testable end-to-end with a PassThrough pair, no child process needed.
|
|
15
|
+
*/
|
|
16
|
+
import { type McpTool, type ServerInfo } from './protocol.js';
|
|
17
|
+
export interface RunServerOptions {
|
|
18
|
+
tools: McpTool[];
|
|
19
|
+
server: ServerInfo;
|
|
20
|
+
/** Stream of newline-delimited JSON-RPC messages. Defaults to `process.stdin`. */
|
|
21
|
+
stdin?: NodeJS.ReadableStream;
|
|
22
|
+
/** Sink for newline-delimited JSON-RPC responses. Defaults to `process.stdout`. */
|
|
23
|
+
stdout?: NodeJS.WritableStream;
|
|
24
|
+
/** Diagnostic channel. Defaults to `process.stderr`. */
|
|
25
|
+
stderr?: NodeJS.WritableStream;
|
|
26
|
+
/** Hook fired when a handler throws unexpectedly — primarily for tests. */
|
|
27
|
+
onError?: (err: unknown, line: string) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function runMcpServer(opts: RunServerOptions): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server runtime — bridges newline-delimited JSON-RPC on stdio to the
|
|
3
|
+
* pure `handleMessage` dispatcher in `./protocol.ts`. Everything stateful
|
|
4
|
+
* (stream I/O, serialization, logging) lives here; the protocol module
|
|
5
|
+
* stays pure so its tests don't need fake streams.
|
|
6
|
+
*
|
|
7
|
+
* Why serial: for await (const line of rl) processes one line at a time
|
|
8
|
+
* and we await the handler before reading the next. MCP clients tolerate
|
|
9
|
+
* both ordered and out-of-order responses, but ordered keeps the stdio
|
|
10
|
+
* frame sequence deterministic, avoids interleaving partial writes, and
|
|
11
|
+
* — the boring reason — matches what the tests can easily assert on.
|
|
12
|
+
*
|
|
13
|
+
* Why `runMcpServer` takes injectable streams: makes the event loop
|
|
14
|
+
* testable end-to-end with a PassThrough pair, no child process needed.
|
|
15
|
+
*/
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { handleMessage, parseLine, errorResponse, encodeMessage, JSONRPC_ERRORS, } from './protocol.js';
|
|
18
|
+
/**
|
|
19
|
+
* Back-pressure-aware line write. Using the callback form of
|
|
20
|
+
* `stream.write` means we wait for the chunk to drain before proceeding —
|
|
21
|
+
* on a slow stdout consumer that prevents us from buffering unboundedly.
|
|
22
|
+
*/
|
|
23
|
+
function writeLine(stream, chunk) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
stream.write(chunk, (err) => (err ? reject(err) : resolve()));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function runMcpServer(opts) {
|
|
29
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
30
|
+
const stdout = opts.stdout ?? process.stdout;
|
|
31
|
+
const stderr = opts.stderr ?? process.stderr;
|
|
32
|
+
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
|
33
|
+
for await (const line of rl) {
|
|
34
|
+
const parsed = parseLine(line);
|
|
35
|
+
if (!parsed.ok) {
|
|
36
|
+
// Blank lines are legal framing noise, not errors — skip silently.
|
|
37
|
+
if (parsed.error === null)
|
|
38
|
+
continue;
|
|
39
|
+
await writeLine(stdout, encodeMessage(errorResponse(null, JSONRPC_ERRORS.PARSE_ERROR, `parse error: ${parsed.error}`)));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const response = await handleMessage(parsed.msg, opts.tools, opts.server);
|
|
44
|
+
if (response !== null) {
|
|
45
|
+
await writeLine(stdout, encodeMessage(response));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
// `handleMessage` already wraps tool-handler errors into -32603 responses;
|
|
50
|
+
// anything reaching here is a bug in the dispatcher itself. Still, don't
|
|
51
|
+
// let it crash the server — emit a synthetic error response and log.
|
|
52
|
+
const message = err?.message ?? 'internal error';
|
|
53
|
+
const id = 'id' in parsed.msg && parsed.msg.id !== undefined
|
|
54
|
+
? parsed.msg.id
|
|
55
|
+
: null;
|
|
56
|
+
if (opts.onError)
|
|
57
|
+
opts.onError(err, line);
|
|
58
|
+
else
|
|
59
|
+
stderr.write(`[dario mcp] unhandled: ${message}\n`);
|
|
60
|
+
await writeLine(stdout, encodeMessage(errorResponse(id, JSONRPC_ERRORS.INTERNAL_ERROR, message)));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registry for the dario MCP server (v3.27, direction #4).
|
|
3
|
+
*
|
|
4
|
+
* Each tool wraps an existing dario subsystem that's already covered by
|
|
5
|
+
* its own tests, so these wrappers stay thin — fetch data, format it as
|
|
6
|
+
* text content, return. All tools are read-only. Destructive operations
|
|
7
|
+
* (login/logout, accounts add/remove, backend add/remove, proxy start)
|
|
8
|
+
* are deliberately NOT exposed: an MCP client shouldn't be able to
|
|
9
|
+
* mutate the user's dario state just by being connected, same boundary
|
|
10
|
+
* as the sub-agent prompt (v3.26).
|
|
11
|
+
*
|
|
12
|
+
* `buildToolRegistry` is a factory so tests can inject fake backends for
|
|
13
|
+
* each dario subsystem. In production, `buildDefaultToolRegistry` wires
|
|
14
|
+
* up the real dynamic imports — the imports live inside the factory so
|
|
15
|
+
* `src/mcp/protocol.ts` stays a pure module, decoupled from any of
|
|
16
|
+
* dario's heavier code paths.
|
|
17
|
+
*/
|
|
18
|
+
import type { McpTool } from './protocol.js';
|
|
19
|
+
/**
|
|
20
|
+
* Injectable data sources for the tool handlers. Production wiring in
|
|
21
|
+
* `buildDefaultToolRegistry` fills these in with the real dario imports;
|
|
22
|
+
* tests can substitute pure synthetic data to avoid touching network /
|
|
23
|
+
* filesystem / OAuth state.
|
|
24
|
+
*/
|
|
25
|
+
export interface ToolDataSources {
|
|
26
|
+
doctor: () => Promise<Array<{
|
|
27
|
+
status: string;
|
|
28
|
+
label: string;
|
|
29
|
+
detail: string;
|
|
30
|
+
}>>;
|
|
31
|
+
status: () => Promise<{
|
|
32
|
+
authenticated: boolean;
|
|
33
|
+
status: string;
|
|
34
|
+
expiresIn?: string;
|
|
35
|
+
canRefresh?: boolean;
|
|
36
|
+
}>;
|
|
37
|
+
accounts: () => Promise<Array<{
|
|
38
|
+
alias: string;
|
|
39
|
+
expiresAt: number;
|
|
40
|
+
}>>;
|
|
41
|
+
backends: () => Promise<Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
model?: string;
|
|
45
|
+
}>>;
|
|
46
|
+
subagent: () => Promise<{
|
|
47
|
+
installed: boolean;
|
|
48
|
+
path: string;
|
|
49
|
+
fileVersion: string | null;
|
|
50
|
+
current: boolean;
|
|
51
|
+
agentsDirExists: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
fingerprint: () => Promise<{
|
|
54
|
+
runtime: string;
|
|
55
|
+
runtimeVersion: string;
|
|
56
|
+
status: string;
|
|
57
|
+
detail: string;
|
|
58
|
+
templateSource: string;
|
|
59
|
+
templateSchema: number | null;
|
|
60
|
+
}>;
|
|
61
|
+
darioVersion: () => string;
|
|
62
|
+
}
|
|
63
|
+
export declare function buildToolRegistry(data: ToolDataSources): McpTool[];
|
|
64
|
+
/**
|
|
65
|
+
* Default production wiring — imports dario's real subsystems. Kept out
|
|
66
|
+
* of `buildToolRegistry` so the registry factory stays pure over its
|
|
67
|
+
* data sources (and the unit tests don't pay for dynamic imports).
|
|
68
|
+
*/
|
|
69
|
+
export declare function buildDefaultToolRegistry(): Promise<McpTool[]>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registry for the dario MCP server (v3.27, direction #4).
|
|
3
|
+
*
|
|
4
|
+
* Each tool wraps an existing dario subsystem that's already covered by
|
|
5
|
+
* its own tests, so these wrappers stay thin — fetch data, format it as
|
|
6
|
+
* text content, return. All tools are read-only. Destructive operations
|
|
7
|
+
* (login/logout, accounts add/remove, backend add/remove, proxy start)
|
|
8
|
+
* are deliberately NOT exposed: an MCP client shouldn't be able to
|
|
9
|
+
* mutate the user's dario state just by being connected, same boundary
|
|
10
|
+
* as the sub-agent prompt (v3.26).
|
|
11
|
+
*
|
|
12
|
+
* `buildToolRegistry` is a factory so tests can inject fake backends for
|
|
13
|
+
* each dario subsystem. In production, `buildDefaultToolRegistry` wires
|
|
14
|
+
* up the real dynamic imports — the imports live inside the factory so
|
|
15
|
+
* `src/mcp/protocol.ts` stays a pure module, decoupled from any of
|
|
16
|
+
* dario's heavier code paths.
|
|
17
|
+
*/
|
|
18
|
+
function textResult(text, isError = false) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: 'text', text }],
|
|
21
|
+
...(isError ? { isError: true } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function buildToolRegistry(data) {
|
|
25
|
+
const emptyObjectSchema = { type: 'object', properties: {}, required: [] };
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
name: 'doctor',
|
|
29
|
+
description: 'Run dario\'s health-report checks and return the formatted output. Covers: dario version, Node, platform, runtime TLS fingerprint, CC binary + compat, template source + drift, OAuth state, account pool, backends, CC sub-agent install. No side effects.',
|
|
30
|
+
inputSchema: emptyObjectSchema,
|
|
31
|
+
handler: async () => {
|
|
32
|
+
const checks = await data.doctor();
|
|
33
|
+
if (checks.length === 0)
|
|
34
|
+
return textResult('No checks produced output.');
|
|
35
|
+
const labelWidth = checks.reduce((n, c) => Math.max(n, c.label.length), 0);
|
|
36
|
+
const prefix = {
|
|
37
|
+
ok: '[ OK ]', warn: '[WARN]', fail: '[FAIL]', info: '[INFO]',
|
|
38
|
+
};
|
|
39
|
+
const lines = checks.map((c) => `${prefix[c.status] ?? '[????]'} ${c.label.padEnd(labelWidth)} ${c.detail}`);
|
|
40
|
+
const failed = checks.filter((c) => c.status === 'fail').length;
|
|
41
|
+
const warned = checks.filter((c) => c.status === 'warn').length;
|
|
42
|
+
const summary = `\n\nSummary: ${checks.length} checks — ${failed} fail, ${warned} warn`;
|
|
43
|
+
return textResult(lines.join('\n') + summary);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'status',
|
|
48
|
+
description: 'Report the dario OAuth authentication status: whether credentials are present, valid, and when they expire. Read-only.',
|
|
49
|
+
inputSchema: emptyObjectSchema,
|
|
50
|
+
handler: async () => {
|
|
51
|
+
const s = await data.status();
|
|
52
|
+
if (!s.authenticated) {
|
|
53
|
+
const detail = s.status === 'none'
|
|
54
|
+
? 'No credentials — run `dario login`.'
|
|
55
|
+
: s.status === 'expired' && s.canRefresh
|
|
56
|
+
? 'Credentials expired but refreshable — run `dario refresh` or `dario proxy`.'
|
|
57
|
+
: `Not authenticated (status: ${s.status}).`;
|
|
58
|
+
return textResult(`Authenticated: no\n${detail}`);
|
|
59
|
+
}
|
|
60
|
+
return textResult(`Authenticated: yes\nStatus: ${s.status}\nExpires in: ${s.expiresIn ?? 'unknown'}`);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'accounts_list',
|
|
65
|
+
description: 'List the accounts configured in dario\'s multi-account pool (~/.dario/accounts/). Returns alias + token expiry per account. Read-only.',
|
|
66
|
+
inputSchema: emptyObjectSchema,
|
|
67
|
+
handler: async () => {
|
|
68
|
+
const accounts = await data.accounts();
|
|
69
|
+
if (accounts.length === 0) {
|
|
70
|
+
return textResult('No pool accounts configured. dario runs in single-account mode from ~/.dario/credentials.json.');
|
|
71
|
+
}
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const lines = accounts.map((a) => {
|
|
74
|
+
const msLeft = Math.max(0, a.expiresAt - now);
|
|
75
|
+
const hours = Math.floor(msLeft / 3600000);
|
|
76
|
+
const mins = Math.floor((msLeft % 3600000) / 60000);
|
|
77
|
+
const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
|
|
78
|
+
return ` ${a.alias.padEnd(20)} token expires in ${expiry}`;
|
|
79
|
+
});
|
|
80
|
+
const note = accounts.length < 2
|
|
81
|
+
? '\n\nPool mode activates at 2+ accounts — currently single-account.'
|
|
82
|
+
: '';
|
|
83
|
+
return textResult(`${accounts.length} account${accounts.length === 1 ? '' : 's'}:\n${lines.join('\n')}${note}`);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'backends_list',
|
|
88
|
+
description: 'List configured OpenAI-compat backends (OpenAI, OpenRouter, Groq, LiteLLM, Ollama, etc.). Read-only — does not expose API keys.',
|
|
89
|
+
inputSchema: emptyObjectSchema,
|
|
90
|
+
handler: async () => {
|
|
91
|
+
const backends = await data.backends();
|
|
92
|
+
if (backends.length === 0) {
|
|
93
|
+
return textResult('No OpenAI-compat backends configured. Claude subscription is the only route.');
|
|
94
|
+
}
|
|
95
|
+
const lines = backends.map((b) => ` ${b.name.padEnd(20)} ${b.baseUrl}${b.model ? ` (default model: ${b.model})` : ''}`);
|
|
96
|
+
return textResult(`${backends.length} backend${backends.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'subagent_status',
|
|
101
|
+
description: 'Report whether the dario CC sub-agent (~/.claude/agents/dario.md) is installed and whether it matches the running dario version. Read-only.',
|
|
102
|
+
inputSchema: emptyObjectSchema,
|
|
103
|
+
handler: async () => {
|
|
104
|
+
const s = await data.subagent();
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push(`Path: ${s.path}`);
|
|
107
|
+
lines.push(`~/.claude/agents exists: ${s.agentsDirExists ? 'yes' : 'no'}`);
|
|
108
|
+
lines.push(`Installed: ${s.installed ? `yes (v${s.fileVersion ?? 'unknown'})` : 'no'}`);
|
|
109
|
+
if (s.installed && !s.current) {
|
|
110
|
+
lines.push('Note: file version does not match running dario — run `dario subagent install` to refresh.');
|
|
111
|
+
}
|
|
112
|
+
if (!s.installed && s.agentsDirExists) {
|
|
113
|
+
lines.push('Install with: `dario subagent install`.');
|
|
114
|
+
}
|
|
115
|
+
return textResult(lines.join('\n'));
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'fingerprint_info',
|
|
120
|
+
description: 'Report dario\'s runtime / TLS fingerprint state: whether the proxy is running under Bun (matches CC\'s TLS stack) or Node (diverges), which template source is active (live-captured vs bundled), and the template schema version. Read-only.',
|
|
121
|
+
inputSchema: emptyObjectSchema,
|
|
122
|
+
handler: async () => {
|
|
123
|
+
const f = await data.fingerprint();
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push(`Runtime: ${f.runtime} ${f.runtimeVersion}`);
|
|
126
|
+
lines.push(`TLS status: ${f.status}`);
|
|
127
|
+
lines.push(`TLS detail: ${f.detail}`);
|
|
128
|
+
lines.push(`Template source: ${f.templateSource}`);
|
|
129
|
+
lines.push(`Template schema: v${f.templateSchema ?? '?'}`);
|
|
130
|
+
lines.push(`dario version: ${data.darioVersion()}`);
|
|
131
|
+
return textResult(lines.join('\n'));
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Default production wiring — imports dario's real subsystems. Kept out
|
|
138
|
+
* of `buildToolRegistry` so the registry factory stays pure over its
|
|
139
|
+
* data sources (and the unit tests don't pay for dynamic imports).
|
|
140
|
+
*/
|
|
141
|
+
export async function buildDefaultToolRegistry() {
|
|
142
|
+
const [doctorMod, oauthMod, accountsMod, backendMod, subagentMod, runtimeMod, templateMod, pkgVersion] = await Promise.all([
|
|
143
|
+
import('../doctor.js'),
|
|
144
|
+
import('../oauth.js'),
|
|
145
|
+
import('../accounts.js'),
|
|
146
|
+
import('../openai-backend.js'),
|
|
147
|
+
import('../subagent.js'),
|
|
148
|
+
import('../runtime-fingerprint.js'),
|
|
149
|
+
import('../cc-template.js'),
|
|
150
|
+
readDarioVersion(),
|
|
151
|
+
]);
|
|
152
|
+
return buildToolRegistry({
|
|
153
|
+
doctor: async () => {
|
|
154
|
+
const checks = await doctorMod.runChecks();
|
|
155
|
+
return checks.map((c) => ({
|
|
156
|
+
status: c.status,
|
|
157
|
+
label: c.label,
|
|
158
|
+
detail: c.detail,
|
|
159
|
+
}));
|
|
160
|
+
},
|
|
161
|
+
status: async () => oauthMod.getStatus(),
|
|
162
|
+
accounts: async () => {
|
|
163
|
+
const loaded = await accountsMod.loadAllAccounts();
|
|
164
|
+
return loaded.map((a) => ({
|
|
165
|
+
alias: a.alias,
|
|
166
|
+
expiresAt: a.expiresAt,
|
|
167
|
+
}));
|
|
168
|
+
},
|
|
169
|
+
backends: async () => {
|
|
170
|
+
const backends = await backendMod.listBackends();
|
|
171
|
+
return backends.map((b) => ({
|
|
172
|
+
name: b.name,
|
|
173
|
+
baseUrl: b.baseUrl,
|
|
174
|
+
model: b.model,
|
|
175
|
+
}));
|
|
176
|
+
},
|
|
177
|
+
subagent: async () => subagentMod.loadSubagentStatus(),
|
|
178
|
+
fingerprint: async () => {
|
|
179
|
+
const rt = runtimeMod.detectRuntimeFingerprint();
|
|
180
|
+
const tmpl = templateMod.CC_TEMPLATE;
|
|
181
|
+
return {
|
|
182
|
+
runtime: rt.runtime,
|
|
183
|
+
runtimeVersion: rt.runtimeVersion,
|
|
184
|
+
status: rt.status,
|
|
185
|
+
detail: rt.detail,
|
|
186
|
+
templateSource: tmpl._source ?? 'unknown',
|
|
187
|
+
templateSchema: tmpl._schemaVersion ?? null,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
darioVersion: () => pkgVersion,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async function readDarioVersion() {
|
|
194
|
+
try {
|
|
195
|
+
const { readFileSync } = await import('node:fs');
|
|
196
|
+
const { fileURLToPath } = await import('node:url');
|
|
197
|
+
const { dirname, join } = await import('node:path');
|
|
198
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
199
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
|
|
200
|
+
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return 'unknown';
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ interface ProxyOptions {
|
|
|
16
16
|
pacingMinMs?: number;
|
|
17
17
|
pacingJitterMs?: number;
|
|
18
18
|
drainOnClose?: boolean;
|
|
19
|
+
sessionIdleRotateMs?: number;
|
|
20
|
+
sessionRotateJitterMs?: number;
|
|
21
|
+
sessionMaxAgeMs?: number;
|
|
22
|
+
sessionPerClient?: boolean;
|
|
19
23
|
}
|
|
20
24
|
export declare function sanitizeError(err: unknown): string;
|
|
21
25
|
export declare function startProxy(opts?: ProxyOptions): Promise<void>;
|
package/dist/proxy.js
CHANGED
|
@@ -97,11 +97,17 @@ function extractFirstUserMessage(body) {
|
|
|
97
97
|
//
|
|
98
98
|
// v3.19 keeps the id stable through a conversation window and rotates
|
|
99
99
|
// only after an idle gap long enough to credibly indicate a new
|
|
100
|
-
// conversation
|
|
101
|
-
//
|
|
100
|
+
// conversation. Pool mode still uses the per-account identity.sessionId
|
|
101
|
+
// (stable across the account's lifetime).
|
|
102
|
+
//
|
|
103
|
+
// v3.28 generalises the single hardcoded 15-min window into a tunable
|
|
104
|
+
// registry (see src/session-rotation.ts) with optional jitter, max-age,
|
|
105
|
+
// and per-client keying. SESSION_ID below is kept only as a mirror of
|
|
106
|
+
// the default single-account session so out-of-band consumers (presence
|
|
107
|
+
// ping, diagnostic logs) can read the most recent id without going
|
|
108
|
+
// through the registry. It's refreshed after every dispatch-path call
|
|
109
|
+
// that assigns a new id.
|
|
102
110
|
let SESSION_ID = randomUUID();
|
|
103
|
-
let SESSION_LAST_USED = 0;
|
|
104
|
-
const SESSION_IDLE_ROTATE_MS = 15 * 60 * 1000;
|
|
105
111
|
const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
|
|
106
112
|
// Claude Code device identity — required for Max plan billing classification.
|
|
107
113
|
// Without metadata.user_id, Anthropic classifies requests as third-party and
|
|
@@ -593,6 +599,22 @@ export async function startProxy(opts = {}) {
|
|
|
593
599
|
if (verbose) {
|
|
594
600
|
console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
|
|
595
601
|
}
|
|
602
|
+
// Session-ID lifecycle (v3.28, direction #1). Replaces the v3.27 hardcoded
|
|
603
|
+
// 15-minute idle window with a tunable registry: idle threshold, jitter on
|
|
604
|
+
// that threshold, optional hard max-age, and optional per-client keying.
|
|
605
|
+
// Defaults preserve v3.27 behavior exactly. See src/session-rotation.ts.
|
|
606
|
+
const { SessionRegistry, resolveSessionRotationConfig } = await import('./session-rotation.js');
|
|
607
|
+
const sessionCfg = resolveSessionRotationConfig({
|
|
608
|
+
idleRotateMs: opts.sessionIdleRotateMs,
|
|
609
|
+
jitterMs: opts.sessionRotateJitterMs,
|
|
610
|
+
maxAgeMs: opts.sessionMaxAgeMs,
|
|
611
|
+
perClient: opts.sessionPerClient,
|
|
612
|
+
});
|
|
613
|
+
const sessionRegistry = new SessionRegistry(sessionCfg, () => randomUUID());
|
|
614
|
+
if (verbose) {
|
|
615
|
+
const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
|
|
616
|
+
console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
|
|
617
|
+
}
|
|
596
618
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
597
619
|
const apiKey = process.env.DARIO_API_KEY;
|
|
598
620
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
@@ -958,6 +980,9 @@ export async function startProxy(opts = {}) {
|
|
|
958
980
|
// selection toward one we already paid cache cost on — passthrough
|
|
959
981
|
// users aren't doing template replay anyway).
|
|
960
982
|
let stickyKey = null;
|
|
983
|
+
// Outbound session id resolved once — either inside the template build
|
|
984
|
+
// (so body metadata matches) or below for passthrough (no body build).
|
|
985
|
+
let preBodySessionId;
|
|
961
986
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
962
987
|
// per request from incoming headers so the reverse mapper can fill
|
|
963
988
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -1010,9 +1035,28 @@ export async function startProxy(opts = {}) {
|
|
|
1010
1035
|
}
|
|
1011
1036
|
}
|
|
1012
1037
|
}
|
|
1038
|
+
// Resolve the outbound session id before the body build so the
|
|
1039
|
+
// metadata.session_id in the CC body and the x-claude-code-session-id
|
|
1040
|
+
// header both use the same value. v3.27 consulted SESSION_ID twice
|
|
1041
|
+
// with rotation between the reads, so on rotation events body and
|
|
1042
|
+
// header disagreed — harmless for plain operation but a fingerprint
|
|
1043
|
+
// in its own right.
|
|
1044
|
+
if (poolAccount) {
|
|
1045
|
+
preBodySessionId = poolAccount.identity.sessionId;
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
const clientKey = req.headers['x-session-id']
|
|
1049
|
+
?? req.headers['x-client-session-id'];
|
|
1050
|
+
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
1051
|
+
preBodySessionId = assigned.sessionId;
|
|
1052
|
+
SESSION_ID = assigned.sessionId;
|
|
1053
|
+
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
1054
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1013
1057
|
const bodyIdentity = poolAccount
|
|
1014
1058
|
? poolAccount.identity
|
|
1015
|
-
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId:
|
|
1059
|
+
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
|
|
1016
1060
|
const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
|
|
1017
1061
|
preserveTools: opts.preserveTools ?? false,
|
|
1018
1062
|
hybridTools: opts.hybridTools ?? false,
|
|
@@ -1102,18 +1146,30 @@ export async function startProxy(opts = {}) {
|
|
|
1102
1146
|
}
|
|
1103
1147
|
lastRequestTime = Date.now();
|
|
1104
1148
|
// Session ID: pool mode uses the per-account identity.sessionId (stable
|
|
1105
|
-
// per account). Single-account mode
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1149
|
+
// per account). Single-account mode delegates to the session registry
|
|
1150
|
+
// (src/session-rotation.ts) which applies the configured idle / jitter /
|
|
1151
|
+
// max-age / per-client policy. Resolution happens earlier, at body-build
|
|
1152
|
+
// time, so the CC body's metadata.session_id and the outbound
|
|
1153
|
+
// x-claude-code-session-id header always agree. preBodySessionId holds
|
|
1154
|
+
// the template-build value; in passthrough mode (no template build)
|
|
1155
|
+
// the registry is consulted here instead.
|
|
1156
|
+
let outboundSessionId;
|
|
1157
|
+
if (poolAccount) {
|
|
1158
|
+
outboundSessionId = poolAccount.identity.sessionId;
|
|
1159
|
+
}
|
|
1160
|
+
else if (preBodySessionId !== undefined) {
|
|
1161
|
+
outboundSessionId = preBodySessionId;
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
const clientKey = req.headers['x-session-id']
|
|
1165
|
+
?? req.headers['x-client-session-id'];
|
|
1166
|
+
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
1167
|
+
outboundSessionId = assigned.sessionId;
|
|
1168
|
+
SESSION_ID = assigned.sessionId;
|
|
1169
|
+
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
1170
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
1113
1171
|
}
|
|
1114
|
-
SESSION_LAST_USED = nowTs;
|
|
1115
1172
|
}
|
|
1116
|
-
const outboundSessionId = poolAccount ? poolAccount.identity.sessionId : SESSION_ID;
|
|
1117
1173
|
const headers = {
|
|
1118
1174
|
...staticHeaders,
|
|
1119
1175
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound request to Anthropic carries a session identifier in the
|
|
5
|
+
* CC request body's metadata. Real Claude Code holds that id stable through
|
|
6
|
+
* a conversation and mints a new one when the user returns after an idle
|
|
7
|
+
* gap — roughly "one id per conversation", not per HTTP call. A proxy that
|
|
8
|
+
* rotates per-request looks synthetic; one that never rotates looks equally
|
|
9
|
+
* synthetic over long sessions. v3.19 tightened the per-request leak into a
|
|
10
|
+
* single hardcoded 15-minute idle window; this module generalises that into
|
|
11
|
+
* a registry so operators can tune the behaviour and so the multi-client
|
|
12
|
+
* case (dario fanning multiple UIs through one proxy) stops sharing one id.
|
|
13
|
+
*
|
|
14
|
+
* Three independent knobs:
|
|
15
|
+
*
|
|
16
|
+
* idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
|
|
17
|
+
* traffic on a given session. Default 15 min preserves
|
|
18
|
+
* v3.27 exactly when the other knobs stay at defaults.
|
|
19
|
+
*
|
|
20
|
+
* jitterMs — the observable idle threshold for a given session is
|
|
21
|
+
* idleRotateMs + U(0, jitterMs), sampled once at session
|
|
22
|
+
* creation. A zero-jitter proxy rotates at exactly the
|
|
23
|
+
* same interval every time; adding jitter means the floor
|
|
24
|
+
* can't be inferred from long-run rotation cadence.
|
|
25
|
+
*
|
|
26
|
+
* maxAgeMs — hard cap on a session's total lifetime regardless of
|
|
27
|
+
* activity. Optional (undefined disables). A chatty
|
|
28
|
+
* always-on pipeline would otherwise keep one session id
|
|
29
|
+
* alive for days; real CC conversations don't.
|
|
30
|
+
*
|
|
31
|
+
* perClient — when true, the registry keys sessions by the caller's
|
|
32
|
+
* `x-session-id` / `x-client-session-id` header so two
|
|
33
|
+
* upstream UIs talking to one dario don't collapse onto
|
|
34
|
+
* a single session id. Default false preserves v3.27
|
|
35
|
+
* single-account semantics.
|
|
36
|
+
*
|
|
37
|
+
* Pure logic (decideSessionRotation) is separated from the stateful cache
|
|
38
|
+
* (SessionRegistry) so tests can walk every decision branch without Maps,
|
|
39
|
+
* timers, or UUID sources. The proxy injects a `() => string` id factory
|
|
40
|
+
* (randomUUID) and `() => number` rng so both are swappable in tests.
|
|
41
|
+
*
|
|
42
|
+
* Pool mode is unaffected — each account carries a stable identity.sessionId
|
|
43
|
+
* for its lifetime, and the caller doesn't consult this registry. This
|
|
44
|
+
* module only governs the single-account SESSION_ID slot.
|
|
45
|
+
*/
|
|
46
|
+
export interface SessionRotationConfig {
|
|
47
|
+
/** Idle threshold in ms: if no traffic for this long, the session rotates on the next request. */
|
|
48
|
+
idleRotateMs: number;
|
|
49
|
+
/** Max additional uniform-random ms added to the idle threshold at session creation. Pass 0 to disable. */
|
|
50
|
+
jitterMs: number;
|
|
51
|
+
/** Optional hard cap on session lifetime in ms. Undefined = no cap. */
|
|
52
|
+
maxAgeMs?: number;
|
|
53
|
+
/** When true, key sessions by client header so multiple upstreams get distinct ids. Default false. */
|
|
54
|
+
perClient: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface SessionEntry {
|
|
57
|
+
/** The session id sent to Anthropic in the outbound body. */
|
|
58
|
+
upstreamSessionId: string;
|
|
59
|
+
/** Wall-clock creation time (ms since epoch). */
|
|
60
|
+
createdAt: number;
|
|
61
|
+
/** Wall-clock time of last outbound use (ms since epoch). */
|
|
62
|
+
lastUsedAt: number;
|
|
63
|
+
/** Jitter offset sampled once at creation; added to cfg.idleRotateMs to get this session's effective idle threshold. */
|
|
64
|
+
idleJitterOffsetMs: number;
|
|
65
|
+
}
|
|
66
|
+
export type RotationDecision = 'keep' | 'rotate-new' | 'rotate-idle' | 'rotate-age';
|
|
67
|
+
/**
|
|
68
|
+
* Pure decision: should the given entry be rotated at `now`?
|
|
69
|
+
*
|
|
70
|
+
* Returns 'rotate-new' when no entry exists yet (first use for this key).
|
|
71
|
+
* Returns 'rotate-idle' when traffic has been silent for longer than this
|
|
72
|
+
* entry's sampled threshold. Returns 'rotate-age' when the entry's
|
|
73
|
+
* absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
|
|
74
|
+
*
|
|
75
|
+
* Idle is checked before age so an idle-but-young session rotates on a
|
|
76
|
+
* fresh conversation boundary rather than churning mid-conversation at
|
|
77
|
+
* exactly its max-age. Negative config values are clamped to 0 (lenient:
|
|
78
|
+
* a typoed flag should behave like "rotate eagerly", not crash startup).
|
|
79
|
+
*/
|
|
80
|
+
export declare function decideSessionRotation(entry: SessionEntry | undefined, now: number, cfg: SessionRotationConfig): RotationDecision;
|
|
81
|
+
/** Result of SessionRegistry.getOrCreate — both the id to send and why it was chosen. */
|
|
82
|
+
export interface RegistryResult {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
rotated: boolean;
|
|
85
|
+
reason: RotationDecision;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Per-client session cache with rotation + LRU eviction.
|
|
89
|
+
*
|
|
90
|
+
* Not concurrency-safe — the proxy's dispatch loop is single-threaded
|
|
91
|
+
* JavaScript and call sites are serialized by the event loop. The
|
|
92
|
+
* registry is intentionally a plain Map, not a TTL cache, because
|
|
93
|
+
* rotation timing is part of the observable behaviour we're modelling
|
|
94
|
+
* and a background sweeper would add a separate dimension (WHEN entries
|
|
95
|
+
* disappear) that doesn't exist in a real CC client.
|
|
96
|
+
*
|
|
97
|
+
* maxEntries defaults to 1024 — more than enough for any reasonable
|
|
98
|
+
* fan-out while capping memory growth against a pathological client
|
|
99
|
+
* that sends a fresh session header on every request.
|
|
100
|
+
*/
|
|
101
|
+
export declare class SessionRegistry {
|
|
102
|
+
private readonly cfg;
|
|
103
|
+
private readonly newId;
|
|
104
|
+
private readonly rng;
|
|
105
|
+
private readonly maxEntries;
|
|
106
|
+
private readonly entries;
|
|
107
|
+
constructor(cfg: SessionRotationConfig, newId: () => string, rng?: () => number, maxEntries?: number);
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the outbound session id for a given client key at time `now`.
|
|
110
|
+
*
|
|
111
|
+
* `clientKey` is the caller-side session header when cfg.perClient is
|
|
112
|
+
* true, and ignored (replaced with 'default') when perClient is false.
|
|
113
|
+
* Callers pass the raw header value and let the registry decide —
|
|
114
|
+
* otherwise flipping perClient at runtime would require threading
|
|
115
|
+
* the decision to every call site.
|
|
116
|
+
*
|
|
117
|
+
* Updates lastUsedAt on the entry (whether kept or freshly minted),
|
|
118
|
+
* and nudges the entry to the end of the insertion-order map so
|
|
119
|
+
* eviction under maxEntries pressure is LRU.
|
|
120
|
+
*/
|
|
121
|
+
getOrCreate(clientKey: string | undefined, now: number): RegistryResult;
|
|
122
|
+
/**
|
|
123
|
+
* Read the current id for a client key without touching lastUsedAt.
|
|
124
|
+
*
|
|
125
|
+
* Used by out-of-band consumers (e.g. presence pings) that want to
|
|
126
|
+
* reflect the most recently assigned session id but must not count
|
|
127
|
+
* as activity for rotation purposes. Returns undefined if no entry.
|
|
128
|
+
*/
|
|
129
|
+
peek(clientKey: string | undefined): string | undefined;
|
|
130
|
+
size(): number;
|
|
131
|
+
clear(): void;
|
|
132
|
+
private evictIfOverCap;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
|
|
136
|
+
*
|
|
137
|
+
* Precedence (highest first):
|
|
138
|
+
* 1. Explicit argument (typically from CLI flag)
|
|
139
|
+
* 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
|
|
140
|
+
* DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
|
|
141
|
+
* 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
|
|
142
|
+
* perClient=false — exactly matches the hardcoded v3.27 behaviour.
|
|
143
|
+
*
|
|
144
|
+
* Invalid numeric strings fall through to the next source. For perClient,
|
|
145
|
+
* '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
|
|
146
|
+
* the explicit or default value.
|
|
147
|
+
*/
|
|
148
|
+
export declare function resolveSessionRotationConfig(explicit?: {
|
|
149
|
+
idleRotateMs?: number;
|
|
150
|
+
jitterMs?: number;
|
|
151
|
+
maxAgeMs?: number;
|
|
152
|
+
perClient?: boolean;
|
|
153
|
+
}, env?: NodeJS.ProcessEnv): SessionRotationConfig;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound request to Anthropic carries a session identifier in the
|
|
5
|
+
* CC request body's metadata. Real Claude Code holds that id stable through
|
|
6
|
+
* a conversation and mints a new one when the user returns after an idle
|
|
7
|
+
* gap — roughly "one id per conversation", not per HTTP call. A proxy that
|
|
8
|
+
* rotates per-request looks synthetic; one that never rotates looks equally
|
|
9
|
+
* synthetic over long sessions. v3.19 tightened the per-request leak into a
|
|
10
|
+
* single hardcoded 15-minute idle window; this module generalises that into
|
|
11
|
+
* a registry so operators can tune the behaviour and so the multi-client
|
|
12
|
+
* case (dario fanning multiple UIs through one proxy) stops sharing one id.
|
|
13
|
+
*
|
|
14
|
+
* Three independent knobs:
|
|
15
|
+
*
|
|
16
|
+
* idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
|
|
17
|
+
* traffic on a given session. Default 15 min preserves
|
|
18
|
+
* v3.27 exactly when the other knobs stay at defaults.
|
|
19
|
+
*
|
|
20
|
+
* jitterMs — the observable idle threshold for a given session is
|
|
21
|
+
* idleRotateMs + U(0, jitterMs), sampled once at session
|
|
22
|
+
* creation. A zero-jitter proxy rotates at exactly the
|
|
23
|
+
* same interval every time; adding jitter means the floor
|
|
24
|
+
* can't be inferred from long-run rotation cadence.
|
|
25
|
+
*
|
|
26
|
+
* maxAgeMs — hard cap on a session's total lifetime regardless of
|
|
27
|
+
* activity. Optional (undefined disables). A chatty
|
|
28
|
+
* always-on pipeline would otherwise keep one session id
|
|
29
|
+
* alive for days; real CC conversations don't.
|
|
30
|
+
*
|
|
31
|
+
* perClient — when true, the registry keys sessions by the caller's
|
|
32
|
+
* `x-session-id` / `x-client-session-id` header so two
|
|
33
|
+
* upstream UIs talking to one dario don't collapse onto
|
|
34
|
+
* a single session id. Default false preserves v3.27
|
|
35
|
+
* single-account semantics.
|
|
36
|
+
*
|
|
37
|
+
* Pure logic (decideSessionRotation) is separated from the stateful cache
|
|
38
|
+
* (SessionRegistry) so tests can walk every decision branch without Maps,
|
|
39
|
+
* timers, or UUID sources. The proxy injects a `() => string` id factory
|
|
40
|
+
* (randomUUID) and `() => number` rng so both are swappable in tests.
|
|
41
|
+
*
|
|
42
|
+
* Pool mode is unaffected — each account carries a stable identity.sessionId
|
|
43
|
+
* for its lifetime, and the caller doesn't consult this registry. This
|
|
44
|
+
* module only governs the single-account SESSION_ID slot.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Pure decision: should the given entry be rotated at `now`?
|
|
48
|
+
*
|
|
49
|
+
* Returns 'rotate-new' when no entry exists yet (first use for this key).
|
|
50
|
+
* Returns 'rotate-idle' when traffic has been silent for longer than this
|
|
51
|
+
* entry's sampled threshold. Returns 'rotate-age' when the entry's
|
|
52
|
+
* absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
|
|
53
|
+
*
|
|
54
|
+
* Idle is checked before age so an idle-but-young session rotates on a
|
|
55
|
+
* fresh conversation boundary rather than churning mid-conversation at
|
|
56
|
+
* exactly its max-age. Negative config values are clamped to 0 (lenient:
|
|
57
|
+
* a typoed flag should behave like "rotate eagerly", not crash startup).
|
|
58
|
+
*/
|
|
59
|
+
export function decideSessionRotation(entry, now, cfg) {
|
|
60
|
+
if (!entry)
|
|
61
|
+
return 'rotate-new';
|
|
62
|
+
const idleBase = Math.max(0, cfg.idleRotateMs);
|
|
63
|
+
const idleThreshold = idleBase + Math.max(0, entry.idleJitterOffsetMs);
|
|
64
|
+
if (now - entry.lastUsedAt > idleThreshold)
|
|
65
|
+
return 'rotate-idle';
|
|
66
|
+
if (cfg.maxAgeMs !== undefined && cfg.maxAgeMs > 0 && now - entry.createdAt > cfg.maxAgeMs) {
|
|
67
|
+
return 'rotate-age';
|
|
68
|
+
}
|
|
69
|
+
return 'keep';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-client session cache with rotation + LRU eviction.
|
|
73
|
+
*
|
|
74
|
+
* Not concurrency-safe — the proxy's dispatch loop is single-threaded
|
|
75
|
+
* JavaScript and call sites are serialized by the event loop. The
|
|
76
|
+
* registry is intentionally a plain Map, not a TTL cache, because
|
|
77
|
+
* rotation timing is part of the observable behaviour we're modelling
|
|
78
|
+
* and a background sweeper would add a separate dimension (WHEN entries
|
|
79
|
+
* disappear) that doesn't exist in a real CC client.
|
|
80
|
+
*
|
|
81
|
+
* maxEntries defaults to 1024 — more than enough for any reasonable
|
|
82
|
+
* fan-out while capping memory growth against a pathological client
|
|
83
|
+
* that sends a fresh session header on every request.
|
|
84
|
+
*/
|
|
85
|
+
export class SessionRegistry {
|
|
86
|
+
cfg;
|
|
87
|
+
newId;
|
|
88
|
+
rng;
|
|
89
|
+
maxEntries;
|
|
90
|
+
entries = new Map();
|
|
91
|
+
constructor(cfg, newId, rng = Math.random, maxEntries = 1024) {
|
|
92
|
+
this.cfg = cfg;
|
|
93
|
+
this.newId = newId;
|
|
94
|
+
this.rng = rng;
|
|
95
|
+
this.maxEntries = maxEntries;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the outbound session id for a given client key at time `now`.
|
|
99
|
+
*
|
|
100
|
+
* `clientKey` is the caller-side session header when cfg.perClient is
|
|
101
|
+
* true, and ignored (replaced with 'default') when perClient is false.
|
|
102
|
+
* Callers pass the raw header value and let the registry decide —
|
|
103
|
+
* otherwise flipping perClient at runtime would require threading
|
|
104
|
+
* the decision to every call site.
|
|
105
|
+
*
|
|
106
|
+
* Updates lastUsedAt on the entry (whether kept or freshly minted),
|
|
107
|
+
* and nudges the entry to the end of the insertion-order map so
|
|
108
|
+
* eviction under maxEntries pressure is LRU.
|
|
109
|
+
*/
|
|
110
|
+
getOrCreate(clientKey, now) {
|
|
111
|
+
const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
|
|
112
|
+
const existing = this.entries.get(key);
|
|
113
|
+
const decision = decideSessionRotation(existing, now, this.cfg);
|
|
114
|
+
if (decision === 'keep' && existing) {
|
|
115
|
+
existing.lastUsedAt = now;
|
|
116
|
+
// Re-insert to refresh LRU position.
|
|
117
|
+
this.entries.delete(key);
|
|
118
|
+
this.entries.set(key, existing);
|
|
119
|
+
return { sessionId: existing.upstreamSessionId, rotated: false, reason: 'keep' };
|
|
120
|
+
}
|
|
121
|
+
const jitterOffset = this.cfg.jitterMs > 0 ? Math.floor(this.rng() * this.cfg.jitterMs) : 0;
|
|
122
|
+
const entry = {
|
|
123
|
+
upstreamSessionId: this.newId(),
|
|
124
|
+
createdAt: now,
|
|
125
|
+
lastUsedAt: now,
|
|
126
|
+
idleJitterOffsetMs: jitterOffset,
|
|
127
|
+
};
|
|
128
|
+
this.entries.set(key, entry);
|
|
129
|
+
this.evictIfOverCap();
|
|
130
|
+
return { sessionId: entry.upstreamSessionId, rotated: true, reason: decision };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read the current id for a client key without touching lastUsedAt.
|
|
134
|
+
*
|
|
135
|
+
* Used by out-of-band consumers (e.g. presence pings) that want to
|
|
136
|
+
* reflect the most recently assigned session id but must not count
|
|
137
|
+
* as activity for rotation purposes. Returns undefined if no entry.
|
|
138
|
+
*/
|
|
139
|
+
peek(clientKey) {
|
|
140
|
+
const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
|
|
141
|
+
return this.entries.get(key)?.upstreamSessionId;
|
|
142
|
+
}
|
|
143
|
+
size() {
|
|
144
|
+
return this.entries.size;
|
|
145
|
+
}
|
|
146
|
+
clear() {
|
|
147
|
+
this.entries.clear();
|
|
148
|
+
}
|
|
149
|
+
evictIfOverCap() {
|
|
150
|
+
while (this.entries.size > this.maxEntries) {
|
|
151
|
+
const oldest = this.entries.keys().next().value;
|
|
152
|
+
if (oldest === undefined)
|
|
153
|
+
break;
|
|
154
|
+
this.entries.delete(oldest);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
|
|
160
|
+
*
|
|
161
|
+
* Precedence (highest first):
|
|
162
|
+
* 1. Explicit argument (typically from CLI flag)
|
|
163
|
+
* 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
|
|
164
|
+
* DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
|
|
165
|
+
* 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
|
|
166
|
+
* perClient=false — exactly matches the hardcoded v3.27 behaviour.
|
|
167
|
+
*
|
|
168
|
+
* Invalid numeric strings fall through to the next source. For perClient,
|
|
169
|
+
* '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
|
|
170
|
+
* the explicit or default value.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveSessionRotationConfig(explicit = {}, env = process.env) {
|
|
173
|
+
const idleRotateMs = pickNonNegativeInt(explicit.idleRotateMs, env.DARIO_SESSION_IDLE_ROTATE_MS) ?? 15 * 60 * 1000;
|
|
174
|
+
const jitterMs = pickNonNegativeInt(explicit.jitterMs, env.DARIO_SESSION_JITTER_MS) ?? 0;
|
|
175
|
+
const maxAgeMs = pickPositiveInt(explicit.maxAgeMs, env.DARIO_SESSION_MAX_AGE_MS);
|
|
176
|
+
const perClient = pickBool(explicit.perClient, env.DARIO_SESSION_PER_CLIENT) ?? false;
|
|
177
|
+
return { idleRotateMs, jitterMs, maxAgeMs, perClient };
|
|
178
|
+
}
|
|
179
|
+
function pickNonNegativeInt(...candidates) {
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
if (c === undefined || c === null || c === '')
|
|
182
|
+
continue;
|
|
183
|
+
const n = typeof c === 'number' ? c : parseInt(c, 10);
|
|
184
|
+
if (Number.isFinite(n) && n >= 0)
|
|
185
|
+
return Math.floor(n);
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
function pickPositiveInt(...candidates) {
|
|
190
|
+
for (const c of candidates) {
|
|
191
|
+
if (c === undefined || c === null || c === '')
|
|
192
|
+
continue;
|
|
193
|
+
const n = typeof c === 'number' ? c : parseInt(c, 10);
|
|
194
|
+
if (Number.isFinite(n) && n > 0)
|
|
195
|
+
return Math.floor(n);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
function pickBool(...candidates) {
|
|
200
|
+
for (const c of candidates) {
|
|
201
|
+
if (c === undefined || c === null)
|
|
202
|
+
continue;
|
|
203
|
+
if (typeof c === 'boolean')
|
|
204
|
+
return c;
|
|
205
|
+
const s = c.trim().toLowerCase();
|
|
206
|
+
if (s === '')
|
|
207
|
+
continue;
|
|
208
|
+
if (s === '1' || s === 'true' || s === 'yes' || s === 'on')
|
|
209
|
+
return true;
|
|
210
|
+
if (s === '0' || s === 'false' || s === 'no' || s === 'off')
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.0",
|
|
4
4
|
"description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
|
|
24
|
-
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
|
|
24
|
+
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
|
|
25
25
|
"audit": "npm audit --production --audit-level=high",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
27
|
"start": "node dist/cli.js",
|