subscription-gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # subscription-gateway — run the platform on a subscription instead of per-token billing
2
+
3
+ A system service: it takes a request from the platform (or from any other host),
4
+ drives the model loop through the vendor's official SDK on **subscription**
5
+ access, and streams the answer back.
6
+
7
+ The subscription token lives in **one** place on the machine. Agents do not know
8
+ it — they know the address of an entry point.
9
+
10
+ ---
11
+
12
+ ## What it gives you
13
+
14
+ - the model loop runs on a subscription instead of per-token billing;
15
+ - the secret does not multiply across the machine: one file, group permissions,
16
+ rotation in one place;
17
+ - a new agent is connected by a line of configuration, without copying the secret;
18
+ - platform tools can be mixed into the model's tool set through the bridge (see
19
+ `dsh-tool-bridge`).
20
+
21
+ ---
22
+
23
+ ## When you do NOT need this
24
+
25
+ **If you have an ordinary API key, you do not need it at all.** The platform can
26
+ reach the provider by itself, and an extra process between it and the vendor only
27
+ adds places where something can go quiet.
28
+
29
+ The gateway is for the case where access is by **subscription** and the host knows
30
+ nothing about that kind of access.
31
+
32
+ **And one more case where it is not needed: if you have a single agent.** The
33
+ whole point of moving the token out is that there are several agents. With one, it
34
+ is simpler to keep the secret next to it.
35
+
36
+ ---
37
+
38
+ ## 🔴 The main thing to know before installing: whose hands the agent works with
39
+
40
+ The SDK is **agentic**. It drives the loop itself and itself runs the shell,
41
+ files, search and web.
42
+
43
+ So the agent's "hands" are the user the **gateway process** runs as, not the user
44
+ the platform runs as. Everything else follows from that:
45
+
46
+ - the service is started as **one instance per agent**
47
+ (`gateway@<agent name>.service`), under that agent's own user name and on its
48
+ own port;
49
+ - a shared system user does not fit here structurally: it has no access to the
50
+ agent's home, and every agent on the machine would act as one and the same
51
+ person;
52
+ - **any right you give to the agent's hands is given to THIS unit.** The
53
+ platform's rights are beside the point.
54
+
55
+ That last one is not theory. We lost a work session to it: we gave the agent the
56
+ right to read the system journal, wrote it into the platform's unit, verified it
57
+ against the platform's live process — everything checked out, and the agent's
58
+ refusal stayed. **A right method applied to the wrong object yields confidence,
59
+ not truth.** What must be checked is the process that executes the commands:
60
+
61
+ ```bash
62
+ grep ^Groups: /proc/$(systemctl show -p MainPID --value gateway@<agent>.service)/status
63
+ ```
64
+
65
+ 🔴 `id` and `sudo -u <agent>` do **not answer this question**: they spawn a NEW
66
+ process, which takes its groups from the system file, and will show what you want
67
+ rather than what is. A live process fixes its group set when it starts.
68
+
69
+ ---
70
+
71
+ ## Installation
72
+
73
+ ### 0. The code and its dependencies
74
+
75
+ ```bash
76
+ sudo mkdir -p /opt/subscription-gateway
77
+ cd /opt/subscription-gateway
78
+ npm install subscription-gateway # or copy this package here
79
+ ```
80
+
81
+ The unit template below starts `/opt/subscription-gateway/gateway.mjs`. Put the
82
+ code somewhere else and you have three places to change, all named in the
83
+ comment at the top of the unit file.
84
+
85
+ **Sign of success:** `node -e "import('./gateway.mjs')"` exits without
86
+ `ERR_MODULE_NOT_FOUND`. The two runtime dependencies — the vendor agent SDK and
87
+ the schema library — must resolve from the directory the unit starts the file
88
+ in; `NODE_PATH` does **not** help here, ES modules ignore it.
89
+
90
+ ### 1. The token in one place, permissions by group
91
+
92
+ ```bash
93
+ sudo groupadd -r gateway-token
94
+ sudo install -d -m 750 -o root -g gateway-token /etc/subscription-gateway
95
+ sudo install -m 640 -o root -g gateway-token /dev/null /etc/subscription-gateway/token
96
+ # put the subscription token into the file
97
+ ```
98
+
99
+ **Sign of success:** the file is readable by a member of the group and not
100
+ readable by anyone else.
101
+
102
+ 🔴 Isolation of the secret is real only against agents **without** `sudo`. An
103
+ agent with `sudo` will read the file anyway — do not imagine a protection that is
104
+ not there.
105
+
106
+ ### 2. The unit template
107
+
108
+ Install `systemd/gateway@.service` (in this package) and create an instance:
109
+
110
+ ```bash
111
+ sudo systemctl enable --now gateway@<agent>.service
112
+ ```
113
+
114
+ **Sign of success:**
115
+
116
+ ```bash
117
+ curl -s http://127.0.0.1:<port>/health
118
+ {"ok":true,"token":"present","sdk":true}
119
+ ```
120
+
121
+ 🔴 Health means the **presence of the secret**, not "the process is alive".
122
+ Without a token the service is up and useless — that must be visible from
123
+ outside, which is why it answers 503 rather than 200.
124
+
125
+ ### 3. The instance environment
126
+
127
+ `/etc/subscription-gateway/instance-<agent>.env`:
128
+
129
+ ```
130
+ GATEWAY_PORT=<instance port>
131
+ GATEWAY_WORK_DIR=/home/<agent>/workspace
132
+ GATEWAY_MAX_TURNS=120
133
+ GATEWAY_MCP={"<server name>":{"type":"http","url":"http://127.0.0.1:PORT/mcp"}}
134
+ ```
135
+
136
+ | variable | what it sets | default |
137
+ |---|---|---|
138
+ | `GATEWAY_PORT` | loopback port | 8788 |
139
+ | `GATEWAY_TOKEN_FILE` | token file | set by the unit |
140
+ | `GATEWAY_WORK_DIR` | working directory for the tools | `$HOME`, else `/tmp` |
141
+ | `GATEWAY_MAX_TURNS` | upper turn limit within one request | 60 |
142
+ | `GATEWAY_MCP` | external MCP servers, JSON | empty (not an error) |
143
+
144
+ 🔴 **Raise `GATEWAY_MAX_TURNS` deliberately.** We hit it on the 61st turn of a
145
+ long task, and the SDK reported it as `exited with code N` — that is, a code
146
+ without a reason. The gateway now digs the real reason out of the transcript and
147
+ prints `the agent hit the turn limit: reached <N> against a threshold of <M>`, and
148
+ if it does not find it, says "reason not established" instead of a plausible
149
+ invention.
150
+
151
+ ### 4. Check that the subscription answers
152
+
153
+ Send a short request to `POST /v1/agent-stream` and wait for the model's answer.
154
+
155
+ **Sign of success:** a stream with text arrived. **Sign of trouble:** every call
156
+ is rejected by a rate limit while the subscription is demonstrably healthy — see
157
+ the next section, that is not about your quota.
158
+
159
+ ---
160
+
161
+ ## 🔴 Why the vendor SDK inside, and not hand-rolled HTTP
162
+
163
+ The first version assembled the API request by hand: subscription token, the
164
+ required beta flags pulled out of the client binary. **Authorisation worked** — a
165
+ wrong token got 401, ours got 429 — but **every** call was rejected by a rate
166
+ limit while the subscription was entirely healthy: three agents were working on it
167
+ at that very moment.
168
+
169
+ We went through and discarded four hypotheses: client headers, model binding,
170
+ token expiry, the set of beta flags. The truth was something else — **the raw path
171
+ is simply not served to subscription access**. Same token, same machine, same
172
+ network egress: the SDK answers in four seconds, the hand-rolled request is
173
+ refused.
174
+
175
+ The general conclusion, and it is worth more than the case itself: **do not
176
+ reinvent the vendor's protocol.** Vendor code knows subtleties that are not in the
177
+ documentation, and it will survive them changing. And a refusal that looks like
178
+ "you are out of quota" may mean "you are knocking at the wrong door".
179
+
180
+ ---
181
+
182
+ ## What does not work, and will not
183
+
184
+ - **it is never exposed outward.** It listens on loopback only. This is access to
185
+ the subscription without a password; moving it to an external address is the
186
+ same as publishing the token;
187
+ - **no confirmations are requested** (`bypassPermissions`): there is nobody to
188
+ ask, the far end is not a human but the host. The real boundary is the rights of
189
+ the instance user, and they are set in systemd, not here;
190
+ - **the unit sandbox is deliberately not tightened**: the agent needs its own
191
+ files and home, and against an agent with `sudo` the unit's restrictions are no
192
+ boundary anyway;
193
+ - **the gateway does not know what tools it is handing over.** It receives their
194
+ description from the bridge and proxies the calls back. That is on purpose: the
195
+ next agent with a different set connects without editing the gateway.
196
+
197
+ ---
198
+
199
+ ## The schema adapter: a place where things are lost silently
200
+
201
+ Tools arriving from the host are described by a JSON Schema, and the SDK expects a
202
+ schema of its own kind. `jsonschema-to-zod.mjs` translates between them.
203
+
204
+ 🔴 **An unfamiliar shape is a silent loss, not a refusal.** The first version did
205
+ not know the shape "string OR object" (`anyOf`/`oneOf`) and returned "anything" for
206
+ it. The tool still registered, still looked healthy — and failed at call time,
207
+ while parsing arguments.
208
+
209
+ Test the adapter **against the real schemas of your platform**, not invented ones:
210
+
211
+ ```bash
212
+ node test-schema-adapter.mjs
213
+ ```
214
+
215
+ The bench in this package takes the schemas from a file generated by the platform
216
+ itself and checks that both `anyOf` branches pass while a foreign type does **not**.
217
+ The second half matters more than the first: a schema that has degenerated into
218
+ "anything" lets everything through and thereby hides the error.
219
+
220
+ ---
221
+
222
+ ## Acceptance by your own hand
223
+
224
+ 1. `curl /health` → `{"ok":true,"token":"present","sdk":true}`. Checks that the
225
+ secret is readable.
226
+ 2. `grep ^Groups: /proc/<MainPID>/status` → the required groups **on the live
227
+ process**. Checks the rights of the agent's hands, and only this method answers
228
+ that question.
229
+ 3. A short request to `/v1/agent-stream` → the model answered. Checks the
230
+ subscription itself.
231
+ 4. `node test-schema-adapter.mjs` → everything green. Checks the schema adapter.
232
+ 5. If the bridge is installed alongside: ask the model to call a platform tool and
233
+ find the call in the host log. The model's answer is not a sign.
234
+
235
+ 🔴 **All five are usable as a run-through after an upgrade** — of the vendor SDK
236
+ and of the platform alike. None of these desynchronisations fails with an error:
237
+ the schema degenerates into "anything", the group is lost on restart, the token
238
+ stays where it was but the path to it changes. All of it looks like healthy work.
239
+
240
+ ---
241
+
242
+ ## License
243
+
244
+ MIT.
package/gateway.mjs ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Anthropic subscription gateway — a shared system service of the machine.
3
+ *
4
+ * WHY. There are several agents on the machine, each under its own user. If
5
+ * every one of them kept the subscription token, the secret would multiply
6
+ * across the machine. Here it lives in ONE place, under its own user, and the
7
+ * agents get an entry point. A new agent is connected by a line of
8
+ * configuration and knows nothing about the token.
9
+ *
10
+ * 🔴 WHY THE OFFICIAL SDK INSIDE AND NOT HAND-ROLLED HTTP (lesson of 2026-08-19).
11
+ * At first the gateway assembled the API request by hand: subscription token,
12
+ * two beta flags pulled out of the client binary. Authorisation worked (a wrong
13
+ * token got 401, ours got 429), but EVERY call was rejected by a rate limit
14
+ * while the subscription was entirely healthy: three agents were working on it
15
+ * at that very moment. We went through and discarded four hypotheses — client
16
+ * headers, model binding, token expiry, the set of beta flags. The truth was
17
+ * something else: the raw path is simply not served to subscription access. Same
18
+ * token, same machine, same network egress — the SDK answers in four seconds, a
19
+ * hand-rolled request is refused.
20
+ * The general conclusion: DO NOT REINVENT THE VENDOR'S PROTOCOL. Vendor code
21
+ * knows subtleties that are not in the documentation, and it will survive them
22
+ * changing.
23
+ *
24
+ * 🔴 INDEPENDENCE FROM ANY OTHER MACHINE. The SDK is installed HERE, the token is
25
+ * HERE, and the machine has its own network egress. No central node takes part
26
+ * in the chain and any such node may be switched off — a direct requirement of
27
+ * the owner, verified with a live call.
28
+ *
29
+ * BOUNDARIES. Listens on loopback only. It is never exposed outward: this is
30
+ * access to our subscription without a password.
31
+ *
32
+ * 🔴 THE ENGINE EXECUTES THE TOOLS, NOT THE PLATFORM — AND THE CHOICE OF USER
33
+ * FOLLOWS FROM THAT. The SDK is agentic: it drives the loop itself and itself
34
+ * runs the shell, files, search and web. So the agent's "hands" are the user
35
+ * THIS process runs as. That is why the service is started as an INSTANCE PER
36
+ * AGENT (`...@<agent name>.service`), under the agent's own user name and on its
37
+ * own port. A shared system user does not fit here structurally: it has no
38
+ * access to the agent's home, and every agent on the machine would act as one
39
+ * and the same person, treading on each other.
40
+ * What DOES stay shared: the token file — one per machine, no secret in the
41
+ * agent's configuration, rotation in one place.
42
+ * What you must NOT imagine: an agent with sudo will read the token file
43
+ * anyway. Isolation of the secret is real against agents WITHOUT sudo.
44
+ *
45
+ * THE RIGHTS BOUNDARY. No confirmations are requested (`bypassPermissions`):
46
+ * there is nobody here to ask, the far end is not a human but the platform. The
47
+ * real boundary is the rights of the instance user, and it is set in systemd,
48
+ * not here.
49
+ */
50
+
51
+ import http from 'node:http';
52
+ import fs from 'node:fs';
53
+ import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
54
+ import { shape } from './jsonschema-to-zod.mjs';
55
+
56
+ const PORT = Number(process.env.GATEWAY_PORT || 8788);
57
+ const HOST = '127.0.0.1';
58
+ const TOKEN_FILE = process.env.GATEWAY_TOKEN_FILE || '/etc/subscription-gateway/token';
59
+ const DEFAULT_MODEL = 'claude-opus-5';
60
+ const DEFAULT_MAX_TURNS = Number(process.env.GATEWAY_MAX_TURNS || 60);
61
+ /**
62
+ * External tool servers (MCP) for this instance's agent. Set through the
63
+ * GATEWAY_MCP variable as JSON: {"<server name>":{"type":"http","url":"..."}}.
64
+ * Empty — the agent works without them, and that is not an error.
65
+ */
66
+ const MCP_SERVERS = (() => {
67
+ const raw = process.env.GATEWAY_MCP;
68
+ if (!raw) return null;
69
+ try {
70
+ const v = JSON.parse(raw);
71
+ return v && Object.keys(v).length ? v : null;
72
+ } catch (e) {
73
+ // Ignoring this silently is not allowed: the agent would be left without
74
+ // memory, and it would look like "memory does not work" rather than "I wrote
75
+ // the line wrong".
76
+ console.error(`[subscription-gateway] 🔴 GATEWAY_MCP could not be parsed: ${e?.message}`);
77
+ return null;
78
+ }
79
+ })();
80
+
81
+ /** Default working directory for the tools — the instance user's home. */
82
+ const WORK_DIR = process.env.GATEWAY_WORK_DIR || process.env.HOME || '/tmp';
83
+
84
+ const log = (m) => console.error(`[subscription-gateway] ${m}`);
85
+
86
+ /**
87
+ * "exited with code N" from the SDK is only a code without a reason; the truth
88
+ * is in the session transcript. We look for attachment.type == "max_turns_reached"
89
+ * and return a human-readable line with the numbers. Not found — the original
90
+ * text plus an explicit "reason not established", WITHOUT inventing a plausible
91
+ * one.
92
+ *
93
+ * 🔴 The strings matched here — 'exited with code', 'returned an error result',
94
+ * 'max_turns_reached' — are the SDK'S OWN wording and protocol constants. They
95
+ * are not ours to translate or prettify: change them and the match silently
96
+ * stops finding anything, leaving a code without a reason again.
97
+ */
98
+ function explainExit(err, sessionId, cwd) {
99
+ const raw = String(err?.message ?? err);
100
+ if (!/(?:exited with code|returned an error result)/.test(raw)) return raw;
101
+ try {
102
+ const slug = cwd.replaceAll('/', '-');
103
+ const file = `${process.env.HOME || WORK_DIR}/.claude/projects/${slug}/${sessionId}.jsonl`;
104
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
105
+ if (!line.includes('max_turns_reached')) continue;
106
+ const a = JSON.parse(line)?.attachment;
107
+ if (a?.type === 'max_turns_reached') {
108
+ return `the agent hit the turn limit: reached ${a.turnCount} against a threshold of ${a.maxTurns} (max_turns_reached)`;
109
+ }
110
+ }
111
+ } catch {
112
+ return `${raw} — reason not established (the transcript could not be read)`;
113
+ }
114
+ return `${raw} — reason not established (no max_turns_reached entry in the fresh transcript)`;
115
+ }
116
+
117
+ /** The token is read on EVERY request: rotating the secret needs no restart. */
118
+ function readToken() {
119
+ try {
120
+ return fs.readFileSync(TOKEN_FILE, 'utf8').trim() || null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /** A short caption for a tool call: what exactly it does, in one line. */
127
+ function briefOf(input) {
128
+ if (!input || typeof input !== 'object') return '';
129
+ const v = input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.path ?? input.query;
130
+ return typeof v === 'string' ? v.slice(0, 200) : '';
131
+ }
132
+
133
+ function readBody(req) {
134
+ return new Promise((resolve, reject) => {
135
+ const parts = [];
136
+ req.on('data', (c) => parts.push(c));
137
+ req.on('end', () => {
138
+ try {
139
+ resolve(JSON.parse(Buffer.concat(parts).toString('utf8') || '{}'));
140
+ } catch (e) {
141
+ reject(e);
142
+ }
143
+ });
144
+ req.on('error', reject);
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Assemble one text prompt out of the messages.
150
+ *
151
+ * A deliberate simplification: the SDK takes the prompt as a string. History is
152
+ * glued together with role labels — the model understands them, and the platform
153
+ * keeps its own history anyway. When there is time for it, real message passing
154
+ * will appear here instead of gluing.
155
+ *
156
+ * 🔴 THESE ROLE LABELS ARE MODEL-FACING TEXT, NOT DISPLAY TEXT. They go into the
157
+ * prompt, so changing them changes what the model reads. If your platform speaks
158
+ * another language, change them deliberately and together, not one of the two.
159
+ */
160
+ function buildPrompt(messages) {
161
+ const parts = [];
162
+ for (const m of messages ?? []) {
163
+ const who = m.role === 'assistant' ? 'Assistant' : 'User';
164
+ const c = m.content;
165
+ const text =
166
+ typeof c === 'string'
167
+ ? c
168
+ : Array.isArray(c)
169
+ ? c.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')
170
+ : '';
171
+ if (text) parts.push(`${who}: ${text}`);
172
+ }
173
+ return parts.join('\n\n');
174
+ }
175
+
176
+ /**
177
+ * THE PLATFORM TOOL BRIDGE.
178
+ *
179
+ * The engine executes the tools, so its own set is the only one the model sees.
180
+ * Platform plugins do not reach it at all. The bridge builds an MCP server out of
181
+ * the description sent by the platform and proxies the calls back.
182
+ *
183
+ * 🔴 THE GATEWAY DOES NOT KNOW WHAT THESE TOOLS ARE. Names, descriptions and
184
+ * schemas arrive from the other side; there is only transport here. The next
185
+ * agent with a different tool set connects without editing this file — that is
186
+ * what makes it a general solution rather than a patch for one case.
187
+ *
188
+ * TRANSPORT sdk, NOT stdio: an sdk server lives in this same process and spawns
189
+ * no child. Everything the SDK passes to a child process goes as the
190
+ * --mcp-config argument and is readable by any user of the machine through
191
+ * /proc/<pid>/cmdline (mode 444) — verified with a live observer on 2026-08-22.
192
+ * That is why the bridge TICKET does not leak with sdk: it stays in the memory of
193
+ * two processes and in the request body over loopback.
194
+ *
195
+ * 🔴 WHAT EXACTLY THE GATEWAY CARRIES. Not an identity and not a shared secret,
196
+ * but a ONE-TIME TICKET issued by the platform for this turn. The gateway does
197
+ * not know whose it is: there is not a single agent-identity field in this file,
198
+ * neither in the code nor in the comments (we deliberately avoid writing even the
199
+ * name of that field here: otherwise a grep check would find its own caveat and
200
+ * take it for an occurrence). The platform retrieves the identity by the ticket
201
+ * from its own table. So there is nothing here to assert somebody else's identity
202
+ * with — neither for the model nor for the gateway itself.
203
+ */
204
+ function buildBridgeServer(bridge) {
205
+ if (!bridge?.url || !bridge?.ticket || !Array.isArray(bridge.tools) || !bridge.tools.length) return null;
206
+
207
+ // 🔴 THE GATEWAY DOES NOT KNOW WHOSE REQUEST THIS IS, AND MUST NOT. It carries
208
+ // an opaque one-time ticket issued by the platform for this turn and presents it
209
+ // at the door. The platform retrieves the identity by that ticket from its own
210
+ // table. The identity is NOT transmitted: whoever asserts it must not be the one
211
+ // who assigns it. The ticket lives in the CLOSURE of this handler — with the sdk
212
+ // transport it goes neither into a command line nor into the environment
213
+ // (measured: 0 hits across 275 inspected processes).
214
+ const callBridge = async (toolName, args) => {
215
+ const r = await fetch(bridge.url, {
216
+ method: 'POST',
217
+ headers: { 'content-type': 'application/json', 'x-bridge-ticket': bridge.ticket },
218
+ body: JSON.stringify({ tool: toolName, args }),
219
+ });
220
+ if (!r.ok) throw new Error(`the bridge answered ${r.status}`);
221
+ const out = await r.json();
222
+ if (out?.ok !== true) throw new Error(String(out?.error ?? 'the bridge refused without a reason'));
223
+ return out.value;
224
+ };
225
+
226
+ const tools = [];
227
+ for (const t of bridge.tools) {
228
+ let inputShape;
229
+ try {
230
+ inputShape = shape(t.inputSchema ?? { type: 'object', properties: {} });
231
+ } catch (e) {
232
+ // A tool whose schema we cannot assemble is NOT exposed "as is": the model
233
+ // would get a tool with no parameter shape and would fail at execution
234
+ // time. The skip is loud, the rest keep working.
235
+ log(`🔴 tool ${t.name} skipped: ${e?.message}`);
236
+ continue;
237
+ }
238
+ tools.push(
239
+ tool(
240
+ t.name,
241
+ t.description ?? '',
242
+ inputShape,
243
+ async (args) => {
244
+ try {
245
+ const value = await callBridge(t.name, args ?? {});
246
+ return { content: [{ type: 'text', text: JSON.stringify(value ?? null) }] };
247
+ } catch (e) {
248
+ // The refusal is returned as text, not as an exception: the model
249
+ // must read the reason and decide what to do, not see the tool cut
250
+ // off. 🔴 This text is model-facing.
251
+ return { content: [{ type: 'text', text: `REFUSED: ${e?.message ?? e}` }], isError: true };
252
+ }
253
+ },
254
+ ),
255
+ );
256
+ }
257
+ if (!tools.length) return null;
258
+
259
+ // alwaysLoad: otherwise the tools go behind a catalogue search and are not
260
+ // visible in the system header — and the header is precisely our acceptance sign.
261
+ return createSdkMcpServer({ name: bridge.name || 'dsh', version: '0.1.0', tools, alwaysLoad: true });
262
+ }
263
+
264
+ /**
265
+ * Merge the tool servers WITHOUT letting the bridge overwrite somebody else's
266
+ * server with its own name.
267
+ *
268
+ * 🔴 THE NAMING RULE (2026-08-22): server names must not coincide — neither with
269
+ * ones already connected here, nor between the root and subagent levels. The
270
+ * price of a collision is silent, and it goes both ways:
271
+ * * here a plain spread would overwrite a same-named server entirely, and the
272
+ * model would get the bridge in its place without ever learning of it;
273
+ * * for a subagent (experiment B, 2026-08-22) a server bearing THE ROOT'S NAME
274
+ * comes up with its own environment yet the root's one answers anyway — from
275
+ * the start-up alone it looks as if the configuration works.
276
+ * Therefore a collision means refusing to connect the bridge, not a quiet
277
+ * substitution: without the bridge the agent works worse, with a substituted
278
+ * server it works wrongly.
279
+ */
280
+ function mergeMcpServers(base, bridgeName, bridgeServer) {
281
+ const servers = { ...(base ?? {}) };
282
+ if (!bridgeServer) return { servers, mounted: false, conflict: null };
283
+ if (Object.prototype.hasOwnProperty.call(servers, bridgeName)) {
284
+ return { servers, mounted: false, conflict: bridgeName };
285
+ }
286
+ servers[bridgeName] = bridgeServer;
287
+ return { servers, mounted: true, conflict: null };
288
+ }
289
+
290
+ const server = http.createServer(async (req, res) => {
291
+ if (req.url === '/health') {
292
+ const ok = Boolean(readToken());
293
+ res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
294
+ // Health means the PRESENCE OF THE SECRET, not "the process is alive":
295
+ // without a token the service is up but useless, and that must be visible
296
+ // from outside.
297
+ // 🔴 The `token` field is API SURFACE, not a log line: the README documents
298
+ // this exact response and acceptance step 1 compares against it. If you change
299
+ // the wording, change the README and anything scripted against it in the same
300
+ // pass — the HTTP status is the machine-readable part, this field is not.
301
+ res.end(JSON.stringify({ ok, token: ok ? 'present' : 'MISSING', sdk: true }));
302
+ return;
303
+ }
304
+
305
+ if (req.method !== 'POST' || req.url !== '/v1/agent-stream') {
306
+ res.writeHead(404, { 'content-type': 'application/json' });
307
+ res.end(JSON.stringify({ error: 'unknown path; /v1/agent-stream and /health exist' }));
308
+ return;
309
+ }
310
+
311
+ const token = readToken();
312
+ if (!token) {
313
+ res.writeHead(503, { 'content-type': 'application/json' });
314
+ res.end(JSON.stringify({ error: { type: 'no_credential', message: 'no subscription token' } }));
315
+ log('🔴 request rejected: the token could not be read');
316
+ return;
317
+ }
318
+
319
+ let body;
320
+ try {
321
+ body = await readBody(req);
322
+ } catch {
323
+ res.writeHead(400, { 'content-type': 'application/json' });
324
+ res.end(JSON.stringify({ error: { type: 'bad_json', message: 'the request body could not be parsed' } }));
325
+ return;
326
+ }
327
+
328
+ // A stream of events as JSON lines: one line, one event. The format is our own
329
+ // and deliberately simple; translating it into the platform's protocol is the
330
+ // job of a module on the agent's side.
331
+ res.writeHead(200, {
332
+ 'content-type': 'application/x-ndjson; charset=utf-8',
333
+ 'cache-control': 'no-cache',
334
+ });
335
+ const send = (o) => res.write(JSON.stringify(o) + '\n');
336
+
337
+ const started = Date.now();
338
+ const cwd = body.cwd || WORK_DIR;
339
+ const sessionId = crypto.randomUUID(); // Node >=19; we run v24, the global exists
340
+
341
+ // The platform tool bridge: connected ONLY when the other side has sent a
342
+ // descriptor with a ticket. Without one the behaviour is exactly as before.
343
+ let bridgeServer = null;
344
+ try {
345
+ bridgeServer = buildBridgeServer(body.bridge);
346
+ } catch (e) {
347
+ // A failure to build the bridge must not bring down the request itself:
348
+ // without tools the agent works worse, but it works. Staying silent about it
349
+ // is not allowed.
350
+ log(`🔴 the bridge was not built: ${e?.message}`);
351
+ }
352
+ const bridgeName = body.bridge?.name || 'dsh';
353
+ const merged = mergeMcpServers(MCP_SERVERS, bridgeName, bridgeServer);
354
+ const mcpAll = merged.servers;
355
+ // The "connected" line is printed AFTER the merge and only on success: it is
356
+ // our acceptance sign, and it must not lie.
357
+ if (merged.mounted) log(`bridge connected: tools ${body.bridge.tools.length}, server "${bridgeName}" (the ticket carries the identity, the gateway does not know it)`);
358
+ else if (merged.conflict) log(`🔴 bridge NOT connected: the server name "${merged.conflict}" is already taken by another tool server`);
359
+ try {
360
+ const iter = query({
361
+ prompt: buildPrompt(body.messages),
362
+ options: {
363
+ model: body.model || DEFAULT_MODEL,
364
+ // A loop with tools: one turn is only enough for a conversation. We keep
365
+ // a limit so that a jammed agent does not spin forever, but a generous one.
366
+ maxTurns: Number(body.maxTurns) > 0 ? Number(body.maxTurns) : DEFAULT_MAX_TURNS,
367
+ permissionMode: 'bypassPermissions',
368
+ sessionId,
369
+ // 🔴 EXTERNAL TOOL SERVERS ARE CONNECTED HERE, NOT IN THE PLATFORM
370
+ // (2026-08-19, it cost an hour). The engine executes the tools, so its
371
+ // own set is the only one the agent sees. A client plugin on the platform
372
+ // side connects without errors, appears in the plugin set — and does not
373
+ // reach the agent at all: in this arrangement the platform is only a chassis.
374
+ ...(Object.keys(mcpAll).length ? { mcpServers: mcpAll } : {}),
375
+ ...(body.cwd ? { cwd: body.cwd } : { cwd: WORK_DIR }),
376
+ ...(body.system ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: body.system } } : {}),
377
+ env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: token },
378
+ },
379
+ });
380
+
381
+ let model = null;
382
+ for await (const m of iter) {
383
+ if (m.type === 'assistant') {
384
+ model = m.message?.model ?? model;
385
+ for (const b of m.message?.content ?? []) {
386
+ if (b.type === 'text') send({ type: 'text', text: b.text });
387
+ else if (b.type === 'thinking') send({ type: 'thinking', text: b.thinking });
388
+ // Tool work is emitted outward as an EVENT rather than as silence:
389
+ // otherwise a long stretch looks like a hang and the platform has
390
+ // nothing to show. The tool input is not forwarded in full — there can
391
+ // be secrets and megabytes in it; only the name and a short caption.
392
+ else if (b.type === 'tool_use') send({ type: 'tool', name: b.name, brief: briefOf(b.input) });
393
+ }
394
+ const u = m.message?.usage;
395
+ if (u) {
396
+ send({
397
+ type: 'usage',
398
+ inputTokens: u.input_tokens ?? 0,
399
+ outputTokens: u.output_tokens ?? 0,
400
+ cacheReadTokens: u.cache_read_input_tokens,
401
+ cacheWriteTokens: u.cache_creation_input_tokens,
402
+ });
403
+ }
404
+ }
405
+ }
406
+ send({ type: 'done', model, tookMs: Date.now() - started });
407
+ } catch (e) {
408
+ // The error is returned IN THE STREAM rather than as silence: a stream cut
409
+ // off without a reason reads as "the model went quiet", and the investigation
410
+ // starts from nothing.
411
+ const message = explainExit(e, sessionId, cwd);
412
+ log(`🔴 call failed: ${message}`);
413
+ send({ type: 'error', message: message.slice(0, 500) });
414
+ } finally {
415
+ res.end();
416
+ }
417
+ });
418
+
419
+ server.listen(PORT, HOST, () => {
420
+ log(`listening on ${HOST}:${PORT}; token from ${TOKEN_FILE}; engine — the official SDK`);
421
+ if (!readToken()) log('🔴 warning: the token is NOT readable right now, requests will be rejected');
422
+ });
423
+
424
+ for (const sig of ['SIGTERM', 'SIGINT']) {
425
+ process.on(sig, () => {
426
+ log(`${sig} received, shutting down`);
427
+ server.close(() => process.exit(0));
428
+ setTimeout(() => process.exit(0), 5000).unref();
429
+ });
430
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * JSON Schema → zod raw shape. A minimal subset: exactly what zod.toJSONSchema
3
+ * produces on the platform's schemas.
4
+ *
5
+ * WHY. tool() from the SDK requires zod, and zod cannot travel over a wire. The
6
+ * platform hands the schema over as JSON Schema, and the gateway rebuilds zod
7
+ * from it.
8
+ *
9
+ * 🔴 THE BOUNDARY. An unknown type does NOT silently become "anything": such a
10
+ * substitution would give the model a tool with no parameter shape, and a failure
11
+ * would look like working. An unknown type is an exception, the tool is not
12
+ * exposed, and the gateway writes the reason.
13
+ */
14
+ import { z } from 'zod'
15
+
16
+ const node = (s, path) => {
17
+ if (!s || typeof s !== 'object') throw new Error(`${path}: empty schema`)
18
+ if (Array.isArray(s.enum)) {
19
+ if (!s.enum.every((v) => typeof v === 'string')) throw new Error(`${path}: the enum is not made of strings`)
20
+ return z.enum(s.enum)
21
+ }
22
+ // The platform's branded strings (SessionId, GoalId) are described as an
23
+ // intersection of "string AND unknown", and on the wire that is an allOf with
24
+ // an empty second member. We take the single typed member: zod has no empty
25
+ // schema, and the type must not be lost.
26
+ // A branching shape: this is how the platform describes a parameter that takes
27
+ // either a string or an object (schedule_create.at). Without this branch the
28
+ // adapter would not understand a node WITHOUT a type field and would refuse —
29
+ // and the gateway would then silently fail to expose the tool at all. The
30
+ // refusal would be loud in the gateway log and invisible to the model: the tool
31
+ // is simply absent.
32
+ // THE BOUNDARY: there must be at least two branches, and each must assemble on
33
+ // its own. One branch is not a choice but a typo; a branch that will not
34
+ // assemble is a loss of shape, that is, exactly what this whole file guards
35
+ // against.
36
+ const branches = Array.isArray(s.oneOf) ? s.oneOf : Array.isArray(s.anyOf) ? s.anyOf : undefined
37
+ if (branches) {
38
+ const kw = Array.isArray(s.oneOf) ? 'oneOf' : 'anyOf'
39
+ if (branches.length < 2) throw new Error(`${path}: ${kw} of ${branches.length} branch is not a choice`)
40
+ return z.union(branches.map((v, i) => node(v, `${path}|${kw}[${i}]`)))
41
+ }
42
+ if (Array.isArray(s.allOf)) {
43
+ const typed = s.allOf.filter((m) => m && typeof m === 'object' && m.type !== undefined)
44
+ if (typed.length !== 1) throw new Error(`${path}: allOf with ${typed.length} typed members is not supported`)
45
+ return node(typed[0], path)
46
+ }
47
+ switch (s.type) {
48
+ case 'string': return bounds(z.string(), s, 'length')
49
+ case 'number': return bounds(z.number(), s, 'value')
50
+ case 'integer': return bounds(z.number().int(), s, 'value')
51
+ case 'boolean': return z.boolean()
52
+ case 'array': return bounds(z.array(node(s.items, `${path}[]`)), s, 'length')
53
+ case 'object': return z.object(shape(s, path))
54
+ default: throw new Error(`${path}: type ${JSON.stringify(s.type)} is not supported`)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Value and length bounds. Without them the schema QUIETLY weakens: a positive
60
+ * number becomes any number, and the model sees a different contract from the one
61
+ * the platform will enforce. It will get a refusal at execution time, and the
62
+ * reason will be far from obvious.
63
+ */
64
+ const bounds = (t, s, kind) => {
65
+ if (kind === 'value') {
66
+ if (typeof s.minimum === 'number') t = t.min(s.minimum)
67
+ if (typeof s.maximum === 'number') t = t.max(s.maximum)
68
+ if (typeof s.exclusiveMinimum === 'number') t = t.gt(s.exclusiveMinimum)
69
+ if (typeof s.exclusiveMaximum === 'number') t = t.lt(s.exclusiveMaximum)
70
+ return t
71
+ }
72
+ const min = s.minLength ?? s.minItems
73
+ const max = s.maxLength ?? s.maxItems
74
+ if (typeof min === 'number') t = t.min(min)
75
+ if (typeof max === 'number') t = t.max(max)
76
+ return t
77
+ }
78
+
79
+ export const shape = (schema, path = '$') => {
80
+ if (schema?.type !== 'object') throw new Error(`${path}: an object was expected`)
81
+ const required = new Set(schema.required ?? [])
82
+ const out = {}
83
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
84
+ let t = node(sub, `${path}.${key}`)
85
+ // THE ORDER MATTERS. optional() first, describe() second: the SDK's converter
86
+ // reads the description from the OUTER node, so with describe().optional() the
87
+ // parameter description is SILENTLY lost — the schema stays valid and the
88
+ // model does not see the explanation of an optional field. Verified with
89
+ // tools/list against a stub.
90
+ if (!required.has(key)) t = t.optional()
91
+ if (typeof sub.description === 'string') t = t.describe(sub.description)
92
+ out[key] = t
93
+ }
94
+ return out
95
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "subscription-gateway",
3
+ "version": "0.1.0",
4
+ "description": "Subscription gateway for DeepSeek Harness — runs the model loop through the vendor's official agent SDK, so the platform works on a subscription seat instead of a metered API key, with the token kept in one place on the machine.",
5
+ "type": "module",
6
+ "main": "gateway.mjs",
7
+ "files": [
8
+ "gateway.mjs",
9
+ "jsonschema-to-zod.mjs",
10
+ "schedule.json",
11
+ "test-schema-adapter.mjs",
12
+ "systemd",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "deepseek-harness",
17
+ "subscription",
18
+ "gateway",
19
+ "agent-sdk",
20
+ "mcp"
21
+ ],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/iia-arg/dsh-plugins.git",
26
+ "directory": "packages/subscription-gateway"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "dependencies": {
32
+ "@anthropic-ai/claude-agent-sdk": "^0.3.238",
33
+ "zod": "^4.0.0"
34
+ }
35
+ }
package/schedule.json ADDED
@@ -0,0 +1,81 @@
1
+ [
2
+ {
3
+ "pkg": "dsh-schedule",
4
+ "name": "schedule_create",
5
+ "description": "Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest occurrence per overdue rule. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "prompt": {
10
+ "type": "string",
11
+ "description": "Reminder content to present when the target becomes due."
12
+ },
13
+ "after_seconds": {
14
+ "type": "number",
15
+ "description": "Positive safe-integer delay in seconds."
16
+ },
17
+ "every_seconds": {
18
+ "type": "number",
19
+ "description": "Fixed-rate safe-integer interval in seconds, at least 300."
20
+ },
21
+ "at": {
22
+ "oneOf": [
23
+ {
24
+ "type": "string"
25
+ },
26
+ {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "properties": {
30
+ "date": {
31
+ "type": "string"
32
+ },
33
+ "time": {
34
+ "type": "string"
35
+ },
36
+ "time_zone": {
37
+ "type": "string"
38
+ }
39
+ },
40
+ "required": [
41
+ "date",
42
+ "time",
43
+ "time_zone"
44
+ ]
45
+ }
46
+ ],
47
+ "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
48
+ }
49
+ },
50
+ "required": [
51
+ "prompt"
52
+ ]
53
+ }
54
+ },
55
+ {
56
+ "pkg": "dsh-schedule",
57
+ "name": "schedule_list",
58
+ "description": "List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode.",
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {}
62
+ }
63
+ },
64
+ {
65
+ "pkg": "dsh-schedule",
66
+ "name": "schedule_delete",
67
+ "description": "Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false.",
68
+ "parameters": {
69
+ "type": "object",
70
+ "properties": {
71
+ "id": {
72
+ "type": "string",
73
+ "description": "Exact session-local schedule id."
74
+ }
75
+ },
76
+ "required": [
77
+ "id"
78
+ ]
79
+ }
80
+ }
81
+ ]
@@ -0,0 +1,20 @@
1
+ # 🔴 JOURNAL READ ACCESS FOR THE AGENT'S HANDS.
2
+ #
3
+ # WHY HERE AND NOT IN THE PLATFORM'S UNIT. The platform WRITES to the journal,
4
+ # but the agent's commands are executed by the ENGINE — a descendant of THIS unit
5
+ # (see the comment on User=%i in the main file). So the one who reads the journal
6
+ # must be the one who executes, not the one who writes. Our first attempt added
7
+ # the group to the platform: the method was right, the object was the neighbouring
8
+ # one, and the refusal stayed.
9
+ # The class: a right method applied to the wrong object yields confidence, not truth.
10
+ #
11
+ # 🔴 BOTH GROUPS ARE LISTED ON PURPOSE. The token group is set in the main unit and
12
+ # gives the gateway access to the shared token file — losing it means cutting the
13
+ # gateway off from the subscription. We write the full set explicitly instead of
14
+ # relying on it being merged with the main unit.
15
+ #
16
+ # VERIFY BY THE LIVE PROCESS ONLY: grep ^Groups: /proc/<MainPID>/status.
17
+ # `id` and `sudo -u` spawn a NEW process and will show what you want rather than
18
+ # what is.
19
+ [Service]
20
+ SupplementaryGroups=gateway-token systemd-journal
@@ -0,0 +1,34 @@
1
+ # Subscription gateway — ONE INSTANCE PER AGENT.
2
+ #
3
+ # 🔴 %i is the agent's USER name, and that is not cosmetic: the SDK is agentic,
4
+ # it runs the shell and files itself, so the agent's "hands" are the process of
5
+ # THIS unit. Any right that must be given to the agent's hands is given here, and
6
+ # NOT in the platform's unit. Verify by the live process only:
7
+ # /proc/<MainPID>/status.
8
+ #
9
+ # The paths below follow the package name. If you change them, change them in
10
+ # three places: EnvironmentFile, GATEWAY_TOKEN_FILE and ExecStart.
11
+ [Unit]
12
+ Description=Subscription gateway — agent instance %i
13
+ After=network-online.target
14
+ Wants=network-online.target
15
+
16
+ [Service]
17
+ Type=simple
18
+ # The engine executes the tools ITSELF, so the agent's "hands" are THIS user.
19
+ User=%i
20
+ Group=%i
21
+ # Access to the shared token file and the engine directory is by group, not by a
22
+ # copy of the secret.
23
+ SupplementaryGroups=gateway-token
24
+ EnvironmentFile=/etc/subscription-gateway/instance-%i.env
25
+ Environment=GATEWAY_TOKEN_FILE=/etc/subscription-gateway/token
26
+ ExecStart=/usr/local/bin/node /opt/subscription-gateway/gateway.mjs
27
+ Restart=on-failure
28
+ RestartSec=5
29
+ # The sandbox here is DELIBERATELY not tightened: the agent needs its own files
30
+ # and home, and for an agent with sudo the unit's restrictions are no boundary
31
+ # anyway. The real boundary is the rights of the instance user.
32
+
33
+ [Install]
34
+ WantedBy=multi-user.target
@@ -0,0 +1,61 @@
1
+ // A bench for the schema adapter, taken FROM THE PACKAGE FILE. It checks exactly
2
+ // what the adapter was fixed for: shapes that occur in the platform's schemas and
3
+ // that the first version did not know — it lost them SILENTLY, returning z.any().
4
+ import { shape } from './jsonschema-to-zod.mjs'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname } from 'node:path'
8
+
9
+ let ok = 0, bad = 0
10
+ const t = (name, cond, got) => {
11
+ if (cond) { ok++; console.log(` ok ${name}`) }
12
+ else { bad++; console.log(` FAIL ${name}${got === undefined ? '' : ` — got ${JSON.stringify(got)}`}`) }
13
+ }
14
+ const passes = (z, v) => { const r = z.safeParse(v); return r.success }
15
+
16
+ console.log('\n=== A. Real platform schedule schemas ===')
17
+ // The schemas are taken from a file generated BY THE PLATFORM ITSELF: the path is
18
+ // an argument, because what must be checked is your installation, not the one the
19
+ // bench was written on.
20
+ const SCHEDULE_FILE = process.argv[2] ?? `${dirname(fileURLToPath(import.meta.url))}/schedule.json`
21
+ const SCHEDULE = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'))
22
+ for (const tool of SCHEDULE) {
23
+ const s = shape(tool.parameters)
24
+ t(`${tool.name}: the schema assembled, fields ${Object.keys(s).length}`, Object.keys(s).length > 0 || tool.name === 'schedule_list')
25
+ }
26
+
27
+ console.log('\n=== B. The oneOf shape — the reason the adapter was fixed ===')
28
+ {
29
+ // at: a string OR an object — exactly what the platform hands over
30
+ const s = shape({
31
+ type: 'object',
32
+ properties: { at: { anyOf: [{ type: 'string' }, { type: 'object', properties: { date: { type: 'string' } }, required: ['date'] }] } },
33
+ })
34
+ t('the string branch passes', passes(s.at, '2026-08-23T10:00:00Z'))
35
+ t('the object branch passes', passes(s.at, { date: '2026-08-23' }))
36
+ t('a foreign type does NOT pass (it did not degenerate into any)', !passes(s.at, 42), s.at?._def?.typeName)
37
+ }
38
+
39
+ console.log('\n=== C. Required and optional ===')
40
+ {
41
+ const s = shape({
42
+ type: 'object',
43
+ properties: { prompt: { type: 'string' }, after_seconds: { type: 'number' } },
44
+ required: ['prompt'],
45
+ })
46
+ t('required with no value — refused', !passes(s.prompt, undefined))
47
+ t('optional with no value — passes', passes(s.after_seconds, undefined))
48
+ t('optional with a value — passes', passes(s.after_seconds, 300))
49
+ }
50
+
51
+ console.log('\n=== D. Descriptions reach the model ===')
52
+ {
53
+ // 🔴 This literal appears twice on purpose — as the input and as the expected
54
+ // output. Change one without the other and the check goes red on healthy code.
55
+ const DESCRIPTION = 'the exact identifier'
56
+ const s = shape({ type: 'object', properties: { id: { type: 'string', description: DESCRIPTION } }, required: ['id'] })
57
+ t('the field description was carried over', s.id?.description === DESCRIPTION, s.id?.description)
58
+ }
59
+
60
+ console.log(`\nTOTAL: passed ${ok}, failed ${bad}`)
61
+ process.exit(bad ? 1 : 0)