@suveren/gateway 0.12.0 → 0.13.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 +34 -0
- package/bin/suveren-gateway.js +128 -12
- package/dist/control-plane/index.mjs +555 -122
- package/dist/mcp-server/http.mjs +487 -157
- package/dist/ui/assets/{index-BzcgeHlq.js → index-CmLqzgqA.js} +21 -21
- package/dist/ui/assets/{index-BFOprnFZ.css → index-y1OFeYnS.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/lib/autostart-templates.d.mts +6 -0
- package/lib/autostart-templates.mjs +21 -4
- package/lib/config.d.mts +10 -0
- package/lib/config.mjs +79 -18
- package/lib/policy.d.mts +34 -0
- package/lib/policy.mjs +369 -0
- package/package.json +1 -1
- package/server.js +51 -8
package/lib/policy.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed-settings policy — IT-controlled overrides that an employee cannot
|
|
3
|
+
* change locally (W3 of the Windows installer track; see
|
|
4
|
+
* suveren-as/docs/work-plan.md, "Added 2026-10-02 — Windows installer").
|
|
5
|
+
*
|
|
6
|
+
* Sources, highest precedence first:
|
|
7
|
+
* 1. Windows registry `HKLM\SOFTWARE\Policies\Suveren\Gateway`, then
|
|
8
|
+
* `HKCU\SOFTWARE\Policies\Suveren\Gateway` (HKLM wins over HKCU).
|
|
9
|
+
* Read via `reg query` (child_process) — no native modules. Skipped
|
|
10
|
+
* entirely on non-Windows.
|
|
11
|
+
* 2. A JSON file: `%ProgramData%\Suveren\gateway-policy.json` (Windows),
|
|
12
|
+
* `/Library/Application Support/Suveren/gateway-policy.json` (macOS),
|
|
13
|
+
* `/etc/suveren/gateway-policy.json` (Linux) — overridable for tests
|
|
14
|
+
* via `SUVEREN_POLICY_FILE`.
|
|
15
|
+
*
|
|
16
|
+
* A key present in EITHER source is LOCKED: every resolver in this codebase
|
|
17
|
+
* (resolveAsUrl, resolveCaFile, resolvePinTls, resolveProxyUrl,
|
|
18
|
+
* resolveSimulation / isSimulationMode) must return the policy value
|
|
19
|
+
* regardless of env var, saved config.json, or CLI flag — see those
|
|
20
|
+
* modules' own doc comments for where this is wired in. Overall precedence:
|
|
21
|
+
* policy > env > saved config > defaults.
|
|
22
|
+
*
|
|
23
|
+
* `Proxy` is the one key that OVERRIDES an environment variable rather than
|
|
24
|
+
* merely out-ranking a saved value: on a Windows company laptop the proxy is
|
|
25
|
+
* set in system settings, not `HTTP_PROXY`/`HTTPS_PROXY` — see
|
|
26
|
+
* bundle/server.js, which sets both env vars from the policy value
|
|
27
|
+
* unconditionally when `Proxy` is locked (every other locked key has no env
|
|
28
|
+
* tier to override in the first place). `NoProxy` is read the same way but
|
|
29
|
+
* has no saved-config/CLI counterpart at all — NO_PROXY stays env-only
|
|
30
|
+
* except for this one override.
|
|
31
|
+
*
|
|
32
|
+
* Registry value names = JSON file keys (PascalCase, matching what an IT
|
|
33
|
+
* admin would see in either place): AsUrl, CaFile, PinTls, Proxy, NoProxy,
|
|
34
|
+
* Simulation, InstallMethod.
|
|
35
|
+
*
|
|
36
|
+
* Mirrored (same logic, duplicated — no shared runtime package between the
|
|
37
|
+
* CLI and the two apps, see config.mjs's doc comment for the established
|
|
38
|
+
* pattern) in `apps/mcp-server/src/lib/policy.ts` and
|
|
39
|
+
* `apps/control-plane/src/lib/policy.ts`. Keep all three in step.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import { spawnSync } from 'node:child_process';
|
|
45
|
+
|
|
46
|
+
const POLICY_KEYS = ['AsUrl', 'CaFile', 'PinTls', 'Proxy', 'NoProxy', 'Simulation', 'InstallMethod'];
|
|
47
|
+
|
|
48
|
+
/** The documented production path, relative to the hive — never changes for
|
|
49
|
+
* a real install. Overridable ONLY via `SUVEREN_POLICY_REGISTRY_KEY`, which
|
|
50
|
+
* exists purely so `gateway-policy-windows-registry.test.ts` can point at a
|
|
51
|
+
* unique per-run key instead of the real one: `pnpm -r test` runs every
|
|
52
|
+
* workspace package in parallel, so a test writing to the REAL key would
|
|
53
|
+
* race every OTHER package's tests reading the real registry on the same
|
|
54
|
+
* Windows CI runner at the same time (this raced in practice — see
|
|
55
|
+
* `SUVEREN_POLICY_REGISTRY` below for the other half of the fix). */
|
|
56
|
+
const DEFAULT_REGISTRY_BASE_KEY = 'SOFTWARE\\Policies\\Suveren\\Gateway';
|
|
57
|
+
|
|
58
|
+
/** The full `HIVE\base\key` path `reg query`/`reg add` operate on. A pure
|
|
59
|
+
* function (no I/O) so a test can assert the default without mocking
|
|
60
|
+
* anything — see gateway-policy.test.ts's "documented default paths". */
|
|
61
|
+
export function registryKeyPath(hive) {
|
|
62
|
+
const baseKey = process.env.SUVEREN_POLICY_REGISTRY_KEY || DEFAULT_REGISTRY_BASE_KEY;
|
|
63
|
+
return `${hive}\\${baseKey}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate + normalize a candidate Authority Server URL — the SAME rule
|
|
68
|
+
* `config set as-url` enforces (see config.mjs's validateAsUrl), duplicated
|
|
69
|
+
* rather than imported so this module has no dependency on config.mjs (the
|
|
70
|
+
* TS mirrors of each live in different files entirely).
|
|
71
|
+
*/
|
|
72
|
+
function validateAsUrl(candidate) {
|
|
73
|
+
const trimmed = (candidate ?? '').trim();
|
|
74
|
+
if (!trimmed) return { ok: false, error: 'The Authority Server URL is empty.' };
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(trimmed);
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false, error: `"${trimmed}" is not a valid URL.` };
|
|
80
|
+
}
|
|
81
|
+
const isLocalHttp =
|
|
82
|
+
parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
|
|
83
|
+
if (parsed.protocol !== 'https:' && !isLocalHttp) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `"${trimmed}" must use https:// (http:// is only allowed for http://localhost or http://127.0.0.1).`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let normalized = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
90
|
+
if (normalized.endsWith('/') && normalized !== `${parsed.origin}/`) {
|
|
91
|
+
normalized = normalized.slice(0, -1);
|
|
92
|
+
} else if (normalized === `${parsed.origin}/`) {
|
|
93
|
+
normalized = parsed.origin;
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, url: normalized };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Same rule as config.mjs's validateCaFile: must exist on disk. Resolved
|
|
99
|
+
* against cwd like the CLI flag is — a policy-set relative path is
|
|
100
|
+
* unusual, but behaving consistently with `config set ca-file` beats a
|
|
101
|
+
* second rule nobody remembers. */
|
|
102
|
+
function validateCaFile(candidate) {
|
|
103
|
+
const trimmed = (candidate ?? '').trim();
|
|
104
|
+
if (!trimmed) return { ok: false, error: 'The CA file path is empty.' };
|
|
105
|
+
if (!existsSync(trimmed)) return { ok: false, error: `No file at "${trimmed}".` };
|
|
106
|
+
return { ok: true, path: trimmed };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Same rule as config.mjs's validateProxyUrl: http:// or https:// only. */
|
|
110
|
+
function validateProxyUrl(candidate) {
|
|
111
|
+
const trimmed = (candidate ?? '').trim();
|
|
112
|
+
if (!trimmed) return { ok: false, error: 'The proxy URL is empty.' };
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = new URL(trimmed);
|
|
116
|
+
} catch {
|
|
117
|
+
return { ok: false, error: `"${trimmed}" is not a valid URL.` };
|
|
118
|
+
}
|
|
119
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
120
|
+
return { ok: false, error: `"${trimmed}" must use http:// or https://.` };
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, url: trimmed };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Accepts a JSON boolean, or the DWORD-style 0/1 (as a number OR a string,
|
|
126
|
+
* since the registry parser below hands back numbers but a hand-edited
|
|
127
|
+
* JSON file might use either). Returns undefined — not a default — for
|
|
128
|
+
* anything else, so the caller can tell "not a boolean" from "false". */
|
|
129
|
+
function coerceBoolean(value) {
|
|
130
|
+
if (typeof value === 'boolean') return value;
|
|
131
|
+
if (typeof value === 'number') {
|
|
132
|
+
if (value === 0) return false;
|
|
133
|
+
if (value === 1) return true;
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === 'string') {
|
|
137
|
+
const v = value.trim().toLowerCase();
|
|
138
|
+
if (v === 'true' || v === '1') return true;
|
|
139
|
+
if (v === 'false' || v === '0') return false;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Registry (Windows only) ────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse `reg query "HKLM\...\Gateway"` stdout. Real output looks like:
|
|
148
|
+
*
|
|
149
|
+
* HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Suveren\Gateway
|
|
150
|
+
* AsUrl REG_SZ https://as.company.internal
|
|
151
|
+
* PinTls REG_DWORD 0x1
|
|
152
|
+
*
|
|
153
|
+
* Value lines are 4-space-indented; columns are separated by runs of
|
|
154
|
+
* whitespace. REG_DWORD values print as hex ("0x1"), parsed to a number
|
|
155
|
+
* here so coerceBoolean can treat it like any other 0/1.
|
|
156
|
+
*/
|
|
157
|
+
export function parseRegQueryOutput(stdout) {
|
|
158
|
+
const out = {};
|
|
159
|
+
const lineRe = /^ {4}(\S+)\s+(REG_SZ|REG_EXPAND_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_MULTI_SZ)\s+(.*)$/;
|
|
160
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
161
|
+
const m = lineRe.exec(line);
|
|
162
|
+
if (!m) continue;
|
|
163
|
+
const [, name, type, rawValue] = m;
|
|
164
|
+
if (!POLICY_KEYS.includes(name)) continue;
|
|
165
|
+
if (type === 'REG_DWORD' || type === 'REG_QWORD') {
|
|
166
|
+
out[name] = parseInt(rawValue, 16);
|
|
167
|
+
} else {
|
|
168
|
+
out[name] = rawValue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `{ values, source }` for one registry hive, or `{ values: {}, source }`
|
|
175
|
+
* on any failure (key absent — the normal "no policy set" case — missing
|
|
176
|
+
* `reg` binary, or a platform that isn't Windows at all). Never throws:
|
|
177
|
+
* an operator without IT policy must see the gateway behave exactly as it
|
|
178
|
+
* always has.
|
|
179
|
+
*
|
|
180
|
+
* `SUVEREN_POLICY_REGISTRY=off` skips the registry entirely, returning
|
|
181
|
+
* `{}` without even spawning `reg` — unit tests set this globally (see
|
|
182
|
+
* each app's vitest setup) so a real IT policy on the machine running the
|
|
183
|
+
* suite, or another package's test writing to the registry concurrently,
|
|
184
|
+
* cannot change what these tests see. Production never sets this. */
|
|
185
|
+
function readRegistryHive(hive) {
|
|
186
|
+
const path = registryKeyPath(hive);
|
|
187
|
+
const source = `the registry policy (${path})`;
|
|
188
|
+
// Checked BEFORE the platform branch so the switch is testable on any OS,
|
|
189
|
+
// not just Windows — see gateway-policy.test.ts.
|
|
190
|
+
if (process.env.SUVEREN_POLICY_REGISTRY === 'off') return { values: {}, source };
|
|
191
|
+
if (process.platform !== 'win32') return { values: {}, source };
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = spawnSync('reg', ['query', path], { encoding: 'utf8' });
|
|
195
|
+
} catch {
|
|
196
|
+
return { values: {}, source };
|
|
197
|
+
}
|
|
198
|
+
if (!result || result.status !== 0 || !result.stdout) return { values: {}, source };
|
|
199
|
+
return { values: parseRegQueryOutput(result.stdout), source };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Policy file ────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function policyFilePath() {
|
|
205
|
+
if (process.env.SUVEREN_POLICY_FILE) return process.env.SUVEREN_POLICY_FILE;
|
|
206
|
+
if (process.platform === 'win32') {
|
|
207
|
+
const programData = process.env.ProgramData || process.env.PROGRAMDATA || 'C:\\ProgramData';
|
|
208
|
+
return join(programData, 'Suveren', 'gateway-policy.json');
|
|
209
|
+
}
|
|
210
|
+
if (process.platform === 'darwin') {
|
|
211
|
+
return '/Library/Application Support/Suveren/gateway-policy.json';
|
|
212
|
+
}
|
|
213
|
+
return '/etc/suveren/gateway-policy.json';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Throws (fail closed, audibly — same rule as config.mjs's
|
|
217
|
+
* readConfigStrict) when the file exists but is not valid JSON. A missing
|
|
218
|
+
* file is the normal "no policy set" case and returns `{}`. */
|
|
219
|
+
function readPolicyFile() {
|
|
220
|
+
const path = policyFilePath();
|
|
221
|
+
const source = `the policy file (${path})`;
|
|
222
|
+
if (!existsSync(path)) return { values: {}, source };
|
|
223
|
+
let data;
|
|
224
|
+
try {
|
|
225
|
+
data = JSON.parse(readFileSync(path, 'utf8'));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw new Error(`Could not parse policy file ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
const values = {};
|
|
230
|
+
for (const key of POLICY_KEYS) {
|
|
231
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) values[key] = data[key];
|
|
232
|
+
}
|
|
233
|
+
return { values, source };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Merge + validate ───────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/** First entry (in precedence order) that defines each key wins. Returns
|
|
239
|
+
* the merged raw values plus, per key, which source they came from (for
|
|
240
|
+
* error messages). */
|
|
241
|
+
function mergeBySource(entries) {
|
|
242
|
+
const merged = {};
|
|
243
|
+
const sourceOf = {};
|
|
244
|
+
for (const key of POLICY_KEYS) {
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (Object.prototype.hasOwnProperty.call(entry.values, key) && entry.values[key] !== undefined) {
|
|
247
|
+
merged[key] = entry.values[key];
|
|
248
|
+
sourceOf[key] = entry.source;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { merged, sourceOf };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let cached = null;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read + validate the effective policy. Memoized for the life of the
|
|
260
|
+
* process (registry reads spawn a child process — doing that on every
|
|
261
|
+
* `isSimulationMode()` call, which runs per tool call, would be a
|
|
262
|
+
* synchronous fork on the hot path). Policy changes take effect on the next
|
|
263
|
+
* restart anyway, same as every other setting here — see
|
|
264
|
+
* `_resetPolicyCacheForTests` for the one case that needs otherwise.
|
|
265
|
+
*
|
|
266
|
+
* Throws a named error — mentioning the key, the offending value, and which
|
|
267
|
+
* source it came from — on an invalid value, exactly like a bad `config
|
|
268
|
+
* set` input (see config.mjs's resolveAsUrl doc comment on failing closed
|
|
269
|
+
* and audibly rather than silently falling back).
|
|
270
|
+
*/
|
|
271
|
+
export function readPolicy() {
|
|
272
|
+
if (cached) return cached;
|
|
273
|
+
|
|
274
|
+
const hklm = readRegistryHive('HKLM');
|
|
275
|
+
const hkcu = readRegistryHive('HKCU');
|
|
276
|
+
const file = readPolicyFile(); // may throw — propagated, not swallowed
|
|
277
|
+
|
|
278
|
+
const { merged, sourceOf } = mergeBySource([hklm, hkcu, file]);
|
|
279
|
+
|
|
280
|
+
const policy = {};
|
|
281
|
+
const locked = new Set();
|
|
282
|
+
|
|
283
|
+
if (merged.AsUrl !== undefined) {
|
|
284
|
+
const v = validateAsUrl(String(merged.AsUrl));
|
|
285
|
+
if (!v.ok) {
|
|
286
|
+
throw new Error(`Invalid policy AsUrl ("${merged.AsUrl}") from ${sourceOf.AsUrl}: ${v.error}`);
|
|
287
|
+
}
|
|
288
|
+
policy.asUrl = v.url;
|
|
289
|
+
locked.add('asUrl');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (merged.CaFile !== undefined) {
|
|
293
|
+
const v = validateCaFile(String(merged.CaFile));
|
|
294
|
+
if (!v.ok) {
|
|
295
|
+
throw new Error(`Invalid policy CaFile ("${merged.CaFile}") from ${sourceOf.CaFile}: ${v.error}`);
|
|
296
|
+
}
|
|
297
|
+
policy.caFile = v.path;
|
|
298
|
+
locked.add('caFile');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (merged.PinTls !== undefined) {
|
|
302
|
+
const b = coerceBoolean(merged.PinTls);
|
|
303
|
+
if (b === undefined) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Invalid policy PinTls ("${merged.PinTls}") from ${sourceOf.PinTls}: must be a boolean or 0/1.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
policy.pinTls = b;
|
|
309
|
+
locked.add('pinTls');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (merged.Proxy !== undefined) {
|
|
313
|
+
const v = validateProxyUrl(String(merged.Proxy));
|
|
314
|
+
if (!v.ok) {
|
|
315
|
+
throw new Error(`Invalid policy Proxy ("${merged.Proxy}") from ${sourceOf.Proxy}: ${v.error}`);
|
|
316
|
+
}
|
|
317
|
+
policy.proxy = v.url;
|
|
318
|
+
locked.add('proxy');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// NoProxy has no saved-config/CLI counterpart — just a string, no URL
|
|
322
|
+
// shape to validate (it's a NO_PROXY-style hostname list, same grammar as
|
|
323
|
+
// the env var itself).
|
|
324
|
+
if (merged.NoProxy !== undefined) {
|
|
325
|
+
const raw = String(merged.NoProxy).trim();
|
|
326
|
+
if (!raw) {
|
|
327
|
+
throw new Error(`Invalid policy NoProxy ("${merged.NoProxy}") from ${sourceOf.NoProxy}: must not be empty.`);
|
|
328
|
+
}
|
|
329
|
+
policy.noProxy = raw;
|
|
330
|
+
locked.add('noProxy');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (merged.Simulation !== undefined) {
|
|
334
|
+
const b = coerceBoolean(merged.Simulation);
|
|
335
|
+
if (b === undefined) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Invalid policy Simulation ("${merged.Simulation}") from ${sourceOf.Simulation}: must be a boolean or 0/1.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
policy.simulation = b;
|
|
341
|
+
locked.add('simulation');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (merged.InstallMethod !== undefined) {
|
|
345
|
+
const raw = String(merged.InstallMethod).trim().toLowerCase();
|
|
346
|
+
if (raw !== 'managed') {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Invalid policy InstallMethod ("${merged.InstallMethod}") from ${sourceOf.InstallMethod}: only "managed" is supported.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
policy.installMethod = 'managed';
|
|
352
|
+
locked.add('installMethod');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
cached = { policy, locked };
|
|
356
|
+
return cached;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Convenience: is this one key locked by policy? */
|
|
360
|
+
export function isPolicyLocked(key) {
|
|
361
|
+
return readPolicy().locked.has(key);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Drop the memoized policy so the next `readPolicy()` call re-reads the
|
|
365
|
+
* registry/file. Test-only — a real process never needs this, since
|
|
366
|
+
* policy is only ever consulted again after a restart. */
|
|
367
|
+
export function _resetPolicyCacheForTests() {
|
|
368
|
+
cached = null;
|
|
369
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suveren/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Suveren gateway — local agent gateway built in compliance with the Human Agency Protocol (HAP). Runs the UI, control plane, and MCP server in one Node process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -12,7 +12,8 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { homedir, tmpdir, constants as osConstants } from 'node:os';
|
|
14
14
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
|
-
import { resolveCaFile, resolveSimulation } from './lib/config.mjs';
|
|
15
|
+
import { resolveCaFile, resolveProxyUrl, resolveSimulation } from './lib/config.mjs';
|
|
16
|
+
import { readPolicy, isPolicyLocked } from './lib/policy.mjs';
|
|
16
17
|
import { unsupportedNodeReason } from './lib/node-version.mjs';
|
|
17
18
|
|
|
18
19
|
// Docker and the login service start this file directly, not through the CLI,
|
|
@@ -121,6 +122,43 @@ if (savedCaFile && !process.env.SUVEREN_CA_REEXEC_DONE) {
|
|
|
121
122
|
// via `...process.env`, all the way down to individual integrations.
|
|
122
123
|
delete process.env.SUVEREN_CA_REEXEC_DONE;
|
|
123
124
|
|
|
125
|
+
// ─── Corporate proxy (`config set proxy <url>` / `--proxy` / IT policy) ────
|
|
126
|
+
//
|
|
127
|
+
// `HTTP_PROXY`/`HTTPS_PROXY` are read fresh on every outbound call (see
|
|
128
|
+
// proxy-env.ts in both apps) — unlike NODE_EXTRA_CA_CERTS above, there is no
|
|
129
|
+
// Node-startup-time requirement, so no re-exec dance is needed here.
|
|
130
|
+
//
|
|
131
|
+
// Two different precedence rules apply, because `resolveProxyUrl` already
|
|
132
|
+
// folds IT policy ahead of the saved value (see config.mjs):
|
|
133
|
+
// - IT policy LOCKED (`Proxy` — see lib/policy.mjs): OVERRIDES an
|
|
134
|
+
// operator's own HTTP_PROXY/HTTPS_PROXY unconditionally — "locked"
|
|
135
|
+
// means locked even against a pre-existing env var, the one case among
|
|
136
|
+
// all the policy-able settings where there IS a meaningful env tier to
|
|
137
|
+
// override (every other locked key has none to begin with). This is
|
|
138
|
+
// the Windows-company-laptop case: the proxy lives in system settings,
|
|
139
|
+
// not an env var an employee thinks to set.
|
|
140
|
+
// - Otherwise (a plain saved `config set proxy`): fills in whichever of
|
|
141
|
+
// HTTP_PROXY/HTTPS_PROXY is still unset — an operator's own value
|
|
142
|
+
// always wins, same as every other env-vs-saved precedence here.
|
|
143
|
+
const resolvedProxyUrl = resolveProxyUrl(DATA_DIR);
|
|
144
|
+
if (resolvedProxyUrl) {
|
|
145
|
+
if (isPolicyLocked('proxy')) {
|
|
146
|
+
process.env.HTTP_PROXY = resolvedProxyUrl;
|
|
147
|
+
process.env.HTTPS_PROXY = resolvedProxyUrl;
|
|
148
|
+
} else {
|
|
149
|
+
process.env.HTTP_PROXY ??= resolvedProxyUrl;
|
|
150
|
+
process.env.HTTPS_PROXY ??= resolvedProxyUrl;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `NoProxy` has no saved-config/CLI counterpart at all (see policy.mjs) —
|
|
155
|
+
// NO_PROXY stays env-only except for this one IT-policy override, which (like
|
|
156
|
+
// `Proxy` above) overrides an operator's own NO_PROXY unconditionally when set.
|
|
157
|
+
const policyNoProxy = readPolicy().policy.noProxy;
|
|
158
|
+
if (policyNoProxy) {
|
|
159
|
+
process.env.NO_PROXY = policyNoProxy;
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
const CP_PORT = process.env.SUVEREN_CP_PORT ?? '3400';
|
|
125
163
|
const MCP_PORT = process.env.SUVEREN_MCP_PORT ?? '3430';
|
|
126
164
|
const UI_DIST = process.env.HAP_UI_DIST ?? join(__dirname, 'dist', 'ui');
|
|
@@ -147,13 +185,18 @@ const env = {
|
|
|
147
185
|
SUVEREN_PROFILES_DIR: PROFILES_DIR,
|
|
148
186
|
// Single shared internal secret so CP↔MCP authenticate the bridge.
|
|
149
187
|
SUVEREN_INTERNAL_SECRET: process.env.SUVEREN_INTERNAL_SECRET ?? randomHex(32),
|
|
150
|
-
// Simulation mode —
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
188
|
+
// Simulation mode — IT policy (see lib/policy.mjs) wins UNCONDITIONALLY,
|
|
189
|
+
// ahead of even an explicit env var: that is what "locked" means. Below
|
|
190
|
+
// that: an explicit env var (set directly, or by `start --simulation`
|
|
191
|
+
// setting process.env for THIS run — see bundle/bin/suveren-gateway.js)
|
|
192
|
+
// wins; otherwise the saved config.json value, which is what
|
|
193
|
+
// `suveren-gateway simulation on|off` changes for every future
|
|
194
|
+
// start/restart, including autostart (which spawns this file directly,
|
|
195
|
+
// bypassing the CLI's own flag handling).
|
|
196
|
+
SUVEREN_SIMULATION:
|
|
197
|
+
readPolicy().policy.simulation !== undefined
|
|
198
|
+
? (readPolicy().policy.simulation ? '1' : '0')
|
|
199
|
+
: (process.env.SUVEREN_SIMULATION ?? (resolveSimulation(DATA_DIR) ? '1' : '0')),
|
|
157
200
|
// Passed by the login service (see bundle/lib/autostart-templates.mjs) so the
|
|
158
201
|
// control-plane knows a human is NOT sitting in front of a terminal watching
|
|
159
202
|
// it start — that is when a locked gateway needs to announce itself.
|