@mnemom/mnemom 0.17.0-next.6 → 0.17.0-next.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/commands/agent-config.d.ts +29 -0
- package/dist/commands/agent-config.js +193 -0
- package/dist/commands/{code-doctor.d.ts → agent-doctor.d.ts} +2 -2
- package/dist/commands/{code-doctor.js → agent-doctor.js} +27 -22
- package/dist/commands/{code-invite.d.ts → agent-invite.d.ts} +18 -18
- package/dist/commands/{code-invite.js → agent-invite.js} +30 -30
- package/dist/commands/agent-session.d.ts +68 -0
- package/dist/commands/agent-session.js +114 -0
- package/dist/commands/{code-setup.d.ts → agent-setup.d.ts} +13 -13
- package/dist/commands/{code-setup.js → agent-setup.js} +46 -42
- package/dist/commands/{code.d.ts → agent.d.ts} +50 -14
- package/dist/commands/{code.js → agent.js} +144 -86
- package/dist/commands/agents.d.ts +2 -2
- package/dist/commands/agents.js +5 -5
- package/dist/commands/onboard.js +1 -1
- package/dist/commands/wrap.js +1 -1
- package/dist/index.js +59 -51
- package/dist/lib/{code.d.ts → agent.d.ts} +52 -10
- package/dist/lib/{code.js → agent.js} +94 -15
- package/dist/lib/api.d.ts +4 -4
- package/dist/lib/api.js +9 -9
- package/dist/lib/auth.js +21 -1
- package/dist/lib/{code-config.d.ts → launcher-config.d.ts} +25 -23
- package/dist/lib/{code-config.js → launcher-config.js} +50 -31
- package/dist/lib/oauth.d.ts +26 -4
- package/dist/lib/oauth.js +98 -29
- package/dist/lib/version-gate.js +1 -1
- package/dist/rc-proxy.mjs +309 -136
- package/package.json +7 -3
- package/dist/commands/code-config.d.ts +0 -17
- package/dist/commands/code-config.js +0 -147
- package/dist/commands/code-session.d.ts +0 -52
- package/dist/commands/code-session.js +0 -78
package/dist/rc-proxy.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// mnemom-code-rc-proxy — the local proxy behind ``mnemom
|
|
2
|
+
// mnemom-code-rc-proxy — the local proxy behind ``mnemom agent … --remote-control``.
|
|
3
3
|
//
|
|
4
4
|
// WHY THIS EXISTS
|
|
5
5
|
// Claude Code's `remote-control` subcommand (drive a local session from claude.ai / the
|
|
6
6
|
// mobile app) is gated: it refuses to start unless the CLI believes it talks to
|
|
7
7
|
// api.anthropic.com with a claude.ai (OAuth) login — no ANTHROPIC_BASE_URL, no
|
|
8
8
|
// ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, no ANTHROPIC_UNIX_SOCKET. Every knob the
|
|
9
|
-
// normal `mnemom
|
|
9
|
+
// normal `mnemom agent` launch uses to reach the Mnemom gateway trips that gate.
|
|
10
10
|
// HTTPS_PROXY + NODE_EXTRA_CA_CERTS are honoured and NOT gated (measured 2026-09-13,
|
|
11
11
|
// Claude Code 2.1.269). So `--remote-control` runs Claude Code untouched — OAuth login,
|
|
12
12
|
// first-party base URL — and puts this proxy in front of it.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
|
|
20
20
|
// gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
|
|
21
21
|
// `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
|
|
22
|
-
// `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` — the exact header set
|
|
22
|
+
// `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` / `x-mnemom-goal-mode` — the exact header set
|
|
23
23
|
// the non-RC launch sends via ANTHROPIC_CUSTOM_HEADERS. The OAuth `authorization` bearer
|
|
24
24
|
// is STRIPPED on that leg (it never leaves the machine) and `oauth-*` tokens are removed
|
|
25
25
|
// from `anthropic-beta`. Everything else under api.anthropic.com (OAuth refresh, the
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// * Serves BOTH request forms: CONNECT tunnels and absolute-form plaintext requests to the
|
|
29
29
|
// proxy port. Remote Control registration and the environment work-poll loop use the
|
|
30
30
|
// latter for https URLs; a CONNECT-only proxy answers 405 and registration fails.
|
|
31
|
-
// * Body handling on the gateway leg (see the mnemom
|
|
31
|
+
// * Body handling on the gateway leg (see the mnemom agent docs (Remote Control)):
|
|
32
32
|
// - deletes every `cache_control.ttl` — Claude Code on claude.ai auth marks blocks
|
|
33
33
|
// `ttl:"1h"`; the gateway prepends its own 5m-cached system block and Anthropic
|
|
34
34
|
// rejects a 1h block that follows a 5m one (400). Forwarded bodies only.
|
|
@@ -46,105 +46,193 @@
|
|
|
46
46
|
// one more local 400: typically one or two `-> local 400` lines early in the
|
|
47
47
|
// session, each an instant local round trip, and zero gateway 400s. A `thread: null`
|
|
48
48
|
// is not a thread — the key is deleted and the body forwarded.
|
|
49
|
-
//
|
|
49
|
+
// MNEMOM_AGENT_RC_THREAD_MODE=strip restores the legacy delete-and-forward behaviour
|
|
50
50
|
// (diagnostic only: it forwards continue-deltas the API rejects).
|
|
51
51
|
//
|
|
52
52
|
// SECRETS
|
|
53
53
|
// Arrive on stdin as three newline-terminated lines: Anthropic key, Mnemom key (may be
|
|
54
54
|
// empty), OpenAI egress key (may be empty). Never argv, never env, never disk, never logged.
|
|
55
|
-
// The log (
|
|
55
|
+
// The log (MNEMOM_AGENT_RC_PROXY_LOG, else stderr) carries method/path/status/latency and the
|
|
56
56
|
// gateway's `x-mnemom-agent` / `x-mnemom-goal-verdict` response headers only. Gateway error
|
|
57
57
|
// bodies (status ≥ 400) are logged truncated so a 4xx is diagnosable (compressed ones as
|
|
58
58
|
// `body=<encoding> <N> bytes`). Locally answered thread 400s log the thread type only.
|
|
59
59
|
//
|
|
60
60
|
// CONFIG (env, none secret)
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
61
|
+
// MNEMOM_AGENT_RC_DOOR gateway door URL (required), e.g. https://gateway.mnemom.ai/anthropic
|
|
62
|
+
// MNEMOM_AGENT_RC_AGENT x-mnemom-agent value (required)
|
|
63
|
+
// MNEMOM_AGENT_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
|
|
64
|
+
// MNEMOM_AGENT_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
|
|
65
|
+
// MNEMOM_AGENT_RC_GOAL_MODE x-mnemom-goal-mode value (optional; `--implicit` — no contract,
|
|
66
|
+
// the gateway infers the goal from the user's messages)
|
|
67
|
+
// MNEMOM_AGENT_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
|
|
68
|
+
// MNEMOM_AGENT_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
|
|
67
69
|
// see above) | `strip` (legacy: delete `thread`, forward)
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
70
|
+
// MNEMOM_AGENT_RC_PROXY_LOG append log lines to this file instead of stderr (optional)
|
|
71
|
+
// MNEMOM_AGENT_RC_PARENT_PID exit when this pid is gone, so no orphan holds the keys
|
|
72
|
+
// MNEMOM_AGENT_RC_PORT listen port (default 0 = ephemeral)
|
|
71
73
|
//
|
|
72
74
|
// stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18, no deps.
|
|
73
|
-
import http from
|
|
74
|
-
import https from
|
|
75
|
-
import net from
|
|
76
|
-
import fs from
|
|
77
|
-
import os from
|
|
78
|
-
import path from
|
|
79
|
-
import { execFileSync } from
|
|
75
|
+
import http from "node:http";
|
|
76
|
+
import https from "node:https";
|
|
77
|
+
import net from "node:net";
|
|
78
|
+
import fs from "node:fs";
|
|
79
|
+
import os from "node:os";
|
|
80
|
+
import path from "node:path";
|
|
81
|
+
import { execFileSync } from "node:child_process";
|
|
80
82
|
|
|
81
|
-
const MITM_HOST =
|
|
83
|
+
const MITM_HOST = "api.anthropic.com";
|
|
82
84
|
const env = process.env;
|
|
83
85
|
|
|
84
|
-
function die(msg) {
|
|
86
|
+
function die(msg) {
|
|
87
|
+
process.stderr.write(`mnemom-code-rc-proxy: ${msg}\n`);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
85
90
|
|
|
86
|
-
if (!env.
|
|
91
|
+
if (!env.MNEMOM_AGENT_RC_DOOR) die("MNEMOM_AGENT_RC_DOOR is required");
|
|
87
92
|
let GATEWAY;
|
|
88
|
-
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
93
|
+
try {
|
|
94
|
+
GATEWAY = new URL(env.MNEMOM_AGENT_RC_DOOR);
|
|
95
|
+
} catch {
|
|
96
|
+
die(`MNEMOM_AGENT_RC_DOOR is not a URL: ${env.MNEMOM_AGENT_RC_DOOR}`);
|
|
97
|
+
}
|
|
98
|
+
if (GATEWAY.protocol !== "https:") die("MNEMOM_AGENT_RC_DOOR must be https");
|
|
99
|
+
const AGENT = env.MNEMOM_AGENT_RC_AGENT || die("MNEMOM_AGENT_RC_AGENT is required");
|
|
100
|
+
const CONV =
|
|
101
|
+
env.MNEMOM_AGENT_RC_CONVERSATION_ID || die("MNEMOM_AGENT_RC_CONVERSATION_ID is required");
|
|
102
|
+
const CONTRACT_B64 = env.MNEMOM_AGENT_RC_CONTRACT_B64 || "";
|
|
103
|
+
const GOAL_MODE = env.MNEMOM_AGENT_RC_GOAL_MODE || "";
|
|
104
|
+
const PROVIDER = env.MNEMOM_AGENT_RC_PROVIDER || "";
|
|
105
|
+
const THREAD_MODE = env.MNEMOM_AGENT_RC_THREAD_MODE || "reject";
|
|
106
|
+
if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
|
|
107
|
+
die(`MNEMOM_AGENT_RC_THREAD_MODE must be 'reject' or 'strip', got '${THREAD_MODE}'`);
|
|
108
|
+
const LOGFILE = env.MNEMOM_AGENT_RC_PROXY_LOG || "";
|
|
109
|
+
const PARENT = Number(env.MNEMOM_AGENT_RC_PARENT_PID || 0);
|
|
110
|
+
const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
|
|
99
111
|
|
|
100
112
|
const log = (line) => {
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
// One record per line: gateway header values and error bodies are spliced into log lines, so a
|
|
114
|
+
// CR/LF inside them must not fabricate extra records (log injection).
|
|
115
|
+
const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
|
|
116
|
+
if (LOGFILE) {
|
|
117
|
+
try {
|
|
118
|
+
fs.appendFileSync(LOGFILE, s);
|
|
119
|
+
return;
|
|
120
|
+
} catch {
|
|
121
|
+
/* fall through */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
103
124
|
process.stderr.write(s);
|
|
104
125
|
};
|
|
105
126
|
|
|
106
127
|
// --- secrets: three lines on stdin ------------------------------------------------------
|
|
107
128
|
let secrets;
|
|
108
129
|
try {
|
|
109
|
-
const lines = fs.readFileSync(0,
|
|
110
|
-
secrets = {
|
|
130
|
+
const lines = fs.readFileSync(0, "utf8").split("\n");
|
|
131
|
+
secrets = {
|
|
132
|
+
anthropic: (lines[0] || "").trim(),
|
|
133
|
+
mnemom: (lines[1] || "").trim(),
|
|
134
|
+
openai: (lines[2] || "").trim(),
|
|
135
|
+
};
|
|
111
136
|
} catch {
|
|
112
|
-
die(
|
|
137
|
+
die("expected three lines of secrets on stdin (anthropic, mnemom, openai)");
|
|
113
138
|
}
|
|
114
|
-
if (!secrets.anthropic) die(
|
|
139
|
+
if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
|
|
115
140
|
|
|
116
141
|
// --- ephemeral CA + leaf for api.anthropic.com ------------------------------------------
|
|
117
142
|
// Private 0700 dir under the OS tmpdir; the CA key is deleted as soon as the leaf is signed
|
|
118
143
|
// (only the leaf key is needed to serve sessions) and the whole dir goes on exit. Works with
|
|
119
144
|
// macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
|
|
120
|
-
const caDir = fs.mkdtempSync(path.join(os.tmpdir(),
|
|
145
|
+
const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-code-rc-ca-"));
|
|
121
146
|
fs.chmodSync(caDir, 0o700);
|
|
122
147
|
const p = (f) => path.join(caDir, f);
|
|
123
|
-
const cleanup = () => {
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
const cleanup = () => {
|
|
149
|
+
try {
|
|
150
|
+
fs.rmSync(caDir, { recursive: true, force: true });
|
|
151
|
+
} catch {
|
|
152
|
+
/* best effort */
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
process.on("exit", cleanup);
|
|
156
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
|
|
126
157
|
try {
|
|
127
|
-
const ossl = (...a) => execFileSync(
|
|
128
|
-
ossl(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
158
|
+
const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore", "pipe"] });
|
|
159
|
+
ossl(
|
|
160
|
+
"req",
|
|
161
|
+
"-x509",
|
|
162
|
+
"-newkey",
|
|
163
|
+
"ec",
|
|
164
|
+
"-pkeyopt",
|
|
165
|
+
"ec_paramgen_curve:prime256v1",
|
|
166
|
+
"-nodes",
|
|
167
|
+
"-keyout",
|
|
168
|
+
p("ca.key"),
|
|
169
|
+
"-out",
|
|
170
|
+
p("ca.pem"),
|
|
171
|
+
"-days",
|
|
172
|
+
"2",
|
|
173
|
+
"-subj",
|
|
174
|
+
"/CN=mnemom agent local CA (ephemeral)",
|
|
175
|
+
"-addext",
|
|
176
|
+
"basicConstraints=critical,CA:TRUE",
|
|
177
|
+
"-addext",
|
|
178
|
+
"keyUsage=critical,keyCertSign,cRLSign",
|
|
179
|
+
);
|
|
180
|
+
ossl(
|
|
181
|
+
"req",
|
|
182
|
+
"-newkey",
|
|
183
|
+
"ec",
|
|
184
|
+
"-pkeyopt",
|
|
185
|
+
"ec_paramgen_curve:prime256v1",
|
|
186
|
+
"-nodes",
|
|
187
|
+
"-keyout",
|
|
188
|
+
p("leaf.key"),
|
|
189
|
+
"-out",
|
|
190
|
+
p("leaf.csr"),
|
|
191
|
+
"-subj",
|
|
192
|
+
`/CN=${MITM_HOST}`,
|
|
193
|
+
);
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
p("leaf.ext"),
|
|
196
|
+
`subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
|
|
197
|
+
);
|
|
198
|
+
ossl(
|
|
199
|
+
"x509",
|
|
200
|
+
"-req",
|
|
201
|
+
"-in",
|
|
202
|
+
p("leaf.csr"),
|
|
203
|
+
"-CA",
|
|
204
|
+
p("ca.pem"),
|
|
205
|
+
"-CAkey",
|
|
206
|
+
p("ca.key"),
|
|
207
|
+
"-CAcreateserial",
|
|
208
|
+
"-out",
|
|
209
|
+
p("leaf.pem"),
|
|
210
|
+
"-days",
|
|
211
|
+
"2",
|
|
212
|
+
"-extfile",
|
|
213
|
+
p("leaf.ext"),
|
|
214
|
+
);
|
|
215
|
+
fs.rmSync(p("ca.key"));
|
|
138
216
|
} catch (e) {
|
|
139
|
-
die(
|
|
217
|
+
die(
|
|
218
|
+
`could not mint the ephemeral CA with openssl: ${e.stderr ? e.stderr.toString() : e.message}`,
|
|
219
|
+
);
|
|
140
220
|
}
|
|
141
221
|
|
|
142
222
|
// --- request handling ---------------------------------------------------------------------
|
|
143
223
|
const gatewayAgent = new https.Agent({ keepAlive: false }); // a reused socket to the gateway gave ECONNRESET on retry
|
|
144
224
|
const upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
|
|
145
|
-
const HOP = new Set([
|
|
225
|
+
const HOP = new Set([
|
|
226
|
+
"connection",
|
|
227
|
+
"proxy-connection",
|
|
228
|
+
"keep-alive",
|
|
229
|
+
"upgrade",
|
|
230
|
+
"host",
|
|
231
|
+
"te",
|
|
232
|
+
"trailer",
|
|
233
|
+
]);
|
|
146
234
|
|
|
147
|
-
const isMessagesPath = (u) => u ===
|
|
235
|
+
const isMessagesPath = (u) => u === "/v1/messages" || u.startsWith("/v1/messages?");
|
|
148
236
|
|
|
149
237
|
// Inspect + normalise a /v1/messages body for the gateway leg. Returns
|
|
150
238
|
// { body: <possibly rewritten Buffer>, thread: null | 'create' | 'continue' | 'other' }
|
|
@@ -157,24 +245,34 @@ const isMessagesPath = (u) => u === '/v1/messages' || u.startsWith('/v1/messages
|
|
|
157
245
|
// unchanged apart from `cache_control.ttl`, because the caller will not forward it.
|
|
158
246
|
function normalizeBody(body) {
|
|
159
247
|
try {
|
|
160
|
-
const j = JSON.parse(body.toString(
|
|
248
|
+
const j = JSON.parse(body.toString("utf8"));
|
|
161
249
|
let n = 0;
|
|
162
250
|
const walk = (o) => {
|
|
163
|
-
if (Array.isArray(o)) {
|
|
164
|
-
|
|
165
|
-
|
|
251
|
+
if (Array.isArray(o)) {
|
|
252
|
+
for (const x of o) walk(x);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (o && typeof o === "object") {
|
|
256
|
+
if (o.cache_control && typeof o.cache_control === "object" && "ttl" in o.cache_control) {
|
|
257
|
+
delete o.cache_control.ttl;
|
|
258
|
+
n++;
|
|
259
|
+
}
|
|
166
260
|
for (const v of Object.values(o)) walk(v);
|
|
167
261
|
}
|
|
168
262
|
};
|
|
169
263
|
walk(j);
|
|
170
264
|
let thread = null;
|
|
171
|
-
if (j && typeof j ===
|
|
265
|
+
if (j && typeof j === "object" && !Array.isArray(j) && "thread" in j) {
|
|
172
266
|
if (j.thread === null || j.thread === undefined) {
|
|
173
|
-
delete j.thread;
|
|
267
|
+
delete j.thread;
|
|
268
|
+
n++; // not a thread: forward the rest
|
|
174
269
|
} else {
|
|
175
|
-
const t = typeof j.thread ===
|
|
176
|
-
thread = t ===
|
|
177
|
-
if (THREAD_MODE ===
|
|
270
|
+
const t = typeof j.thread === "object" ? j.thread.type : undefined;
|
|
271
|
+
thread = t === "create" || t === "continue" ? t : "other";
|
|
272
|
+
if (THREAD_MODE === "strip") {
|
|
273
|
+
delete j.thread;
|
|
274
|
+
n++;
|
|
275
|
+
}
|
|
178
276
|
}
|
|
179
277
|
}
|
|
180
278
|
return { body: n ? Buffer.from(JSON.stringify(j)) : body, thread };
|
|
@@ -187,36 +285,50 @@ function normalizeBody(body) {
|
|
|
187
285
|
// classifier must land on "unsupported_request": `details.error_code` is the primary
|
|
188
286
|
// signal, and the message names the beta, the `thread` param and `previous_message_id`
|
|
189
287
|
// so the fallback regexes agree even if the details path were ever ignored.
|
|
190
|
-
const THREAD_400 = Buffer.from(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
288
|
+
const THREAD_400 = Buffer.from(
|
|
289
|
+
JSON.stringify({
|
|
290
|
+
type: "error",
|
|
291
|
+
error: {
|
|
292
|
+
type: "invalid_request_error",
|
|
293
|
+
message:
|
|
294
|
+
"message threads (beta message-threads-2026-08-12: top-level thread param / previous_message_id) are not supported on the mnemom gateway leg; resend the full transcript without a thread",
|
|
295
|
+
details: { error_code: "thread_unsupported_request" },
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
198
299
|
let localSeq = 0;
|
|
199
300
|
function rejectThread(res, tag, thread) {
|
|
200
301
|
res.writeHead(400, {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
302
|
+
"content-type": "application/json",
|
|
303
|
+
"content-length": String(THREAD_400.length),
|
|
304
|
+
"request-id": `mnemom-code-rc-proxy-${++localSeq}`,
|
|
204
305
|
});
|
|
205
306
|
res.end(THREAD_400);
|
|
206
|
-
log(
|
|
307
|
+
log(
|
|
308
|
+
`${tag} -> local 400 thread=${thread} (thread_unsupported_request; Claude Code resends this turn stateless and stops threading for this agent+model for the rest of the session — a subagent or model switch takes one more local 400)`,
|
|
309
|
+
);
|
|
207
310
|
}
|
|
208
311
|
|
|
209
312
|
// One handler serves both the MITM'd TLS stream (origin-form URLs, host = api.anthropic.com)
|
|
210
313
|
// and absolute-form plaintext requests sent straight to the proxy port.
|
|
211
314
|
function handle(req, res, viaPlain) {
|
|
212
|
-
let host = MITM_HOST,
|
|
315
|
+
let host = MITM_HOST,
|
|
316
|
+
port = 443,
|
|
317
|
+
urlPath = req.url,
|
|
318
|
+
proto = https;
|
|
213
319
|
if (viaPlain) {
|
|
214
320
|
let u;
|
|
215
|
-
try {
|
|
321
|
+
try {
|
|
322
|
+
u = new URL(req.url);
|
|
323
|
+
} catch {
|
|
324
|
+
res.writeHead(400);
|
|
325
|
+
res.end("mnemom-code-rc-proxy: absolute-form URL required\n");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
216
328
|
host = u.hostname;
|
|
217
|
-
port = Number(u.port || (u.protocol ===
|
|
329
|
+
port = Number(u.port || (u.protocol === "http:" ? 80 : 443));
|
|
218
330
|
urlPath = u.pathname + u.search;
|
|
219
|
-
proto = u.protocol ===
|
|
331
|
+
proto = u.protocol === "http:" ? http : https;
|
|
220
332
|
}
|
|
221
333
|
const headers = {};
|
|
222
334
|
for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k)) headers[k] = v;
|
|
@@ -224,27 +336,42 @@ function handle(req, res, viaPlain) {
|
|
|
224
336
|
const toGateway = host === MITM_HOST && isMessagesPath(urlPath);
|
|
225
337
|
let target;
|
|
226
338
|
if (toGateway) {
|
|
227
|
-
target = {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (headers[
|
|
238
|
-
|
|
239
|
-
|
|
339
|
+
target = {
|
|
340
|
+
hostname: GATEWAY.hostname,
|
|
341
|
+
port: GATEWAY.port || 443,
|
|
342
|
+
path: GATEWAY.pathname.replace(/\/$/, "") + urlPath,
|
|
343
|
+
agent: gatewayAgent,
|
|
344
|
+
};
|
|
345
|
+
delete headers.authorization; // the OAuth bearer stays local
|
|
346
|
+
headers["x-api-key"] = secrets.anthropic;
|
|
347
|
+
headers["x-mnemom-agent"] = AGENT;
|
|
348
|
+
headers["x-mnemom-conversation-id"] = CONV;
|
|
349
|
+
if (secrets.mnemom) headers["x-mnemom-api-key"] = secrets.mnemom;
|
|
350
|
+
if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
|
|
351
|
+
if (secrets.openai) headers["x-mnemom-egress-key"] = secrets.openai;
|
|
352
|
+
if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
|
|
353
|
+
if (GOAL_MODE) headers["x-mnemom-goal-mode"] = GOAL_MODE;
|
|
354
|
+
if (headers["anthropic-beta"]) {
|
|
355
|
+
// drop the OAuth-only beta tokens
|
|
356
|
+
const b = String(headers["anthropic-beta"])
|
|
357
|
+
.split(",")
|
|
358
|
+
.map((s) => s.trim())
|
|
359
|
+
.filter((s) => s && !s.startsWith("oauth-"));
|
|
360
|
+
if (b.length) headers["anthropic-beta"] = b.join(",");
|
|
361
|
+
else delete headers["anthropic-beta"];
|
|
240
362
|
}
|
|
241
363
|
} else {
|
|
242
|
-
target = {
|
|
364
|
+
target = {
|
|
365
|
+
hostname: host,
|
|
366
|
+
port,
|
|
367
|
+
path: urlPath,
|
|
368
|
+
agent: proto === https ? upstreamAgent : undefined,
|
|
369
|
+
};
|
|
243
370
|
}
|
|
244
371
|
|
|
245
372
|
const started = Date.now();
|
|
246
|
-
const tag = `${req.method} ${viaPlain ? host :
|
|
247
|
-
const leg = toGateway ?
|
|
373
|
+
const tag = `${req.method} ${viaPlain ? host : ""}${urlPath}`;
|
|
374
|
+
const leg = toGateway ? "gateway" : "upstream";
|
|
248
375
|
|
|
249
376
|
// Open the upstream request only once we know we are forwarding. `body` is the fully
|
|
250
377
|
// buffered (normalised) gateway body; undefined means stream the client request through.
|
|
@@ -256,45 +383,72 @@ function handle(req, res, viaPlain) {
|
|
|
256
383
|
// Gateway error bodies are logged (truncated) so a 4xx is diagnosable — unless the
|
|
257
384
|
// body is compressed, in which case only its encoding + size is useful
|
|
258
385
|
// (`identity` is not a compression).
|
|
259
|
-
let errBody =
|
|
260
|
-
|
|
261
|
-
const
|
|
386
|
+
let errBody = "",
|
|
387
|
+
errBytes = 0;
|
|
388
|
+
const enc = ur.headers["content-encoding"];
|
|
389
|
+
const errEnc = enc && enc !== "identity" ? enc : "";
|
|
262
390
|
if (toGateway && ur.statusCode >= 400) {
|
|
263
|
-
ur.on(
|
|
391
|
+
ur.on("data", (c) => {
|
|
392
|
+
errBytes += c.length;
|
|
393
|
+
if (!errEnc && errBody.length < 1500) errBody += c.toString("utf8");
|
|
394
|
+
});
|
|
264
395
|
}
|
|
265
396
|
ur.pipe(res);
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
397
|
+
const goalRev = ur.headers["x-mnemom-goal-revision"];
|
|
398
|
+
const steer = ur.headers["x-mnemom-goal-steer"];
|
|
399
|
+
const ident = toGateway
|
|
400
|
+
? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
|
|
401
|
+
(goalRev ? ` goal-rev=${goalRev}` : "") +
|
|
402
|
+
(steer ? ` steer=${steer}` : "")
|
|
403
|
+
: "";
|
|
404
|
+
ur.on("end", () => {
|
|
405
|
+
let bodyNote = "";
|
|
269
406
|
if (toGateway && ur.statusCode >= 400) {
|
|
270
|
-
bodyNote = errEnc
|
|
407
|
+
bodyNote = errEnc
|
|
408
|
+
? ` body=${errEnc} ${errBytes} bytes`
|
|
409
|
+
: errBody
|
|
410
|
+
? " body=" + errBody.slice(0, 1500).replace(/\s+/g, " ")
|
|
411
|
+
: "";
|
|
271
412
|
}
|
|
272
413
|
log(`${tag} -> ${leg} ${ur.statusCode} ${Date.now() - started}ms${ident}${bodyNote}`);
|
|
273
414
|
});
|
|
274
415
|
});
|
|
275
|
-
up.on(
|
|
416
|
+
up.on("error", (e) => {
|
|
276
417
|
log(`${tag} -> ${leg} ERROR ${e.code || e.message}`);
|
|
277
|
-
if (!res.headersSent) res.writeHead(502, {
|
|
278
|
-
res.end(
|
|
418
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
|
|
419
|
+
res.end(
|
|
420
|
+
JSON.stringify({
|
|
421
|
+
type: "error",
|
|
422
|
+
error: {
|
|
423
|
+
type: "proxy_error",
|
|
424
|
+
message: `mnemom-code-rc-proxy upstream: ${e.code || e.message}`,
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
res.on("close", () => {
|
|
430
|
+
if (!up.destroyed) up.destroy();
|
|
279
431
|
});
|
|
280
|
-
res.on('close', () => { if (!up.destroyed) up.destroy(); });
|
|
281
432
|
if (body !== undefined) {
|
|
282
|
-
up.removeHeader(
|
|
283
|
-
up.setHeader(
|
|
433
|
+
up.removeHeader("transfer-encoding");
|
|
434
|
+
up.setHeader("content-length", String(body.length));
|
|
284
435
|
up.end(body);
|
|
285
436
|
} else {
|
|
286
437
|
req.pipe(up);
|
|
287
438
|
}
|
|
288
439
|
};
|
|
289
440
|
|
|
290
|
-
if (toGateway && req.method ===
|
|
441
|
+
if (toGateway && req.method === "POST") {
|
|
291
442
|
// Buffer the body first: a thread-bearing request is answered here and nothing may be
|
|
292
443
|
// sent to (or left half-open toward) the gateway for it.
|
|
293
444
|
const chunks = [];
|
|
294
|
-
req.on(
|
|
295
|
-
req.on(
|
|
445
|
+
req.on("data", (c) => chunks.push(c));
|
|
446
|
+
req.on("end", () => {
|
|
296
447
|
const { body, thread } = normalizeBody(Buffer.concat(chunks));
|
|
297
|
-
if (thread !== null && THREAD_MODE ===
|
|
448
|
+
if (thread !== null && THREAD_MODE === "reject") {
|
|
449
|
+
rejectThread(res, tag, thread);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
298
452
|
forward(body);
|
|
299
453
|
});
|
|
300
454
|
} else {
|
|
@@ -303,39 +457,58 @@ function handle(req, res, viaPlain) {
|
|
|
303
457
|
}
|
|
304
458
|
|
|
305
459
|
const tlsServer = https.createServer(
|
|
306
|
-
{ key: fs.readFileSync(p(
|
|
460
|
+
{ key: fs.readFileSync(p("leaf.key")), cert: fs.readFileSync(p("leaf.pem")) },
|
|
307
461
|
(req, res) => handle(req, res, false),
|
|
308
462
|
);
|
|
309
|
-
tlsServer.on(
|
|
463
|
+
tlsServer.on("tlsClientError", (e) => log(`tls client error: ${e.code || e.message}`));
|
|
310
464
|
|
|
311
465
|
const proxy = http.createServer((req, res) => handle(req, res, true));
|
|
312
|
-
proxy.on(
|
|
313
|
-
const [host, portStr] = req.url.split(
|
|
466
|
+
proxy.on("connect", (req, sock, head) => {
|
|
467
|
+
const [host, portStr] = req.url.split(":");
|
|
314
468
|
const port = Number(portStr || 443);
|
|
315
|
-
sock.on(
|
|
469
|
+
sock.on("error", () => {});
|
|
316
470
|
if (host === MITM_HOST && port === 443) {
|
|
317
|
-
sock.write(
|
|
471
|
+
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
318
472
|
if (head && head.length) sock.unshift(head);
|
|
319
|
-
tlsServer.emit(
|
|
473
|
+
tlsServer.emit("connection", sock);
|
|
320
474
|
return;
|
|
321
475
|
}
|
|
322
476
|
const up = net.connect(port, host, () => {
|
|
323
|
-
sock.write(
|
|
477
|
+
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
324
478
|
if (head && head.length) up.write(head);
|
|
325
|
-
up.pipe(sock);
|
|
479
|
+
up.pipe(sock);
|
|
480
|
+
sock.pipe(up);
|
|
326
481
|
});
|
|
327
|
-
up.on(
|
|
328
|
-
|
|
329
|
-
|
|
482
|
+
up.on("error", (e) => {
|
|
483
|
+
log(`CONNECT ${req.url} tunnel ERROR ${e.code || e.message}`);
|
|
484
|
+
sock.destroy();
|
|
485
|
+
});
|
|
486
|
+
sock.on("close", () => up.destroy());
|
|
487
|
+
up.on("close", () => sock.destroy());
|
|
330
488
|
log(`CONNECT ${req.url} tunnel`);
|
|
331
489
|
});
|
|
332
|
-
proxy.on(
|
|
490
|
+
proxy.on("clientError", (e, sock) => {
|
|
491
|
+
try {
|
|
492
|
+
sock.destroy();
|
|
493
|
+
} catch {
|
|
494
|
+
/* already gone */
|
|
495
|
+
}
|
|
496
|
+
});
|
|
333
497
|
|
|
334
|
-
proxy.listen(PORT,
|
|
498
|
+
proxy.listen(PORT, "127.0.0.1", () => {
|
|
335
499
|
const { port } = proxy.address();
|
|
336
|
-
log(
|
|
337
|
-
|
|
500
|
+
log(
|
|
501
|
+
`listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} goal_mode=${GOAL_MODE || "-"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
|
|
502
|
+
);
|
|
503
|
+
process.stdout.write(`READY ${port} ${p("ca.pem")}\n`);
|
|
338
504
|
});
|
|
339
505
|
|
|
340
506
|
// Die with the launcher so no orphan keeps the secrets in memory.
|
|
341
|
-
if (PARENT)
|
|
507
|
+
if (PARENT)
|
|
508
|
+
setInterval(() => {
|
|
509
|
+
try {
|
|
510
|
+
process.kill(PARENT, 0);
|
|
511
|
+
} catch {
|
|
512
|
+
process.exit(0);
|
|
513
|
+
}
|
|
514
|
+
}, 2000).unref();
|