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 +244 -0
- package/gateway.mjs +430 -0
- package/jsonschema-to-zod.mjs +95 -0
- package/package.json +35 -0
- package/schedule.json +81 -0
- package/systemd/60-journal-read.conf +20 -0
- package/systemd/gateway@.service +34 -0
- package/test-schema-adapter.mjs +61 -0
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)
|