@code-yeongyu/senpi 2026.10.24-2 → 2026.10.24-4
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/CHANGELOG.md +37 -0
- package/dist/bundle/chunks/{args-6T5EQVY3.js → args-JXULI4EH.js} +1 -1
- package/dist/bundle/chunks/{chunk-VUKNGMDT.js → chunk-3LNSLJ7N.js} +1 -1
- package/dist/bundle/chunks/chunk-3VOG6WFU.js +27 -0
- package/dist/bundle/chunks/{chunk-HHYSXDYP.js → chunk-42OY5SZZ.js} +2 -2
- package/dist/bundle/chunks/chunk-7IRLFYNM.js +23 -0
- package/dist/bundle/chunks/{chunk-FIBIVBQK.js → chunk-7UUOGZKR.js} +1 -1
- package/dist/bundle/chunks/chunk-BMYNMUVZ.js +7 -0
- package/dist/bundle/chunks/{chunk-JAXZOEJE.js → chunk-BWA4FVFW.js} +1 -1
- package/dist/bundle/chunks/{chunk-AGQSRWNN.js → chunk-EHQXLHRB.js} +1 -1
- package/dist/bundle/chunks/chunk-EWOEVVQT.js +4 -0
- package/dist/bundle/chunks/{chunk-YJA2B7FQ.js → chunk-FDL4CF6F.js} +1 -1
- package/dist/bundle/chunks/{chunk-RQHK4QZ5.js → chunk-FWXMMMCV.js} +1 -1
- package/dist/bundle/chunks/{chunk-OA5INWHI.js → chunk-GVJ76VQI.js} +102 -42
- package/dist/bundle/chunks/{chunk-HCF5M6NA.js → chunk-GXY5JEI5.js} +1 -1
- package/dist/bundle/chunks/{chunk-VZAYN5SB.js → chunk-HKUXKTFW.js} +1 -1
- package/dist/bundle/chunks/chunk-II22E3CV.js +3 -0
- package/dist/bundle/chunks/{chunk-ONLNXGO3.js → chunk-IUHU5PEC.js} +1 -1
- package/dist/bundle/chunks/{chunk-LR6Z6V54.js → chunk-NQC7MI2G.js} +1 -1
- package/dist/bundle/chunks/{chunk-IELM5U6I.js → chunk-PXKF2RHD.js} +2 -2
- package/dist/bundle/chunks/{chunk-BRXN5R2F.js → chunk-QH2HSCMX.js} +1 -1
- package/dist/bundle/chunks/{chunk-QQSCUEYJ.js → chunk-RHHBXIRN.js} +2 -5
- package/dist/bundle/chunks/chunk-RVYSJBZ7.js +7 -0
- package/dist/bundle/chunks/chunk-SGDOZT6B.js +8 -0
- package/dist/bundle/chunks/{chunk-BZ75DRZB.js → chunk-U67ML6CQ.js} +4 -2
- package/dist/bundle/chunks/{chunk-3N2PQEQV.js → chunk-UT6AJHPK.js} +1 -1
- package/dist/bundle/chunks/{chunk-RAIZG6HA.js → chunk-V3CK2AVC.js} +1 -1
- package/dist/bundle/chunks/{chunk-CZR7C6CY.js → chunk-VOBD6FQG.js} +1 -1
- package/dist/bundle/chunks/{chunk-HRPMLI7O.js → chunk-VRDU425E.js} +2 -2
- package/dist/bundle/chunks/{chunk-OUPD5LWY.js → chunk-XUFUKDKJ.js} +3 -3
- package/dist/bundle/chunks/{chunk-SGATKHO4.js → chunk-Y5OTHMXO.js} +1 -1
- package/dist/bundle/chunks/{chunk-QEQG42HL.js → chunk-ZGAB7WQA.js} +1 -1
- package/dist/bundle/chunks/cli-main-UH3MAREK.js +3 -0
- package/dist/bundle/chunks/{dispatch-KQEGIXQW.js → dispatch-DEP4PEDZ.js} +3 -2
- package/dist/bundle/chunks/{help-fast-path-6PVN7WI5.js → help-fast-path-ZHZGXAUG.js} +1 -1
- package/dist/bundle/chunks/host-command-OQR4RDTM.js +2 -0
- package/dist/bundle/chunks/host-lifecycle-U36U7JTE.js +3 -0
- package/dist/bundle/chunks/host-lifecycle.js +26 -21
- package/dist/bundle/chunks/host-session-command-IGB27ZPJ.js +20 -0
- package/dist/bundle/chunks/{host-socket-request-KKGH3JHV.js → host-socket-request-ZDJGSSZO.js} +1 -1
- package/dist/bundle/chunks/interactive-mode-R5RCSJ2F.js +2 -0
- package/dist/bundle/chunks/{list-tips-BO5RP34W.js → list-tips-CREX4IXZ.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-O5GZQSRL.js → multi-session-host-YZEMYUFY.js} +12 -12
- package/dist/bundle/chunks/{package-manager-cli-LC5GAGTW.js → package-manager-cli-5FOXDKUA.js} +1 -1
- package/dist/bundle/chunks/{role-4G43UDOC.js → role-M2MTVACJ.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-RXMCJJUL.js → rotation-stream-U7YQMC7H.js} +1 -1
- package/dist/bundle/chunks/rpc-mode-6K62CZBW.js +2 -0
- package/dist/bundle/chunks/rpc-monitor-source-OL5KU6NO.js +3 -0
- package/dist/bundle/chunks/{schedule-command-QNCVE4HM.js → schedule-command-XBW4EOG5.js} +1 -1
- package/dist/bundle/chunks/session-control-endpoint-54TMCS7Q.js +3 -0
- package/dist/bundle/chunks/{session-picker-HIC5ZTE6.js → session-picker-P2VUU2MQ.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +437 -368
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/host-supervisor.js +26 -21
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +27 -0
- package/dist/cli/host-command-args.d.ts +33 -0
- package/dist/cli/host-command-args.js +145 -0
- package/dist/cli/host-command.d.ts +1 -30
- package/dist/cli/host-command.js +5 -137
- package/dist/cli/host-session-command.d.ts +1 -0
- package/dist/cli/host-session-command.js +5 -3
- package/dist/core/agent-session.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/index.js +4 -2
- package/dist/core/extensions/builtin/anthropic-subscription/options.js +2 -1
- package/dist/core/extensions/builtin/anthropic-subscription/sdk-boundary.js +9 -2
- package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.d.ts +3 -0
- package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.js +9 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +3 -1
- package/dist/core/extensions/builtin/anthropic-subscription/stream.js +4 -1
- package/dist/core/extensions/builtin/btw/index.js +4 -1
- package/dist/core/extensions/builtin/compaction/lane-policy.js +3 -1
- package/dist/core/extensions/builtin/look-at/runner.js +1 -0
- package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
- package/dist/core/extensions/builtin/mcp/connection.js +3 -0
- package/dist/core/extensions/builtin/mcp/elicitation.d.ts +5 -2
- package/dist/core/extensions/builtin/mcp/elicitation.js +57 -11
- package/dist/core/extensions/builtin/mcp/index.js +1 -1
- package/dist/core/extensions/builtin/mcp/modern/dispatch.js +6 -9
- package/dist/core/extensions/builtin/mcp/service-connection.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/service-connection.js +1 -0
- package/dist/core/extensions/builtin/mcp/service.js +1 -0
- package/dist/core/extensions/builtin/mcp/transport-sdk.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/transport-sdk.js +5 -5
- package/dist/core/extensions/builtin/mcp/transport.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/transport.js +1 -0
- package/dist/core/extensions/builtin/prompt-preset/contract.js +6 -1
- package/dist/core/extensions/builtin/prompt-preset/kimi-k3-1.d.ts +3 -0
- package/dist/core/extensions/builtin/prompt-preset/kimi-k3-1.js +93 -0
- package/dist/core/extensions/builtin/prompt-preset/matchers.d.ts +2 -0
- package/dist/core/extensions/builtin/prompt-preset/matchers.js +9 -0
- package/dist/core/extensions/builtin/prompt-preset/presets.js +3 -0
- package/dist/core/extensions/builtin/prompt-preset/settings.d.ts +1 -1
- package/dist/core/extensions/builtin/prompt-preset/settings.js +1 -0
- package/dist/core/extensions/builtin/terminal/rpc-monitor-observer.d.ts +2 -1
- package/dist/core/extensions/builtin/terminal/rpc-monitor-observer.js +12 -4
- package/dist/core/external-admission.d.ts +4 -1
- package/dist/core/external-admission.js +18 -2
- package/dist/core/lifecycle-session-binding.js +20 -0
- package/dist/core/sdk.js +1 -0
- package/dist/core/session-spawned-by.d.ts +4 -0
- package/dist/core/session-spawned-by.js +16 -0
- package/dist/main.js +7 -2
- package/dist/modes/rpc/host-admission-proxy.d.ts +2 -1
- package/dist/modes/rpc/host-admission-proxy.js +10 -9
- package/dist/modes/rpc/host-cli-entry.d.ts +2 -0
- package/dist/modes/rpc/host-cli-entry.js +13 -1
- package/dist/modes/rpc/host-ensure-client.d.ts +5 -1
- package/dist/modes/rpc/host-ensure-client.js +8 -0
- package/dist/modes/rpc/host-ensure-types.d.ts +1 -0
- package/dist/modes/rpc/host-ensure.js +6 -11
- package/dist/modes/rpc/host-gc-pass.js +3 -0
- package/dist/modes/rpc/host-gc.js +3 -0
- package/dist/modes/rpc/host-internal-transport.d.ts +16 -5
- package/dist/modes/rpc/host-internal-transport.js +40 -9
- package/dist/modes/rpc/host-lifecycle-proxy.d.ts +4 -8
- package/dist/modes/rpc/host-lifecycle-proxy.js +9 -21
- package/dist/modes/rpc/host-lifecycle-shutdown.d.ts +2 -0
- package/dist/modes/rpc/host-lifecycle-shutdown.js +3 -0
- package/dist/modes/rpc/host-lifecycle.js +9 -16
- package/dist/modes/rpc/host-observe-request.js +1 -1
- package/dist/modes/rpc/host-probe.d.ts +8 -6
- package/dist/modes/rpc/host-probe.js +13 -13
- package/dist/modes/rpc/host-request-key-root.d.ts +8 -0
- package/dist/modes/rpc/host-request-key-root.js +14 -0
- package/dist/modes/rpc/host-runner.d.ts +3 -1
- package/dist/modes/rpc/host-runner.js +6 -1
- package/dist/modes/rpc/host-socket-bind.d.ts +6 -0
- package/dist/modes/rpc/host-socket-bind.js +42 -0
- package/dist/modes/rpc/host-socket-request.d.ts +3 -2
- package/dist/modes/rpc/host-socket-request.js +55 -10
- package/dist/modes/rpc/host-status.d.ts +3 -2
- package/dist/modes/rpc/host-status.js +5 -0
- package/dist/modes/rpc/host-supervisor-log.d.ts +1 -0
- package/dist/modes/rpc/host-supervisor-log.js +3 -0
- package/dist/modes/rpc/host-watchdog.d.ts +8 -1
- package/dist/modes/rpc/host-watchdog.js +37 -12
- package/dist/modes/rpc/internal-channel.js +14 -14
- package/dist/modes/rpc/multi-session-host.js +18 -46
- package/dist/modes/rpc/rpc-auth-client.d.ts +13 -0
- package/dist/modes/rpc/rpc-auth-client.js +142 -0
- package/dist/modes/rpc/rpc-auth-proof.d.ts +12 -0
- package/dist/modes/rpc/rpc-auth-proof.js +27 -0
- package/dist/modes/rpc/rpc-client-socket.d.ts +5 -0
- package/dist/modes/rpc/rpc-client-socket.js +30 -0
- package/dist/modes/rpc/rpc-client.d.ts +2 -0
- package/dist/modes/rpc/rpc-client.js +2 -24
- package/dist/modes/rpc/rpc-host-command-types.d.ts +4 -0
- package/dist/modes/rpc/rpc-protocol-info-types.d.ts +6 -0
- package/dist/modes/rpc/rpc-public-auth.d.ts +41 -0
- package/dist/modes/rpc/rpc-public-auth.js +237 -0
- package/dist/modes/rpc/rpc-public-key.d.ts +28 -0
- package/dist/modes/rpc/rpc-public-key.js +193 -0
- package/dist/modes/rpc/session-router-open-profile.js +2 -10
- package/dist/modes/rpc/socket-ownership.d.ts +7 -0
- package/dist/modes/rpc/socket-ownership.js +17 -0
- package/docs/rpc.md +108 -2
- package/docs/settings.md +1 -1
- package/docs/usage.md +14 -0
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-3474DPZK.js +0 -2
- package/dist/bundle/chunks/chunk-64CFJHKT.js +0 -7
- package/dist/bundle/chunks/chunk-B6NJHBLH.js +0 -21
- package/dist/bundle/chunks/chunk-HZTQHE5O.js +0 -26
- package/dist/bundle/chunks/chunk-IBDADH5Z.js +0 -4
- package/dist/bundle/chunks/chunk-OJZD7CO7.js +0 -8
- package/dist/bundle/chunks/cli-main-4JNPG6LB.js +0 -3
- package/dist/bundle/chunks/host-command-I3ZMWQQ3.js +0 -2
- package/dist/bundle/chunks/host-lifecycle-QEO422VW.js +0 -3
- package/dist/bundle/chunks/host-session-command-INULKX6E.js +0 -20
- package/dist/bundle/chunks/interactive-mode-SYWJVPKK.js +0 -2
- package/dist/bundle/chunks/rpc-mode-JMCYSP4P.js +0 -2
- package/dist/bundle/chunks/rpc-monitor-source-34AQDTZ6.js +0 -3
- package/dist/bundle/chunks/session-control-endpoint-IW5X56WN.js +0 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { Duplex, PassThrough } from "node:stream";
|
|
3
|
+
import { attachJsonlLineReader, MAX_RPC_LINE_CHARACTERS, serializeJsonLine } from "./jsonl.js";
|
|
4
|
+
import { authRecord, RPC_AUTH_CAPABILITY, RPC_AUTH_HEX, rpcAuthProof, rpcProofMatches } from "./rpc-auth-proof.js";
|
|
5
|
+
import { SOCKET_HANDSHAKE_TIMEOUT_MS } from "./socket-transport.js";
|
|
6
|
+
const PREAUTH_BYTES = 8 * 1024;
|
|
7
|
+
/** Filters public input before it can reach either the proxy or the bare host's router. */
|
|
8
|
+
export class PublicRpcConnection {
|
|
9
|
+
/**
|
|
10
|
+
* The admitted connection as one duplex for a router that reads and writes a single stream: it reads only
|
|
11
|
+
* frames this filter released and writes through `response()`, so the challenge answer gains its proof.
|
|
12
|
+
*/
|
|
13
|
+
get stream() {
|
|
14
|
+
this.view ??= this.duplex();
|
|
15
|
+
return this.view;
|
|
16
|
+
}
|
|
17
|
+
constructor(socket, endpoint, requireToken = process.env.SENPI_RPC_REQUIRE_TOKEN === "1") {
|
|
18
|
+
this.input = new PassThrough();
|
|
19
|
+
this.raw = new PassThrough();
|
|
20
|
+
this.bytes = 0;
|
|
21
|
+
this.active = false;
|
|
22
|
+
this.failed = false;
|
|
23
|
+
this.first = true;
|
|
24
|
+
this.protocolPending = false;
|
|
25
|
+
this.socket = socket;
|
|
26
|
+
this.endpoint = endpoint;
|
|
27
|
+
this.requireToken = requireToken;
|
|
28
|
+
socket.pause();
|
|
29
|
+
// Enforcing: a client that has not proven the key by the deadline is dropped. Grace: a silent or slow legacy client
|
|
30
|
+
// (a passive observer) is served as before, with its one deprecation line; a challenge in flight is left to
|
|
31
|
+
// finish, so its `authenticate` still reaches this filter instead of the router.
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
if (this.requireToken)
|
|
34
|
+
this.drop("pre-auth deadline");
|
|
35
|
+
else if (!this.active && !this.failed && this.challenge === undefined)
|
|
36
|
+
this.grace();
|
|
37
|
+
}, SOCKET_HANDSHAKE_TIMEOUT_MS);
|
|
38
|
+
const detach = attachJsonlLineReader(this.raw, (line) => this.line(line), {
|
|
39
|
+
maxLineLength: MAX_RPC_LINE_CHARACTERS,
|
|
40
|
+
onOversizedLine: () => (this.active ? socket.destroy() : this.drop("first frame over the line limit")),
|
|
41
|
+
});
|
|
42
|
+
socket.on("error", () => socket.destroy());
|
|
43
|
+
socket.once("close", () => {
|
|
44
|
+
if (!this.active && !this.failed && !this.requireToken && !this.first)
|
|
45
|
+
this.grace();
|
|
46
|
+
clearTimeout(this.timer);
|
|
47
|
+
detach();
|
|
48
|
+
this.raw.destroy();
|
|
49
|
+
this.input.end();
|
|
50
|
+
});
|
|
51
|
+
socket.once("end", () => this.raw.end());
|
|
52
|
+
this.input.on("drain", () => socket.resume());
|
|
53
|
+
socket.on("data", (chunk) => {
|
|
54
|
+
// Feed a coalesced chunk one LF segment at a time: proof verification can remove
|
|
55
|
+
// the 8 KiB budget before a large authenticated command in that same chunk.
|
|
56
|
+
for (let offset = 0; offset < chunk.length && !this.failed;) {
|
|
57
|
+
const newline = chunk.indexOf(0x0a, offset);
|
|
58
|
+
const end = newline === -1 ? chunk.length : newline + 1;
|
|
59
|
+
const segment = chunk.subarray(offset, end);
|
|
60
|
+
if (!this.active)
|
|
61
|
+
this.bytes += segment.length;
|
|
62
|
+
// Only enforcement bounds pre-auth input: in grace a keyless legacy client is served whatever its first frame
|
|
63
|
+
// (a long prompt, an image), with or without a challenge it could not answer (no key record).
|
|
64
|
+
if (!this.active && this.requireToken && this.bytes > PREAUTH_BYTES) {
|
|
65
|
+
this.drop("pre-auth budget exceeded");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.raw.write(segment);
|
|
69
|
+
offset = end;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
void endpoint.ready.then(() => {
|
|
73
|
+
if (!socket.destroyed)
|
|
74
|
+
socket.resume();
|
|
75
|
+
}, () => this.drop("key publication failed"));
|
|
76
|
+
}
|
|
77
|
+
duplex() {
|
|
78
|
+
const input = this.input;
|
|
79
|
+
const stream = new Duplex({
|
|
80
|
+
read: () => input.resume(),
|
|
81
|
+
write: (chunk, _encoding, callback) => {
|
|
82
|
+
const output = this.response(chunk.toString());
|
|
83
|
+
if (output === "")
|
|
84
|
+
callback();
|
|
85
|
+
else
|
|
86
|
+
this.socket.write(output, callback);
|
|
87
|
+
},
|
|
88
|
+
final: (callback) => this.socket.end(callback),
|
|
89
|
+
destroy: (error, callback) => {
|
|
90
|
+
this.socket.destroy();
|
|
91
|
+
callback(error);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
input.on("data", (chunk) => {
|
|
95
|
+
if (!stream.push(chunk))
|
|
96
|
+
input.pause();
|
|
97
|
+
});
|
|
98
|
+
// Released frames already queued when the peer goes away are still delivered, then the view closes.
|
|
99
|
+
input.once("end", () => {
|
|
100
|
+
stream.push(null);
|
|
101
|
+
stream.destroy();
|
|
102
|
+
});
|
|
103
|
+
if (input.readableEnded)
|
|
104
|
+
stream.destroy();
|
|
105
|
+
return stream;
|
|
106
|
+
}
|
|
107
|
+
line(line) {
|
|
108
|
+
if (this.failed || this.socket.destroyed)
|
|
109
|
+
return;
|
|
110
|
+
let request;
|
|
111
|
+
try {
|
|
112
|
+
request = authRecord(JSON.parse(line));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Malformed JSON is left to the existing router only after admission.
|
|
116
|
+
}
|
|
117
|
+
if (!this.active) {
|
|
118
|
+
if (this.first && request?.type === "get_protocol_info") {
|
|
119
|
+
this.first = false;
|
|
120
|
+
this.protocolPending = true;
|
|
121
|
+
const nonce = authRecord(request.auth)?.client_nonce;
|
|
122
|
+
if (nonce !== undefined) {
|
|
123
|
+
if (typeof nonce !== "string" || !RPC_AUTH_HEX.test(nonce)) {
|
|
124
|
+
this.refuse(request, "auth_failed");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.challenge = { id: request.id, client: nonce, server: randomBytes(32).toString("hex") };
|
|
128
|
+
}
|
|
129
|
+
else if (this.requireToken) {
|
|
130
|
+
// Wire contract frame 5: under enforcement any other first frame, a keyless get_protocol_info included.
|
|
131
|
+
this.refuse(request, "auth_required");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
this.grace();
|
|
136
|
+
this.forward(line);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.first = false;
|
|
140
|
+
if (request?.type === "authenticate") {
|
|
141
|
+
const c = this.challenge;
|
|
142
|
+
if (!c ||
|
|
143
|
+
!rpcProofMatches(rpcAuthProof(this.endpoint.key, "client", this.endpoint.launchId, c.server, c.client), request.client_proof)) {
|
|
144
|
+
this.refuse(request, "auth_failed");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.active = true;
|
|
148
|
+
clearTimeout(this.timer);
|
|
149
|
+
this.socket.write(serializeJsonLine({ id: request.id, type: "response", command: "authenticate", success: true }));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.requireToken) {
|
|
153
|
+
this.refuse(request, "auth_required");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.grace();
|
|
157
|
+
}
|
|
158
|
+
this.forward(line);
|
|
159
|
+
}
|
|
160
|
+
forward(line) {
|
|
161
|
+
if (!this.input.write(`${line}\n`))
|
|
162
|
+
this.socket.pause();
|
|
163
|
+
}
|
|
164
|
+
grace() {
|
|
165
|
+
this.active = true;
|
|
166
|
+
clearTimeout(this.timer);
|
|
167
|
+
process.stderr.write(`senpi rpc host: served a client that did not prove the endpoint key on ${this.endpoint.endpoint} (deprecated, refused from the next release; see docs/rpc.md#authentication)\n`);
|
|
168
|
+
}
|
|
169
|
+
/** Every pre-auth drop says why on stderr, by reason code only: never a frame, a nonce or key material. */
|
|
170
|
+
logDrop(reason) {
|
|
171
|
+
process.stderr.write(`senpi rpc host: dropped an unauthenticated connection on ${this.endpoint.endpoint}: ${reason}\n`);
|
|
172
|
+
}
|
|
173
|
+
drop(reason) {
|
|
174
|
+
if (this.failed)
|
|
175
|
+
return;
|
|
176
|
+
this.failed = true;
|
|
177
|
+
clearTimeout(this.timer);
|
|
178
|
+
this.logDrop(reason);
|
|
179
|
+
this.socket.destroy();
|
|
180
|
+
}
|
|
181
|
+
refuse(request, code) {
|
|
182
|
+
this.failed = true;
|
|
183
|
+
clearTimeout(this.timer);
|
|
184
|
+
this.logDrop(code);
|
|
185
|
+
this.socket.end(serializeJsonLine({
|
|
186
|
+
id: request?.id,
|
|
187
|
+
type: "response",
|
|
188
|
+
command: request?.type ?? "parse",
|
|
189
|
+
success: false,
|
|
190
|
+
error: code,
|
|
191
|
+
errorCode: code,
|
|
192
|
+
}), () => this.socket.destroy());
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Writer/proxy batches contain whole JSONL records; ordinary authenticated bytes stay untouched. Before
|
|
196
|
+
* admission only the challenge answer leaves an enforcing host; a grace host keeps delivering records (the
|
|
197
|
+
* lifecycle broadcasts a passive legacy observer relies on).
|
|
198
|
+
*/
|
|
199
|
+
response(chunk) {
|
|
200
|
+
if (this.failed)
|
|
201
|
+
return "";
|
|
202
|
+
if (this.active && !this.protocolPending)
|
|
203
|
+
return chunk;
|
|
204
|
+
let output = "";
|
|
205
|
+
for (const line of chunk.split("\n")) {
|
|
206
|
+
if (!line)
|
|
207
|
+
continue;
|
|
208
|
+
const record = authRecord(JSON.parse(line));
|
|
209
|
+
if (record?.type === "response" && record.command === "get_protocol_info" && record.success === true) {
|
|
210
|
+
this.protocolPending = false;
|
|
211
|
+
const data = authRecord(record.data) ?? {};
|
|
212
|
+
const capabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
|
213
|
+
const c = this.challenge;
|
|
214
|
+
output += serializeJsonLine({
|
|
215
|
+
...record,
|
|
216
|
+
data: {
|
|
217
|
+
...data,
|
|
218
|
+
capabilities: [...new Set([...capabilities, RPC_AUTH_CAPABILITY])],
|
|
219
|
+
...(c && record.id === c.id
|
|
220
|
+
? {
|
|
221
|
+
auth: {
|
|
222
|
+
launch_id: this.endpoint.launchId,
|
|
223
|
+
server_nonce: c.server,
|
|
224
|
+
server_proof: rpcAuthProof(this.endpoint.key, "server", this.endpoint.launchId, c.client, c.server),
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
: {}),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else if (this.active || !this.requireToken)
|
|
232
|
+
output += `${line}\n`;
|
|
233
|
+
}
|
|
234
|
+
return output;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=rpc-public-auth.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-root rule (#3795, shared with the desktop and omo clients): `<root>` and `<root>/rpc` are checked at their
|
|
3
|
+
* realpath and must be directories owned by this user with no group/other write (0755 is fine); `<root>/rpc/keys`
|
|
4
|
+
* itself must be a real (non-symlink) directory owned by this user with mode exactly 0700.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkRpcKeyDirectory(path: string, privateKeys?: boolean): Promise<void>;
|
|
7
|
+
export declare function readRpcKeyRecord(root: string, launchId: string): Promise<Readonly<Record<string, unknown>> | undefined>;
|
|
8
|
+
/** One public binder owns one in-memory key; it never reads a key back from disk. */
|
|
9
|
+
export declare class RpcPublicEndpoint {
|
|
10
|
+
readonly key: NonSharedBuffer;
|
|
11
|
+
readonly launchId: `${string}-${string}-${string}-${string}-${string}`;
|
|
12
|
+
readonly recordPath: string;
|
|
13
|
+
readonly ready: Promise<void>;
|
|
14
|
+
private readonly publication;
|
|
15
|
+
private created;
|
|
16
|
+
private readonly exitCleanup;
|
|
17
|
+
readonly agentDir: string;
|
|
18
|
+
readonly endpoint: string;
|
|
19
|
+
constructor(agentDir: string, endpoint: string);
|
|
20
|
+
publish(): Promise<void>;
|
|
21
|
+
dispose(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Unknown process identity keeps a record; only death or observed PID reuse permits collection. */
|
|
24
|
+
export declare function gcRpcPublicKeys(agentDir: string, budget?: {
|
|
25
|
+
readonly deadline?: number;
|
|
26
|
+
readonly maxRecords?: number;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
//# sourceMappingURL=rpc-public-key.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, unlinkSync } from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ProcessIdentityUnreadableError, processMatchesPidFile, readProcessStartTime, } from "../../utils/process-identity.js";
|
|
6
|
+
import { authRecord, RPC_AUTH_HEX, RPC_LAUNCH_ID, RpcEndpointAuthError } from "./rpc-auth-proof.js";
|
|
7
|
+
const MAX_KEY_RECORD_BYTES = 4096;
|
|
8
|
+
/**
|
|
9
|
+
* Key-root rule (#3795, shared with the desktop and omo clients): `<root>` and `<root>/rpc` are checked at their
|
|
10
|
+
* realpath and must be directories owned by this user with no group/other write (0755 is fine); `<root>/rpc/keys`
|
|
11
|
+
* itself must be a real (non-symlink) directory owned by this user with mode exactly 0700.
|
|
12
|
+
*/
|
|
13
|
+
export async function checkRpcKeyDirectory(path, privateKeys = false) {
|
|
14
|
+
const checked = privateKeys ? path : await fs.realpath(path);
|
|
15
|
+
const problem = keyDirectoryProblem(await fs.lstat(checked), checked, privateKeys);
|
|
16
|
+
if (problem !== undefined)
|
|
17
|
+
throw new RpcEndpointAuthError("unsafe_key_record", `${checked} ${problem}`);
|
|
18
|
+
}
|
|
19
|
+
/** What is wrong with one key-root directory, worded so the message names the fix (senpi#3815 review L6). */
|
|
20
|
+
function keyDirectoryProblem(info, path, privateKeys) {
|
|
21
|
+
if (!info.isDirectory())
|
|
22
|
+
return "is not a directory";
|
|
23
|
+
if (info.uid !== process.getuid?.())
|
|
24
|
+
return "is not owned by this user";
|
|
25
|
+
const mode = info.mode & 0o7777;
|
|
26
|
+
if (privateKeys)
|
|
27
|
+
return mode === 0o700 ? undefined : `must be mode 0700 (is 0${mode.toString(8)}); run: chmod 700 ${path}`;
|
|
28
|
+
return (mode & 0o022) === 0 ? undefined : `is writable by group or others; run: chmod go-w ${path}`;
|
|
29
|
+
}
|
|
30
|
+
async function checkRpcKeyParents(root) {
|
|
31
|
+
await checkRpcKeyDirectory(root);
|
|
32
|
+
await checkRpcKeyDirectory(join(root, "rpc"));
|
|
33
|
+
}
|
|
34
|
+
export async function readRpcKeyRecord(root, launchId) {
|
|
35
|
+
if (!RPC_LAUNCH_ID.test(launchId))
|
|
36
|
+
throw new RpcEndpointAuthError("unsafe_key_record", `invalid launch id under ${root}`);
|
|
37
|
+
const path = join(root, "rpc", "keys", launchId);
|
|
38
|
+
try {
|
|
39
|
+
await checkRpcKeyParents(root);
|
|
40
|
+
await checkRpcKeyDirectory(join(root, "rpc", "keys"), true);
|
|
41
|
+
const handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
42
|
+
try {
|
|
43
|
+
const info = await handle.stat();
|
|
44
|
+
if (!info.isFile() ||
|
|
45
|
+
info.uid !== process.getuid?.() ||
|
|
46
|
+
(info.mode & 0o7777) !== 0o600 ||
|
|
47
|
+
info.size > MAX_KEY_RECORD_BYTES)
|
|
48
|
+
throw new RpcEndpointAuthError("unsafe_key_record", path);
|
|
49
|
+
const record = authRecord(JSON.parse(await handle.readFile("utf8")));
|
|
50
|
+
if (record?.version !== 1 ||
|
|
51
|
+
record.launch_id !== launchId ||
|
|
52
|
+
typeof record.key !== "string" ||
|
|
53
|
+
!RPC_AUTH_HEX.test(record.key))
|
|
54
|
+
throw new RpcEndpointAuthError("unsafe_key_record", path);
|
|
55
|
+
return record;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await handle.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
63
|
+
return undefined;
|
|
64
|
+
if (cause instanceof RpcEndpointAuthError)
|
|
65
|
+
throw cause;
|
|
66
|
+
throw new RpcEndpointAuthError("unsafe_key_record", path, { cause });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** One public binder owns one in-memory key; it never reads a key back from disk. */
|
|
70
|
+
export class RpcPublicEndpoint {
|
|
71
|
+
constructor(agentDir, endpoint) {
|
|
72
|
+
this.key = randomBytes(32);
|
|
73
|
+
this.launchId = randomUUID();
|
|
74
|
+
this.publication = Promise.withResolvers();
|
|
75
|
+
this.created = false;
|
|
76
|
+
this.exitCleanup = () => {
|
|
77
|
+
if (!this.created)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(this.recordPath);
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
84
|
+
process.stderr.write("senpi rpc host: could not remove own endpoint key record\n");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
this.agentDir = agentDir;
|
|
88
|
+
this.endpoint = endpoint;
|
|
89
|
+
this.recordPath = join(agentDir, "rpc", "keys", this.launchId);
|
|
90
|
+
this.ready = this.publication.promise;
|
|
91
|
+
this.ready.catch(() => { });
|
|
92
|
+
}
|
|
93
|
+
async publish() {
|
|
94
|
+
try {
|
|
95
|
+
await fs.mkdir(join(this.agentDir, "rpc"), { recursive: true, mode: 0o700 });
|
|
96
|
+
await checkRpcKeyParents(this.agentDir);
|
|
97
|
+
const keys = join(this.agentDir, "rpc", "keys");
|
|
98
|
+
await fs.mkdir(keys, { recursive: true, mode: 0o700 });
|
|
99
|
+
// An existing keys directory this user owns is tightened; a symlink or a foreign one is refused.
|
|
100
|
+
const directory = await fs.lstat(keys);
|
|
101
|
+
if (!directory.isDirectory() || directory.uid !== process.getuid?.())
|
|
102
|
+
throw new RpcEndpointAuthError("unsafe_key_record", keys);
|
|
103
|
+
if ((directory.mode & 0o7777) !== 0o700)
|
|
104
|
+
await fs.chmod(keys, 0o700);
|
|
105
|
+
await checkRpcKeyDirectory(keys, true);
|
|
106
|
+
const handle = await fs.open(this.recordPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
107
|
+
this.created = true;
|
|
108
|
+
process.once("exit", this.exitCleanup);
|
|
109
|
+
try {
|
|
110
|
+
await handle.writeFile(JSON.stringify({
|
|
111
|
+
version: 1,
|
|
112
|
+
launch_id: this.launchId,
|
|
113
|
+
key: this.key.toString("hex"),
|
|
114
|
+
pid: process.pid,
|
|
115
|
+
process_start: (await readProcessStartTime(process.pid)) ?? null,
|
|
116
|
+
endpoint: this.endpoint,
|
|
117
|
+
created_at: new Date().toISOString(),
|
|
118
|
+
}));
|
|
119
|
+
await handle.sync();
|
|
120
|
+
const info = await handle.stat();
|
|
121
|
+
if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o7777) !== 0o600)
|
|
122
|
+
throw new RpcEndpointAuthError("unsafe_key_record", this.recordPath);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await handle.close();
|
|
126
|
+
}
|
|
127
|
+
this.publication.resolve();
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
const error = new RpcEndpointAuthError("key_publication_failed", cause instanceof RpcEndpointAuthError ? cause.message : this.recordPath, { cause });
|
|
131
|
+
this.publication.reject(error);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async dispose() {
|
|
136
|
+
process.off("exit", this.exitCleanup);
|
|
137
|
+
if (!this.created)
|
|
138
|
+
return;
|
|
139
|
+
this.created = false;
|
|
140
|
+
await fs.unlink(this.recordPath).catch((cause) => {
|
|
141
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
142
|
+
throw cause;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Unknown process identity keeps a record; only death or observed PID reuse permits collection. */
|
|
147
|
+
export async function gcRpcPublicKeys(agentDir, budget = {}) {
|
|
148
|
+
const keys = join(agentDir, "rpc", "keys");
|
|
149
|
+
try {
|
|
150
|
+
await checkRpcKeyParents(agentDir);
|
|
151
|
+
await checkRpcKeyDirectory(keys, true);
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
if (cause instanceof RpcEndpointAuthError)
|
|
155
|
+
return;
|
|
156
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
157
|
+
return;
|
|
158
|
+
throw cause;
|
|
159
|
+
}
|
|
160
|
+
const names = await fs.readdir(keys).catch((cause) => {
|
|
161
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
162
|
+
return [];
|
|
163
|
+
throw cause;
|
|
164
|
+
});
|
|
165
|
+
let examined = 0;
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
if (Date.now() >= (budget.deadline ?? Infinity) || examined >= (budget.maxRecords ?? Infinity))
|
|
168
|
+
break;
|
|
169
|
+
if (!RPC_LAUNCH_ID.test(name))
|
|
170
|
+
continue;
|
|
171
|
+
examined++;
|
|
172
|
+
try {
|
|
173
|
+
const record = await readRpcKeyRecord(agentDir, name);
|
|
174
|
+
if (record === undefined ||
|
|
175
|
+
typeof record.pid !== "number" ||
|
|
176
|
+
!Number.isSafeInteger(record.pid) ||
|
|
177
|
+
record.pid <= 0 ||
|
|
178
|
+
!(record.process_start === null || typeof record.process_start === "string"))
|
|
179
|
+
continue;
|
|
180
|
+
if (await processMatchesPidFile({ pid: record.pid, processStartTime: record.process_start }))
|
|
181
|
+
continue;
|
|
182
|
+
await fs.unlink(join(keys, name));
|
|
183
|
+
}
|
|
184
|
+
catch (cause) {
|
|
185
|
+
if (cause instanceof ProcessIdentityUnreadableError || cause instanceof RpcEndpointAuthError)
|
|
186
|
+
continue;
|
|
187
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
188
|
+
continue;
|
|
189
|
+
throw cause;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=rpc-public-key.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSpawnedBy } from "../../core/session-spawned-by.js";
|
|
1
2
|
import { HOST_DAEMON_DIR_ENV } from "./host-daemon-paths.js";
|
|
2
3
|
import { hostInstanceId } from "./protocol-identity.js";
|
|
3
4
|
import { sessionAutoTitleError, sessionBrowserEngineError, sessionContextError, sessionKindError, sessionPromptSurfaceError, sessionRetryFallbackError, } from "./rpc-input-validation.js";
|
|
@@ -6,16 +7,7 @@ import { error } from "./session-router-errors.js";
|
|
|
6
7
|
/** `open_session.spawnedBy` must name the parent, root and task when present; the open answers `invalid_spawned_by` otherwise. */
|
|
7
8
|
export function invalidSpawnedBy(command) {
|
|
8
9
|
const ancestry = command.spawnedBy;
|
|
9
|
-
return
|
|
10
|
-
(typeof ancestry !== "object" ||
|
|
11
|
-
ancestry === null ||
|
|
12
|
-
Array.isArray(ancestry) ||
|
|
13
|
-
typeof ancestry.parentSessionId !== "string" ||
|
|
14
|
-
ancestry.parentSessionId.length === 0 ||
|
|
15
|
-
typeof ancestry.rootSessionId !== "string" ||
|
|
16
|
-
ancestry.rootSessionId.length === 0 ||
|
|
17
|
-
typeof ancestry.taskId !== "string" ||
|
|
18
|
-
ancestry.taskId.length === 0));
|
|
10
|
+
return ancestry !== undefined && !isSpawnedBy(ancestry);
|
|
19
11
|
}
|
|
20
12
|
export function validatedOpenProfile(host, command, options) {
|
|
21
13
|
// A parked session's reopen replays what its park recorded; a field it left empty stays empty
|
|
@@ -98,4 +98,11 @@ export declare function unlinkOwnedSocket(socketPath: string, identity: SocketFi
|
|
|
98
98
|
* `unlinkOwnedSocket` decides afterwards what may actually be deleted.
|
|
99
99
|
*/
|
|
100
100
|
export declare function shieldSocketDuringClose(socketPath: string, close: () => Promise<void>): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Takes the public name over with a rename, once - and only while - that name still refers to the
|
|
103
|
+
* entry this handoff was decided against. A socket that changed underneath belongs to another
|
|
104
|
+
* process now: replacing it would unlink an endpoint this generation cannot prove it owns, so the
|
|
105
|
+
* successor aborts instead and leaves both the intruder's socket and its own bind entry alone.
|
|
106
|
+
*/
|
|
107
|
+
export declare function adoptPublicSocket(bindSocket: string, publicSocket: string, expected: SocketFileIdentity | undefined): Promise<void>;
|
|
101
108
|
//# sourceMappingURL=socket-ownership.d.ts.map
|
|
@@ -280,4 +280,21 @@ function errorMessage(cause) {
|
|
|
280
280
|
function isNodeErrorCode(cause, code) {
|
|
281
281
|
return cause instanceof Error && "code" in cause && cause.code === code;
|
|
282
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Takes the public name over with a rename, once - and only while - that name still refers to the
|
|
285
|
+
* entry this handoff was decided against. A socket that changed underneath belongs to another
|
|
286
|
+
* process now: replacing it would unlink an endpoint this generation cannot prove it owns, so the
|
|
287
|
+
* successor aborts instead and leaves both the intruder's socket and its own bind entry alone.
|
|
288
|
+
*/
|
|
289
|
+
export async function adoptPublicSocket(bindSocket, publicSocket, expected) {
|
|
290
|
+
if (expected === undefined)
|
|
291
|
+
throw new Error(`${publicSocket}: a generation launch must name the entry it replaces`);
|
|
292
|
+
const current = await statSocketIdentity(publicSocket);
|
|
293
|
+
if (current === undefined || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
294
|
+
throw new Error(`${publicSocket}: owned by another socket entry now; refusing to replace it`);
|
|
295
|
+
}
|
|
296
|
+
// rename(2) is atomic for readers of the path: every connect either reaches the old entry or
|
|
297
|
+
// this one, never nothing. The inode this supervisor bound simply answers to a second name.
|
|
298
|
+
await rename(bindSocket, publicSocket);
|
|
299
|
+
}
|
|
283
300
|
//# sourceMappingURL=socket-ownership.js.map
|
package/docs/rpc.md
CHANGED
|
@@ -524,6 +524,111 @@ senpi --mode rpc --listen /tmp/senpi-rpc.sock [options]
|
|
|
524
524
|
`unix://@name` where supported by the host platform. Socket mode accepts concurrent connections while retaining one
|
|
525
525
|
process-global session registry.
|
|
526
526
|
|
|
527
|
+
### Authentication
|
|
528
|
+
|
|
529
|
+
Public POSIX endpoints advertise `socket_auth` and prove possession of a fresh per-launch key.
|
|
530
|
+
This applies to ensured hosts, direct supervisors, and bare `--listen` hosts. Windows pipes keep
|
|
531
|
+
their existing raw `.secret` handshake. Terminal control sockets and tool-child channels are unchanged.
|
|
532
|
+
|
|
533
|
+
The host publishes `<agentDir>/rpc/keys/<launch_id>` before answering any connection. The agent
|
|
534
|
+
directory and `rpc` must be user-owned, non-symlink directories without group/other write
|
|
535
|
+
permission (0755 is supported). Only `rpc/keys` must be 0700. Before publication, the host
|
|
536
|
+
tightens an existing user-owned, non-symlink `rpc/keys` directory to 0700; clients refuse
|
|
537
|
+
unsafe modes rather than changing them.
|
|
538
|
+
The record is exclusively created with `O_NOFOLLOW`, fsynced, and checked to be a user-owned
|
|
539
|
+
regular file with mode 0600. Publication failure closes the endpoint with `key_publication_failed`.
|
|
540
|
+
A group- or other-writable agent directory or `rpc` (for example one created under umask 002) is such a
|
|
541
|
+
failure: the host exits, and its message names the directory and the fix, `chmod go-w <dir>`.
|
|
542
|
+
Each launch generates a new UUID and a new 32-byte key in memory. Hosts delete only their own
|
|
543
|
+
record; `senpi host gc` also collects records whose process is dead or whose PID has been reused.
|
|
544
|
+
|
|
545
|
+
The JSON record is
|
|
546
|
+
`{version:1, launch_id, key, pid, process_start, endpoint, created_at}`:
|
|
547
|
+
`key` is 64 lowercase hexadecimal characters, `process_start` is the process start identity
|
|
548
|
+
or `null`, and `created_at` is an ISO-8601 UTC timestamp. Never copy the record into logs.
|
|
549
|
+
|
|
550
|
+
The client's key root defaults to its own agent directory. `SENPI_RPC_KEY_ROOT`,
|
|
551
|
+
`senpi host ... --rpc-key-root <dir>`, and the `keyRoot` option on `RpcClient` and socket probes
|
|
552
|
+
select the host's credential directory without changing the client's models, settings, skills,
|
|
553
|
+
MCP environment, or other profile data. For example, a client using profile B can authenticate
|
|
554
|
+
to an endpoint launched by profile A by selecting A as its key root while keeping B active.
|
|
555
|
+
|
|
556
|
+
The exchange uses strict LF-delimited JSON:
|
|
557
|
+
|
|
558
|
+
1. Preserve the caller's own first `get_protocol_info` frame, including `observe` and
|
|
559
|
+
`origin_host_instance`, and add top-level `auth: {client_nonce}`. A caller starting with
|
|
560
|
+
another command first sends an observing protocol probe. Both nonces are 32 fresh random
|
|
561
|
+
bytes encoded as 64 lowercase hex characters.
|
|
562
|
+
2. The normal response's **`data.auth`** contains `{launch_id, server_nonce, server_proof}`.
|
|
563
|
+
Read and validate the named record, including `version === 1`, matching `launch_id`,
|
|
564
|
+
a 32-byte key, directory ownership/modes, and an `O_NOFOLLOW` file open.
|
|
565
|
+
3. Verify the server proof, then send `{id, type:"authenticate", client_proof}`. Wait for
|
|
566
|
+
its successful correlated response before sending any other command. Proofs are lowercase
|
|
567
|
+
hex HMAC-SHA256 digests, compared as 32 raw bytes in constant time:
|
|
568
|
+
|
|
569
|
+
```js
|
|
570
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
571
|
+
|
|
572
|
+
const key = Buffer.from(record.key, "hex");
|
|
573
|
+
const serverProof = createHmac("sha256", key)
|
|
574
|
+
.update(["senpi-rpc-server", launchId, clientNonce, serverNonce].join("\n"), "utf8")
|
|
575
|
+
.digest();
|
|
576
|
+
// Validate that the received proof is exactly 64 lowercase hex characters first.
|
|
577
|
+
if (!timingSafeEqual(serverProof, Buffer.from(receivedServerProof, "hex")))
|
|
578
|
+
throw new Error("host_identity_unproven");
|
|
579
|
+
const clientProof = createHmac("sha256", key)
|
|
580
|
+
.update(["senpi-rpc-client", launchId, serverNonce, clientNonce].join("\n"), "utf8")
|
|
581
|
+
.digest("hex");
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
For third-party scripts, the shipped client implements the record checks and complete exchange:
|
|
585
|
+
|
|
586
|
+
```js
|
|
587
|
+
import { RpcClient } from "@code-yeongyu/senpi";
|
|
588
|
+
|
|
589
|
+
const client = new RpcClient({ socketPath: endpoint, keyRoot: hostAgentDirectory });
|
|
590
|
+
await client.start();
|
|
591
|
+
try {
|
|
592
|
+
console.log(await client.listSessions());
|
|
593
|
+
} finally {
|
|
594
|
+
await client.stop();
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The raw key never crosses the socket. A wrong server proof closes the client with
|
|
599
|
+
`host_identity_unproven`; an unsafe record closes it with `unsafe_key_record`. Neither falls
|
|
600
|
+
back. A host's `auth_failed` triggers one fresh connection and credential lookup, not an
|
|
601
|
+
unauthenticated retry.
|
|
602
|
+
|
|
603
|
+
This release serves clients that never prove the key, with one stderr deprecation line per
|
|
604
|
+
connection and no limit beyond the normal JSONL ones, whatever the first frame (a long prompt or
|
|
605
|
+
an image included) and whether or not the client first received a challenge it could not answer.
|
|
606
|
+
A challenge in flight is not cut short: its `authenticate` may arrive after 2 seconds.
|
|
607
|
+
`SENPI_RPC_REQUIRE_TOKEN=1` enforces now: the first frame must be a `get_protocol_info` carrying
|
|
608
|
+
`auth.client_nonce`, and any other first frame, a keyless `get_protocol_info` included, receives
|
|
609
|
+
`auth_required`; a wrong proof receives `auth_failed`; either way the connection closes before any
|
|
610
|
+
later frame executes. Under enforcement, pre-authentication input is limited to 8 KiB and 2 seconds.
|
|
611
|
+
|
|
612
|
+
The host never drops an unauthenticated connection silently. Each drop writes one stderr line,
|
|
613
|
+
`senpi rpc host: dropped an unauthenticated connection on <endpoint>: <reason>`, where the reason
|
|
614
|
+
is `auth_required`, `auth_failed`, `pre-auth budget exceeded`, `pre-auth deadline`,
|
|
615
|
+
`first frame over the line limit` or `key publication failed`. The line carries no frame
|
|
616
|
+
content, nonce or key material.
|
|
617
|
+
|
|
618
|
+
A host is supervised only when it holds the internal key its supervisor hands it on stdin; the
|
|
619
|
+
supervisor-watch environment (`SENPI_RPC_HOST_WATCH_*` and its companions) never switches public
|
|
620
|
+
authentication off, and a host removes those variables once read, so nothing it starts
|
|
621
|
+
inherits them.
|
|
622
|
+
Clients accept pre-change hosts whose protocol answer has no `data.auth`. A missing record
|
|
623
|
+
is the one grace exception: the client logs the key-root remedy and continues without sending
|
|
624
|
+
`authenticate`; an enforcing host then refuses subsequent commands. Enforcement defaults and
|
|
625
|
+
refusal of legacy hosts are separate follow-ups.
|
|
626
|
+
|
|
627
|
+
Keep endpoints under the protected agent directory for the sandbox boundary. Choosing a `/tmp`
|
|
628
|
+
path or Linux abstract name permits a process that can later bind that name to pose as a legacy
|
|
629
|
+
host during the compatibility period. Challenge-response still prevents disclosure of the key
|
|
630
|
+
to a listener that cannot prove it.
|
|
631
|
+
|
|
527
632
|
### Session runtime (`--session-runtime in-process|worker`)
|
|
528
633
|
|
|
529
634
|
`--session-runtime` selects where a multi-session host runs its sessions:
|
|
@@ -1454,7 +1559,7 @@ survive it, and every subcommand accepts any of the three (plus the session's `n
|
|
|
1454
1559
|
on every call, resuming a parked session as described above. A session parked before its first message has no
|
|
1455
1560
|
transcript yet and cannot be resumed; a reference to it is `unknown_session`.
|
|
1456
1561
|
|
|
1457
|
-
Ownership: socket clients attach before mutating a session. The `senpi host session` commands already attach
|
|
1562
|
+
Ownership: public endpoints first use [authentication](#authentication); socket clients then attach before mutating a session. The `senpi host session` commands already attach
|
|
1458
1563
|
on the acting connection. Raw clients use `attach_session` for a live identity or `open_session` to open/resume
|
|
1459
1564
|
a path. This release serves and logs legacy unattached mutations unless `SENPI_RPC_REQUIRE_ATTACH=1`;
|
|
1460
1565
|
see [Attachment admission](#attachment-admission). Reads remain available without an attachment. `close`
|
|
@@ -2061,7 +2166,8 @@ registrant opens no socket and writes no registry directory.
|
|
|
2061
2166
|
fresh instance id). When that path is longer than a Unix socket path allows, it moves to
|
|
2062
2167
|
`/tmp/senpi-rpc-<8hex>/tui/`, which must be a private directory of this user. The directory is 0700 and the
|
|
2063
2168
|
socket 0600. A 32-byte secret is written 0600 beside the socket, and every connection must send it first, the
|
|
2064
|
-
handshake
|
|
2169
|
+
TUI-specific handshake; public RPC hosts use [challenge-response](#authentication). A wrong TUI secret
|
|
2170
|
+
is disconnected before a line is read. After that it is
|
|
2065
2171
|
ordinary RPC framing: one LF-terminated JSON object per request, one response carrying its `id`.
|
|
2066
2172
|
- **Registration order.** Registering first writes the session header (so the session id is durable), reaps dead
|
|
2067
2173
|
`tui` endpoints (`senpi host gc` scoped to `kinds: ["tui"]`), binds the socket and only then writes the
|
package/docs/settings.md
CHANGED
|
@@ -103,7 +103,7 @@ Permission rules are a confirmation policy, not a sandbox. Senpi, extensions, pa
|
|
|
103
103
|
| `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) |
|
|
104
104
|
| `recommendedModels` | string[] | `kimi-k3`, `gpt-6-astra`, `gpt-5.6-sol`, `claude-fable-5-1`, `claude-opus-5-5`, `glm-5.2` | Preferred default model ids in priority order. Built-in thinking levels are kimi-k3/`max`, GPT-6 Astra/`high`, GPT-5.6 Sol/`medium`, claude-fable-5-1/`high`, claude-opus-5-5/`max`, glm-5.2/`max`. Override the list or disable auto-switch with `--no-recommended-models` / `warnings.offRecommendedModel`. |
|
|
105
105
|
| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
|
|
106
|
-
| `promptPreset` | string | `"auto"` | Force a system prompt preset: `"auto"`, `"kimi-k2-6"`, `"kimi-k2-7"`, `"kimi-k2-8"`, `"kimi-k3"`, `"glm-5.2"`, `"glm-5.3"`, `"grok-4.5"`, `"grok-4.6"`, `"grok-4.7"`, `"claude-fable-5"`, `"claude-fable-5-1"`, `"claude-opus-5-5"`, `"claude-opus-5"`, `"claude-sonnet-5-5"`, `"claude-haiku-5-5"`, `"claude-opus-4-5"`, `"claude-opus-4-6"`, `"claude-opus-4-7"`, `"claude-opus-4-8"`, `"deepseek-v4-flash"`, `"deepseek-v4-flash-0731"`, `"deepseek-v4-1-flash"`, `"deepseek-v4-pro"`, `"gemini-4-argon"`, `"gpt-5"`, `"gpt-5.2"`, `"gpt-5.3-codex"`, `"gpt-5.4"`, `"gpt-5.5"`, `"gpt-5.6"`, or `"gpt-6-astra"` |
|
|
106
|
+
| `promptPreset` | string | `"auto"` | Force a system prompt preset: `"auto"`, `"kimi-k2-6"`, `"kimi-k2-7"`, `"kimi-k2-8"`, `"kimi-k3"`, `"kimi-k3-1"`, `"glm-5.2"`, `"glm-5.3"`, `"grok-4.5"`, `"grok-4.6"`, `"grok-4.7"`, `"claude-fable-5"`, `"claude-fable-5-1"`, `"claude-opus-5-5"`, `"claude-opus-5"`, `"claude-sonnet-5-5"`, `"claude-haiku-5-5"`, `"claude-opus-4-5"`, `"claude-opus-4-6"`, `"claude-opus-4-7"`, `"claude-opus-4-8"`, `"deepseek-v4-flash"`, `"deepseek-v4-flash-0731"`, `"deepseek-v4-1-flash"`, `"deepseek-v4-pro"`, `"gemini-4-argon"`, `"gpt-5"`, `"gpt-5.2"`, `"gpt-5.3-codex"`, `"gpt-5.4"`, `"gpt-5.5"`, `"gpt-5.6"`, or `"gpt-6-astra"` |
|
|
107
107
|
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
|
|
108
108
|
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses, compaction or branch-summary usage, and provider recovery diagnostics such as dropped Anthropic thinking blocks |
|
|
109
109
|
| `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. |
|