actual-mcp-server 0.22.49 → 0.23.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 +3 -1
- package/dist/src/auth/static-tokens.js +167 -0
- package/dist/src/config.js +27 -0
- package/dist/src/index.js +7 -2
- package/dist/src/lib/actual-adapter.js +40 -8
- package/dist/src/lib/apiLogin.js +172 -8
- package/dist/src/lib/authPosture.js +3 -2
- package/dist/src/lib/config-registry.js +2 -1
- package/dist/src/logger.js +3 -1
- package/dist/src/server/httpServer.js +44 -22
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -516,6 +516,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
516
516
|
| **Security & Authentication** ||||
|
|
517
517
|
| `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
|
|
518
518
|
| `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
|
|
519
|
+
| `MCP_STATIC_TOKENS` | _(none)_ | No | Several static Bearer tokens, one per client, each with its own principal and budget allow-list (#572). JSON keyed by principal: `{"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}`. Budgets are registry names. Can coexist with `MCP_SSE_AUTHORIZATION` (which stays unrestricted); refused with `AUTH_PROVIDER=oidc`. See [Security](docs/SECURITY_AND_PRIVACY.md) |
|
|
520
|
+
| `MCP_TOKEN_*` | _(none)_ | If named | The token values `MCP_STATIC_TOKENS` points at, one variable per principal (`MCP_TOKEN_ALICE`). At least 32 characters (`openssl rand -hex 32`), unique, RFC 6750 characters only; a bad one refuses to start |
|
|
519
521
|
| `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
|
|
520
522
|
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
|
|
521
523
|
| `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
|
|
@@ -836,7 +838,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
836
838
|
|
|
837
839
|
---
|
|
838
840
|
|
|
839
|
-
**Version:** 0.
|
|
841
|
+
**Version:** 0.23.0 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
842
|
|
|
841
843
|
## Sponsor
|
|
842
844
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/auth/static-tokens.ts
|
|
2
|
+
//
|
|
3
|
+
// #572: several static Bearer tokens, each bound to its own principal and budget
|
|
4
|
+
// allow-list (MCP_STATIC_TOKENS). Proposed and first implemented by @jospaquim in
|
|
5
|
+
// PR #569; reimplemented here on develop.
|
|
6
|
+
//
|
|
7
|
+
// Why: with the single MCP_SSE_AUTHORIZATION token every caller is the one
|
|
8
|
+
// principal 'static-bearer', so several MCP clients on one server share the #189
|
|
9
|
+
// per-principal budget preference and have no budget isolation without OIDC.
|
|
10
|
+
// Here each client presents its own token, which resolves to a principal (keys
|
|
11
|
+
// the #189 preference and session ownership) and to the request's allowedBudgets,
|
|
12
|
+
// enforced by the SAME chokepoints OIDC uses (_enforceBudgetAcl, switchBudget,
|
|
13
|
+
// the #570 listing and import guards). One enforcement path, two ways to fill it.
|
|
14
|
+
//
|
|
15
|
+
// Format (JSON object keyed by principal; budgets are registry NAMES):
|
|
16
|
+
// MCP_STATIC_TOKENS={"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
|
|
17
|
+
// The token itself lives in the variable `tokenEnv` names, so the JSON can sit in
|
|
18
|
+
// a compose file while the secrets stay in the secret store.
|
|
19
|
+
//
|
|
20
|
+
// WHY THIS MODULE IMPORTS ONLY node:crypto. config.ts calls parseStaticTokensConfig
|
|
21
|
+
// at config-parse time (the budget-acl-parse.ts pattern, #551): importing the
|
|
22
|
+
// logger, config or the adapter from here would create an ESM cycle. It throws and
|
|
23
|
+
// never logs.
|
|
24
|
+
//
|
|
25
|
+
// ERROR-TEXT CONTRACT. A thrown message names principals, variable names and
|
|
26
|
+
// budget names only, never a token value.
|
|
27
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
28
|
+
/** Principal reserved for the legacy MCP_SSE_AUTHORIZATION token. */
|
|
29
|
+
export const LEGACY_STATIC_PRINCIPAL = 'static-bearer';
|
|
30
|
+
/** Minimum token length: `openssl rand -hex 16` produces exactly this. */
|
|
31
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
32
|
+
const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
33
|
+
// Restricted to one family so a principal cannot point at ACTUAL_PASSWORD or any
|
|
34
|
+
// other secret, and so logger.ts can scrub every value of the family.
|
|
35
|
+
const TOKEN_ENV_RE = /^MCP_TOKEN_[A-Z0-9_]+$/;
|
|
36
|
+
// RFC 6750 b64token: '=' only as trailing padding, so whitespace and other
|
|
37
|
+
// characters a header cannot carry intact are refused at startup.
|
|
38
|
+
const B64TOKEN_RE = /^[A-Za-z0-9._~+/-]+=*$/;
|
|
39
|
+
/** True when the raw value is set (unset and blank both mean "feature off"). */
|
|
40
|
+
export function staticTokensConfigured(raw) {
|
|
41
|
+
return raw !== undefined && raw.trim() !== '';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Shape check of MCP_STATIC_TOKENS, run by config.ts at startup on both
|
|
45
|
+
* transports. Returns {} when unset or blank. Touches no environment and no
|
|
46
|
+
* registry: that is resolveStaticTokens, HTTP only.
|
|
47
|
+
*/
|
|
48
|
+
export function parseStaticTokensConfig(raw) {
|
|
49
|
+
if (!staticTokensConfigured(raw))
|
|
50
|
+
return {};
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error('MCP_STATIC_TOKENS is not valid JSON');
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error('MCP_STATIC_TOKENS must be a JSON object keyed by principal');
|
|
60
|
+
}
|
|
61
|
+
const entries = Object.entries(parsed);
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
throw new Error('MCP_STATIC_TOKENS is set but defines no principals');
|
|
64
|
+
}
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [principal, value] of entries) {
|
|
67
|
+
if (!PRINCIPAL_RE.test(principal)) {
|
|
68
|
+
throw new Error(`MCP_STATIC_TOKENS: invalid principal name "${principal}" (letters, digits, . _ -; at most 64)`);
|
|
69
|
+
}
|
|
70
|
+
if (principal === LEGACY_STATIC_PRINCIPAL) {
|
|
71
|
+
throw new Error(`MCP_STATIC_TOKENS: principal "${LEGACY_STATIC_PRINCIPAL}" is reserved for MCP_SSE_AUTHORIZATION`);
|
|
72
|
+
}
|
|
73
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
74
|
+
throw new Error(`MCP_STATIC_TOKENS: entry for "${principal}" must be an object {"tokenEnv", "budgets"}`);
|
|
75
|
+
}
|
|
76
|
+
const { tokenEnv, budgets } = value;
|
|
77
|
+
if (typeof tokenEnv !== 'string' || !TOKEN_ENV_RE.test(tokenEnv)) {
|
|
78
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv must be a variable name starting with MCP_TOKEN_ (A to Z, 0 to 9, _)`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(budgets) || budgets.length === 0 || !budgets.every((b) => typeof b === 'string' && b.trim() !== '')) {
|
|
81
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".budgets must be a non-empty array of budget names`);
|
|
82
|
+
}
|
|
83
|
+
out[principal] = { tokenEnv, budgets: [...budgets] };
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function digestOf(token) {
|
|
88
|
+
return createHash('sha256').update(token, 'utf8').digest();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve MCP_STATIC_TOKENS against the environment and the budget registry, and
|
|
92
|
+
* build the matcher's entries: one per static principal, plus the legacy token
|
|
93
|
+
* when set. Called by startHttpServer before it binds, so a refusal stops startup
|
|
94
|
+
* the way an unreadable TLS key does. Returns [] when neither token source is set.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveStaticTokens(raw, env, registry, legacyToken) {
|
|
97
|
+
const parsed = parseStaticTokensConfig(raw);
|
|
98
|
+
const byName = new Map();
|
|
99
|
+
for (const b of registry)
|
|
100
|
+
byName.set(b.name.toLowerCase(), b);
|
|
101
|
+
const entries = [];
|
|
102
|
+
// Uniqueness is checked on digests: equal digests are equal tokens, and no
|
|
103
|
+
// token value has to be held to compare them.
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
if (legacyToken)
|
|
106
|
+
seen.set(digestOf(legacyToken).toString('hex'), 'MCP_SSE_AUTHORIZATION');
|
|
107
|
+
for (const [principal, { tokenEnv, budgets }] of Object.entries(parsed)) {
|
|
108
|
+
const token = env[tokenEnv];
|
|
109
|
+
if (token === undefined || token === '') {
|
|
110
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv names ${tokenEnv}, which is unset or empty`);
|
|
111
|
+
}
|
|
112
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
113
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is shorter than ${MIN_TOKEN_LENGTH} characters`);
|
|
114
|
+
}
|
|
115
|
+
if (!B64TOKEN_RE.test(token)) {
|
|
116
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} contains characters outside the RFC 6750 token set ` +
|
|
117
|
+
'(letters, digits, . _ ~ + / -, and = only as trailing padding)');
|
|
118
|
+
}
|
|
119
|
+
const allowedBudgets = [];
|
|
120
|
+
for (const name of budgets) {
|
|
121
|
+
const found = byName.get(name.toLowerCase());
|
|
122
|
+
// A "*" sync id would read as "unrestricted" to every ACL check, so it is
|
|
123
|
+
// refused like an unknown name: a static principal is always restricted, and
|
|
124
|
+
// the unrestricted case is the legacy token.
|
|
125
|
+
if (!found || found.syncId === '*') {
|
|
126
|
+
const available = [...byName.values()].map((b) => `"${b.name}"`).join(', ');
|
|
127
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}" references unknown budget "${name}". Available: ${available}`);
|
|
128
|
+
}
|
|
129
|
+
if (!allowedBudgets.includes(found.syncId))
|
|
130
|
+
allowedBudgets.push(found.syncId);
|
|
131
|
+
}
|
|
132
|
+
const digest = digestOf(token);
|
|
133
|
+
const other = seen.get(digest.toString('hex'));
|
|
134
|
+
if (other) {
|
|
135
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is not unique (also used by ${other})`);
|
|
136
|
+
}
|
|
137
|
+
seen.set(digest.toString('hex'), tokenEnv);
|
|
138
|
+
entries.push({ principal, allowedBudgets, digest });
|
|
139
|
+
}
|
|
140
|
+
if (legacyToken)
|
|
141
|
+
entries.push({ principal: LEGACY_STATIC_PRINCIPAL, digest: digestOf(legacyToken) });
|
|
142
|
+
return entries;
|
|
143
|
+
}
|
|
144
|
+
function matchWith(presented, entries, compare) {
|
|
145
|
+
const digest = digestOf(presented);
|
|
146
|
+
let match;
|
|
147
|
+
// Every entry is compared, with no early exit, so timing reveals neither which
|
|
148
|
+
// principal matched nor how many exist. Digests are all 32 bytes, so there is
|
|
149
|
+
// no length short-circuit either (the pre-#572 matcher had one).
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (compare(digest, e.digest) && !match)
|
|
152
|
+
match = e;
|
|
153
|
+
}
|
|
154
|
+
return match;
|
|
155
|
+
}
|
|
156
|
+
/** Constant-time lookup of a presented Bearer token. Used for static AND legacy tokens. */
|
|
157
|
+
export function matchBearerToken(presented, entries) {
|
|
158
|
+
return matchWith(presented, entries, timingSafeEqual);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Test seam: the matcher with an injected compare, so a test can count compares.
|
|
162
|
+
* Separate from matchBearerToken so no production caller can pass a compare that
|
|
163
|
+
* is not constant time.
|
|
164
|
+
*/
|
|
165
|
+
export function _matchBearerTokenWithCompareForTests(presented, entries, compare) {
|
|
166
|
+
return matchWith(presented, entries, compare);
|
|
167
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { parseIdentityMap } from './auth/identity-map.js';
|
|
3
3
|
import { parseBudgetAcl } from './auth/budget-acl-parse.js';
|
|
4
|
+
import { parseStaticTokensConfig, staticTokensConfigured } from './auth/static-tokens.js';
|
|
4
5
|
// #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
|
|
5
6
|
// null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
|
|
6
7
|
// without importing that transitive package: floor(number * unit), units b and kb
|
|
@@ -101,6 +102,24 @@ export const configSchema = z.object({
|
|
|
101
102
|
MCP_BRIDGE_PORT: z.string().default('3600'),
|
|
102
103
|
MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
|
|
103
104
|
MCP_SSE_AUTHORIZATION: z.string().optional(),
|
|
105
|
+
// #572: several static Bearer tokens, each with its own principal and budget
|
|
106
|
+
// allow-list, as a JSON object keyed by principal:
|
|
107
|
+
// {"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
|
|
108
|
+
// Only the SHAPE is checked here (both transports). The tokens themselves
|
|
109
|
+
// (MCP_TOKEN_*) and the budget names are resolved by the HTTP server before it
|
|
110
|
+
// binds (src/auth/static-tokens.ts). Unset or blank means the feature is off.
|
|
111
|
+
MCP_STATIC_TOKENS: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.superRefine((v, ctx) => {
|
|
115
|
+
try {
|
|
116
|
+
parseStaticTokensConfig(v);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Safe by contract: names principals and variables, never a token value.
|
|
120
|
+
ctx.addIssue({ code: 'custom', message: err instanceof Error ? err.message : String(err) });
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
104
123
|
// #242: explicit opt-out for the required-by-default HTTP auth gate. When auth
|
|
105
124
|
// is unconfigured AND the bind is non-loopback, the server refuses to start
|
|
106
125
|
// unless this is the exact string 'true'. Strict parse (only 'true' enables)
|
|
@@ -318,6 +337,14 @@ export const configSchema = z.object({
|
|
|
318
337
|
'unrelated userId, so matching sub denies every user. Use AUTH_BUDGET_ACL_CLAIM=auto (the default, ' +
|
|
319
338
|
'which mirrors that precedence), or pin a claim that your IdP also used to populate Actual user_name. ' +
|
|
320
339
|
'See #343.',
|
|
340
|
+
})
|
|
341
|
+
// #572: static tokens are their own authentication mode. Under OIDC the verified
|
|
342
|
+
// JWT decides the principal and AUTH_BUDGET_ACL the budgets, so a static token
|
|
343
|
+
// would either be ignored or open a second door; refuse the pairing.
|
|
344
|
+
.refine((cfg) => !(cfg.AUTH_PROVIDER === 'oidc' && staticTokensConfigured(cfg.MCP_STATIC_TOKENS)), {
|
|
345
|
+
path: ['MCP_STATIC_TOKENS'],
|
|
346
|
+
message: 'MCP_STATIC_TOKENS cannot be combined with AUTH_PROVIDER=oidc: under OIDC the verified token decides ' +
|
|
347
|
+
'the principal and AUTH_BUDGET_ACL decides its budgets. Unset one of them (#572).',
|
|
321
348
|
})
|
|
322
349
|
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
323
350
|
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
package/dist/src/index.js
CHANGED
|
@@ -303,7 +303,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
303
303
|
if (useHttp || useTestMcpClient) {
|
|
304
304
|
const decision = enforceHttpAuthPosture({
|
|
305
305
|
bindHost,
|
|
306
|
-
|
|
306
|
+
// #572: MCP_STATIC_TOKENS is authentication too. A set-but-unresolvable value
|
|
307
|
+
// still counts here: startHttpServer refuses it before binding.
|
|
308
|
+
hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION || !!process.env.MCP_STATIC_TOKENS?.trim(),
|
|
307
309
|
oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
|
|
308
310
|
allowUnauthenticated: process.env.MCP_ALLOW_UNAUTHENTICATED === 'true',
|
|
309
311
|
});
|
|
@@ -379,7 +381,10 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
379
381
|
}
|
|
380
382
|
}
|
|
381
383
|
main().catch((err) => {
|
|
382
|
-
|
|
384
|
+
// #572: the message goes INTO the format string. Passed as a second argument, winston
|
|
385
|
+
// treats a string as metadata and printed it one character per line ("0": "M", ...),
|
|
386
|
+
// so an operator never saw why startup failed.
|
|
387
|
+
logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
|
|
383
388
|
process.exit(1);
|
|
384
389
|
});
|
|
385
390
|
})();
|
|
@@ -108,7 +108,23 @@ function getActiveBudgetConfig() {
|
|
|
108
108
|
return restored;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
// #572: a restricted caller with no selection and no restorable preference starts
|
|
112
|
+
// on its FIRST PERMITTED budget in registry order (the order the operator controls
|
|
113
|
+
// and every listing shows; the allow-list is a set), instead of the default it may
|
|
114
|
+
// not use. Keyed on the list, not the auth mode, so OIDC restricted principals get
|
|
115
|
+
// it too: before #572 one whose list excluded the default got "Budget ACL:" on every
|
|
116
|
+
// call until it switched. With no restricting list, or nothing in the registry
|
|
117
|
+
// permitted, this is the default as before, and _enforceBudgetAcl then refuses.
|
|
118
|
+
// Not memoised: a later switch writes the session slot, and the next call
|
|
119
|
+
// recomputes the same answer from the same list.
|
|
120
|
+
const values = [...budgetRegistry.values()];
|
|
121
|
+
const restricting = restrictingAllowList(store?.allowedBudgets);
|
|
122
|
+
if (restricting) {
|
|
123
|
+
const permitted = values.find((b) => restricting.includes(b.syncId));
|
|
124
|
+
if (permitted)
|
|
125
|
+
return permitted;
|
|
126
|
+
}
|
|
127
|
+
return values[0];
|
|
112
128
|
}
|
|
113
129
|
/**
|
|
114
130
|
* #571: the budget a NEW session should open on, resolved before its session id is live.
|
|
@@ -447,10 +463,12 @@ function abortBatchOnInfrastructureError(error, ctx) {
|
|
|
447
463
|
function _enforceBudgetAcl(toolName) {
|
|
448
464
|
const sessionId = requestContext.getStore()?.sessionId;
|
|
449
465
|
// Trusted-local short-circuit. stdio and startup health checks run with no
|
|
450
|
-
// sessionId; in non-OIDC modes those are by-construction
|
|
451
|
-
// user, local process).
|
|
452
|
-
//
|
|
453
|
-
|
|
466
|
+
// sessionId AND no allow-list; in non-OIDC modes those are by-construction
|
|
467
|
+
// trusted (single user, local process). #572: a context that CARRIES a list is
|
|
468
|
+
// always checked, session or not (the HTTP initialize context has a static
|
|
469
|
+
// principal's list and no session id yet), so the skip is narrowed to contexts
|
|
470
|
+
// with no list.
|
|
471
|
+
if (!sessionId && config.AUTH_PROVIDER !== 'oidc' && !requestContext.getStore()?.allowedBudgets) {
|
|
454
472
|
return;
|
|
455
473
|
}
|
|
456
474
|
const allowedBudgets = restrictedAllowedBudgets(toolName);
|
|
@@ -502,6 +520,17 @@ function restrictedAllowedBudgets(toolName) {
|
|
|
502
520
|
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
503
521
|
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
504
522
|
}
|
|
523
|
+
return restrictingAllowList(allowedBudgets);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* #572: THE allow-list predicate: the list when it restricts, else `undefined`.
|
|
527
|
+
* A missing list and a list containing `"*"` do not restrict. An EMPTY list does:
|
|
528
|
+
* it is the deny state (budget-acl.ts gives [] to a principal with no access), so it
|
|
529
|
+
* restricts to nothing. Used by restrictedAllowedBudgets, switchBudget, the
|
|
530
|
+
* _enforceBudgetAcl skip and the getActiveBudgetConfig fallback, so they cannot
|
|
531
|
+
* disagree about who is restricted.
|
|
532
|
+
*/
|
|
533
|
+
function restrictingAllowList(allowedBudgets) {
|
|
505
534
|
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
506
535
|
return undefined;
|
|
507
536
|
return allowedBudgets;
|
|
@@ -4008,8 +4037,11 @@ export async function switchBudget(name) {
|
|
|
4008
4037
|
if (!found)
|
|
4009
4038
|
throw notFound();
|
|
4010
4039
|
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
4011
|
-
// OIDC mode: explicit ACL required.
|
|
4012
|
-
|
|
4040
|
+
// OIDC mode: explicit ACL required. #572: any mode whose request carries a
|
|
4041
|
+
// restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
|
|
4042
|
+
// outside OIDC the caller is unrestricted (legacy bearer, no-auth).
|
|
4043
|
+
const restricting = restrictingAllowList(allowedBudgets);
|
|
4044
|
+
if (config.AUTH_PROVIDER === 'oidc' || restricting) {
|
|
4013
4045
|
if (!allowedBudgets) {
|
|
4014
4046
|
logger.warn(JSON.stringify({
|
|
4015
4047
|
event: 'acl_denied',
|
|
@@ -4020,7 +4052,7 @@ export async function switchBudget(name) {
|
|
|
4020
4052
|
}));
|
|
4021
4053
|
throw new Error(`Budget ACL: cannot switch to "${found.name}". No allowedBudgets in request context.`);
|
|
4022
4054
|
}
|
|
4023
|
-
if (
|
|
4055
|
+
if (restricting && !restricting.includes(found.syncId)) {
|
|
4024
4056
|
logger.warn(JSON.stringify({
|
|
4025
4057
|
event: 'acl_denied',
|
|
4026
4058
|
attemptedBudget: found.syncId,
|
package/dist/src/lib/apiLogin.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import api from '@actual-app/api';
|
|
2
2
|
import { withOpTimeout } from './opTimeout.js';
|
|
3
3
|
import { setApiLogin, setLoadedBudgetSyncId, registerBudgetLoad, loginsSpanServers } from './apiState.js';
|
|
4
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
5
|
+
const log = createModuleLogger('API-LOGIN');
|
|
4
6
|
// Upstream's `init` returns its module-level handler object, the same one every time. Upstream
|
|
5
7
|
// exports no other way to reach `close-budget`. Nothing can be open before the first init, which
|
|
6
8
|
// is when this is still unset.
|
|
@@ -43,14 +45,15 @@ let upstreamLib = null;
|
|
|
43
45
|
* after the caller has thrown and released the lock. A late close would close the budget the next
|
|
44
46
|
* operation loaded, in any deployment, so it is always registered with the #393 abandoned-load
|
|
45
47
|
* registry: every `withApiLock` acquisition settles it, bounded and failing closed, before it runs
|
|
46
|
-
* anything.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* anything. With one login, upstream logs in itself and the login is not registered: it lands on
|
|
49
|
+
* the only server there is, and registering it would replace #270's per-request recovery with a
|
|
50
|
+
* fail-closed wait on every operation. With more than one (`loginsSpanServers`), a late login
|
|
51
|
+
* could move the client to another server, so #574 performs it as our own abortable fetch and
|
|
52
|
+
* registers only the credential-less init that follows (see `initOverFetchedLogin`). The close
|
|
50
53
|
* has no such exemption: it waits on upstream's spreadsheet and services (not on running handlers), so a close
|
|
51
54
|
* that stalls makes every session wait, bounded and failing closed, in a one-login deployment
|
|
52
|
-
* too (the accepted #393 trade-off;
|
|
53
|
-
*
|
|
55
|
+
* too (the accepted #393 trade-off; upstream's close takes no signal, so it stays unabortable).
|
|
56
|
+
* A late close, login or init records nothing (the records were cleared in step 1 and `setApiLogin` is only reached by
|
|
54
57
|
* a caller still waiting), so the next operation logs in again.
|
|
55
58
|
*/
|
|
56
59
|
export async function initApiLoggedIn(dataDir, login, label) {
|
|
@@ -61,15 +64,176 @@ export async function initApiLoggedIn(dataDir, login, label) {
|
|
|
61
64
|
registerBudgetLoad(closing);
|
|
62
65
|
await withOpTimeout(() => closing, `${label} (close outgoing budget)`);
|
|
63
66
|
}
|
|
67
|
+
if (loginsSpanServers()) {
|
|
68
|
+
await initOverFetchedLogin(dataDir, login, label);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
64
71
|
const loggingIn = api.init({
|
|
65
72
|
dataDir,
|
|
66
73
|
serverURL: login.serverUrl,
|
|
67
74
|
password: login.password,
|
|
68
75
|
});
|
|
69
|
-
if (loginsSpanServers())
|
|
70
|
-
registerBudgetLoad(loggingIn);
|
|
71
76
|
const lib = await withOpTimeout(() => loggingIn, label);
|
|
72
77
|
if (lib && typeof lib.send === 'function')
|
|
73
78
|
upstreamLib = lib;
|
|
74
79
|
setApiLogin(login);
|
|
75
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* #574: the multi-login path. Upstream's sign-in takes no signal, so a stalled login could only
|
|
83
|
+
* be raced, and its late answer then pointed the client at its server and wrote its token under
|
|
84
|
+
* whatever operation ran next. Here the login is our own fetch, aborted at the bound, and the
|
|
85
|
+
* client is only touched after that fetch has answered in time:
|
|
86
|
+
* 1. the bounded login: the fetch and its body read, nothing else. `finally` aborts it, which
|
|
87
|
+
* is a no-op once it has answered. There is deliberately no `signal.aborted` check after
|
|
88
|
+
* it (round 1 of the gate asked for one, round 2 reversed it): the `finally` aborts on
|
|
89
|
+
* success too, so the check would fail every login, and a timeout rejects the awaited race
|
|
90
|
+
* before the next line runs, which `multi_server_budget_selection.test.js` row 22 pins
|
|
91
|
+
* with a fetch that ignores the abort.
|
|
92
|
+
* 2. a credential-less `api.init` (local: `setServer` plus a fresh in-memory store), then
|
|
93
|
+
* `subscribe-set-token` on the handle it returns. Neither makes a network call, but both
|
|
94
|
+
* are still raced, so both are registered like the close: a late one would re-point the
|
|
95
|
+
* client or hand it the wrong server's token.
|
|
96
|
+
*
|
|
97
|
+
* An empty password keeps upstream's skip: no fetch and no token. Nothing of the previous server
|
|
98
|
+
* survives, because every `api.init` resets upstream's store (`init$3({ persist: false })`).
|
|
99
|
+
*/
|
|
100
|
+
async function initOverFetchedLogin(dataDir, login, label) {
|
|
101
|
+
let token = '';
|
|
102
|
+
if (login.password) {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
try {
|
|
105
|
+
token = await withOpTimeout(() => loginOverFetch(login, controller.signal), label);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
controller.abort();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// `password: ''` is upstream's no-credential init (it signs in only on a truthy password).
|
|
112
|
+
const pointing = api.init({ dataDir, serverURL: login.serverUrl, password: '' });
|
|
113
|
+
registerBudgetLoad(pointing);
|
|
114
|
+
const lib = await withOpTimeout(() => pointing, label);
|
|
115
|
+
if (!lib || typeof lib.send !== 'function')
|
|
116
|
+
throw new Error(`Actual API ${label}: upstream init returned no handle`);
|
|
117
|
+
upstreamLib = lib;
|
|
118
|
+
if (token) {
|
|
119
|
+
// Local, but raced and registered like the init: a late token set after the next init would
|
|
120
|
+
// hand this server's token to whichever server the client then points at.
|
|
121
|
+
const setting = lib.send('subscribe-set-token', { token });
|
|
122
|
+
registerBudgetLoad(setting);
|
|
123
|
+
await withOpTimeout(() => setting, label);
|
|
124
|
+
}
|
|
125
|
+
setApiLogin(login);
|
|
126
|
+
}
|
|
127
|
+
function authError(reason) {
|
|
128
|
+
return Object.assign(new Error(`Authentication failed: ${reason}`), { code: reason });
|
|
129
|
+
}
|
|
130
|
+
/** The server URL as written into a message: no `user:pass@`, which an operator may put there. */
|
|
131
|
+
function displayUrl(serverUrl) {
|
|
132
|
+
try {
|
|
133
|
+
const u = new URL(serverUrl);
|
|
134
|
+
u.username = '';
|
|
135
|
+
u.password = '';
|
|
136
|
+
return u.toString().replace(/\/$/, '');
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return 'the configured server';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* #574: upstream's password login (`signIn` over `post`), as our own fetch so it can be aborted.
|
|
144
|
+
* Resolves the session token. Errors follow upstream's `post`/`throwIfNot200` order and carry
|
|
145
|
+
* `.code` like its `withErrorCode`, because `classifyInitFailure` and `isRetryableAuthError`
|
|
146
|
+
* read them: in particular a 429 keeps upstream's raw text (`Too many requests, ...`), which is
|
|
147
|
+
* what the auth-rate-limit retry matches. Three cases go beyond upstream on purpose:
|
|
148
|
+
* `redirect-refused` (upstream follows a redirect and re-POSTs the password to its target),
|
|
149
|
+
* `invalid-server-url`, and `openid-required` (upstream returns `{ redirectUrl }` as a success,
|
|
150
|
+
* which a server-side password login cannot follow).
|
|
151
|
+
*
|
|
152
|
+
* Reads `globalThis.fetch` at call time; upstream captured its own at module load. Never logs
|
|
153
|
+
* or throws the password, the token or a response body.
|
|
154
|
+
*/
|
|
155
|
+
export async function loginOverFetch(login, signal) {
|
|
156
|
+
let url;
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(login.serverUrl);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw authError('invalid-server-url');
|
|
162
|
+
}
|
|
163
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/account/login`;
|
|
164
|
+
let res;
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
res = await globalThis.fetch(url.toString(), {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: JSON.stringify({ loginMethod: 'password', password: login.password }),
|
|
170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
// 'manual', not 'error': under 'error' Node rejects a redirect with the same
|
|
172
|
+
// `TypeError: fetch failed` as a refused connection, which would map to the retried
|
|
173
|
+
// `network-failure`. Here the 3xx arrives as a status and is refused below.
|
|
174
|
+
redirect: 'manual',
|
|
175
|
+
signal,
|
|
176
|
+
});
|
|
177
|
+
text = await res.text();
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (signal.aborted)
|
|
181
|
+
throw authError('aborted');
|
|
182
|
+
log.debug('Login request failed', { error: err instanceof Error ? err.name : 'unknown' });
|
|
183
|
+
throw authError('network-failure');
|
|
184
|
+
}
|
|
185
|
+
if (res.status >= 300 && res.status < 400) {
|
|
186
|
+
// Refused even for an http to https upgrade on the same host (the usual TLS proxy setup):
|
|
187
|
+
// following any redirect re-POSTs the password, and one rule is easier to trust than a
|
|
188
|
+
// same-host exception. The operator fixes it once, so the message names the server URL the
|
|
189
|
+
// redirect implies: its origin plus the path before `/account/login` (a subpath deployment
|
|
190
|
+
// keeps its subpath), never its userinfo or query. No hint when the Location is absent,
|
|
191
|
+
// unparseable, not http(s), or implies the URL already configured.
|
|
192
|
+
let hint = '';
|
|
193
|
+
const location = res.headers.get('Location');
|
|
194
|
+
try {
|
|
195
|
+
const target = new URL(location ?? '', url);
|
|
196
|
+
const suggested = target.origin + target.pathname.replace(/\/account\/login\/*$/, '').replace(/\/+$/, '');
|
|
197
|
+
if (location && /^https?:$/.test(target.protocol) && suggested !== displayUrl(login.serverUrl)) {
|
|
198
|
+
hint = ` (it redirects to ${suggested}; configure that URL instead)`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// An unparseable Location gives no hint; the refusal stands.
|
|
203
|
+
}
|
|
204
|
+
throw Object.assign(new Error(`Authentication failed: redirect refused by ${displayUrl(login.serverUrl)}${hint}`), { code: 'redirect-refused' });
|
|
205
|
+
}
|
|
206
|
+
if (res.status !== 200) {
|
|
207
|
+
if (res.status === 500)
|
|
208
|
+
throw authError('internal');
|
|
209
|
+
if ((res.headers.get('Content-Type') ?? '').toLowerCase().includes('application/json')) {
|
|
210
|
+
let body;
|
|
211
|
+
try {
|
|
212
|
+
body = JSON.parse(text);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw authError('parse-json');
|
|
216
|
+
}
|
|
217
|
+
const reason = body?.reason === 'unauthorized' && body?.details === 'token-not-found' ? 'token-expired' : body?.reason;
|
|
218
|
+
throw authError(reason || 'network-failure');
|
|
219
|
+
}
|
|
220
|
+
if (res.headers.has('ngrok-error-code'))
|
|
221
|
+
throw authError('network-failure');
|
|
222
|
+
throw authError(text.slice(0, 200) || 'network-failure');
|
|
223
|
+
}
|
|
224
|
+
let data;
|
|
225
|
+
try {
|
|
226
|
+
data = JSON.parse(text);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw authError('parse-json');
|
|
230
|
+
}
|
|
231
|
+
if (data?.status !== 'ok')
|
|
232
|
+
throw authError(data?.description || data?.reason || 'unknown');
|
|
233
|
+
if (data.data?.returnUrl) {
|
|
234
|
+
throw Object.assign(new Error(`Authentication failed: ${displayUrl(login.serverUrl)} requires OpenID login, which a server password cannot use`), { code: 'openid-required' });
|
|
235
|
+
}
|
|
236
|
+
if (!data.data?.token)
|
|
237
|
+
throw new Error('login: User token not set');
|
|
238
|
+
return data.data.token;
|
|
239
|
+
}
|
|
@@ -76,7 +76,8 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
|
|
|
76
76
|
const { decision, reason } = evaluateHttpAuthPosture(input);
|
|
77
77
|
if (decision === 'refuse') {
|
|
78
78
|
log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
|
|
79
|
-
'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or ' +
|
|
79
|
+
'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or set ' +
|
|
80
|
+
'MCP_STATIC_TOKENS (one token per client, #572), or ' +
|
|
80
81
|
'set AUTH_PROVIDER=oidc, or bind a loopback host. To deliberately run open ' +
|
|
81
82
|
'(for example behind your own authenticating proxy), set MCP_ALLOW_UNAUTHENTICATED=true.');
|
|
82
83
|
exit(1);
|
|
@@ -85,7 +86,7 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
|
|
|
85
86
|
log.warn(`Serving HTTP WITHOUT authentication: ${reason}. Anyone who can reach this ` +
|
|
86
87
|
'port can read and modify your financial data. This is allowed only because ' +
|
|
87
88
|
'MCP_ALLOW_UNAUTHENTICATED=true. Put an authenticating reverse proxy in front, ' +
|
|
88
|
-
'or set MCP_SSE_AUTHORIZATION / AUTH_PROVIDER=oidc.');
|
|
89
|
+
'or set MCP_SSE_AUTHORIZATION / MCP_STATIC_TOKENS / AUTH_PROVIDER=oidc.');
|
|
89
90
|
}
|
|
90
91
|
return decision;
|
|
91
92
|
}
|
|
@@ -67,7 +67,8 @@ export const SCHEMA_VARS_ALSO_READ_RAW = [
|
|
|
67
67
|
* one. `.env.example` documents example members (BUDGET_1_*, BUDGET_2_*, ...); the
|
|
68
68
|
* drift guard collapses any name starting with one of these to the family.
|
|
69
69
|
*/
|
|
70
|
-
|
|
70
|
+
// MCP_TOKEN_ (#572): the per-principal static token values that MCP_STATIC_TOKENS names.
|
|
71
|
+
export const DYNAMIC_ENV_FAMILIES = ['BUDGET_', 'MCP_TOKEN_'];
|
|
71
72
|
/**
|
|
72
73
|
* Variables documented for the operator (e.g. in Docker) but read by the OS or
|
|
73
74
|
* runtime, not by the app. The drift guard allows these in docs without requiring a
|
package/dist/src/logger.js
CHANGED
|
@@ -168,7 +168,9 @@ export function collectSecretValues(env = process.env) {
|
|
|
168
168
|
// length guard avoids masking short/common values that could collide with benign log
|
|
169
169
|
// text (a short secret is still masked by KEY name when under a sensitive field; only
|
|
170
170
|
// the free-text VALUE scrub is skipped for it).
|
|
171
|
-
|
|
171
|
+
// MCP_TOKEN_* (#572) are the static Bearer tokens MCP_STATIC_TOKENS names; MCP_TOKEN_ALICE
|
|
172
|
+
// ends in neither suffix, so the family is matched by prefix.
|
|
173
|
+
if (typeof v === 'string' && v.length >= 8 && (/(PASSWORD|SECRET|AUTHORIZATION|_TOKEN)$/.test(k) || k.startsWith('MCP_TOKEN_'))) {
|
|
172
174
|
out.push(v);
|
|
173
175
|
}
|
|
174
176
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { randomUUID
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -9,7 +9,7 @@ import { getLocalIp } from '../utils.js';
|
|
|
9
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
10
10
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
|
|
11
11
|
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
12
|
-
import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession } from '../lib/actual-adapter.js';
|
|
12
|
+
import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession, getBudgetRegistry } from '../lib/actual-adapter.js';
|
|
13
13
|
import { errorLogFields } from '../lib/errors.js';
|
|
14
14
|
import observability from '../observability.js';
|
|
15
15
|
import config from '../config.js';
|
|
@@ -23,6 +23,7 @@ import { stripJoseCause } from '../lib/oidc-error-cause.js';
|
|
|
23
23
|
import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
|
|
24
24
|
import { parseScopeList } from '../lib/oidc-scopes.js';
|
|
25
25
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
26
|
+
import { resolveStaticTokens, matchBearerToken, LEGACY_STATIC_PRINCIPAL } from '../auth/static-tokens.js';
|
|
26
27
|
import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
|
|
27
28
|
import * as https from 'node:https';
|
|
28
29
|
import { finished } from 'node:stream';
|
|
@@ -42,14 +43,19 @@ import { createHttpErrorHandler } from './httpErrorHandler.js';
|
|
|
42
43
|
export { classifyInitFailure };
|
|
43
44
|
export { requestContext };
|
|
44
45
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
45
|
-
// (#189). OIDC: the verified JWT subject.
|
|
46
|
-
//
|
|
47
|
-
//
|
|
46
|
+
// (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
|
|
47
|
+
// own principal, set by authenticateRequest on a match. Legacy static bearer: a
|
|
48
|
+
// single shared identity (all its callers are the same user). Auth-disabled:
|
|
49
|
+
// undefined, so the preference simply no-ops. Never derived from a
|
|
50
|
+
// client-supplied value.
|
|
48
51
|
function resolvePrincipal(req) {
|
|
49
52
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
50
53
|
return req.auth?.subject;
|
|
51
54
|
}
|
|
52
|
-
|
|
55
|
+
const staticPrincipal = req.staticPrincipal;
|
|
56
|
+
if (staticPrincipal)
|
|
57
|
+
return staticPrincipal;
|
|
58
|
+
return config.MCP_SSE_AUTHORIZATION ? LEGACY_STATIC_PRINCIPAL : undefined;
|
|
53
59
|
}
|
|
54
60
|
export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
|
|
55
61
|
implementedTools, // was passed by index.ts
|
|
@@ -59,6 +65,14 @@ toolSchemas, // was passed by index.ts
|
|
|
59
65
|
version, // server version from package.json
|
|
60
66
|
bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
|
|
61
67
|
advertisedUrl) {
|
|
68
|
+
// #572: resolve MCP_STATIC_TOKENS (token variables, budget names) before anything
|
|
69
|
+
// binds, so a bad entry refuses startup like an unreadable TLS key. The registry is
|
|
70
|
+
// read outside any request context, so it is the full, unfiltered list. config.ts
|
|
71
|
+
// already refused the variable under OIDC, where these entries are never consulted.
|
|
72
|
+
const bearerEntries = config.AUTH_PROVIDER === 'oidc'
|
|
73
|
+
? []
|
|
74
|
+
: resolveStaticTokens(config.MCP_STATIC_TOKENS, process.env, getBudgetRegistry(), config.MCP_SSE_AUTHORIZATION);
|
|
75
|
+
const staticPrincipals = bearerEntries.filter((e) => e.allowedBudgets);
|
|
62
76
|
const app = express();
|
|
63
77
|
// Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
|
|
64
78
|
// rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
|
|
@@ -336,9 +350,11 @@ advertisedUrl) {
|
|
|
336
350
|
res.status(401).json({ error: 'Unauthorized: OIDC authentication required' });
|
|
337
351
|
return false;
|
|
338
352
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
353
|
+
// Static Bearer token mode (default): MCP_SSE_AUTHORIZATION and/or MCP_STATIC_TOKENS
|
|
354
|
+
// (#572). Only when NEITHER is configured is every request allowed. Static tokens are
|
|
355
|
+
// authentication, so MCP_ALLOW_UNAUTHENTICATED (a posture opt-out for the no-auth
|
|
356
|
+
// case) does not relax this.
|
|
357
|
+
if (bearerEntries.length === 0) {
|
|
342
358
|
return true;
|
|
343
359
|
}
|
|
344
360
|
const authHeader = req.headers.authorization;
|
|
@@ -354,20 +370,22 @@ advertisedUrl) {
|
|
|
354
370
|
res.status(401).json({ error: 'Unauthorized: Invalid Authorization header format. Expected "Bearer <token>"' });
|
|
355
371
|
return false;
|
|
356
372
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// via response timing regardless; the secret bytes are not.
|
|
363
|
-
const tokenBuf = Buffer.from(token, 'utf8');
|
|
364
|
-
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
365
|
-
const equal = tokenBuf.length === expectedBuf.length && timingSafeEqual(tokenBuf, expectedBuf);
|
|
366
|
-
if (!equal) {
|
|
373
|
+
// Constant-time comparison to defeat timing oracles (#157): matchBearerToken
|
|
374
|
+
// compares SHA-256 digests with timingSafeEqual against every configured token,
|
|
375
|
+
// static and legacy, with no early exit (#572).
|
|
376
|
+
const hit = matchBearerToken(match[1], bearerEntries);
|
|
377
|
+
if (!hit) {
|
|
367
378
|
logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
|
|
368
379
|
res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
|
369
380
|
return false;
|
|
370
381
|
}
|
|
382
|
+
// A static principal carries its resolved allow-list into the requestContext.run
|
|
383
|
+
// calls below, where the adapter's ACL chokepoints read it. The legacy token sets
|
|
384
|
+
// neither field: principal 'static-bearer', unrestricted, exactly as before #572.
|
|
385
|
+
if (hit.allowedBudgets) {
|
|
386
|
+
req.staticPrincipal = hit.principal;
|
|
387
|
+
req.allowedBudgets = [...hit.allowedBudgets];
|
|
388
|
+
}
|
|
371
389
|
return true;
|
|
372
390
|
};
|
|
373
391
|
// Create a fresh Server instance per session
|
|
@@ -994,11 +1012,15 @@ advertisedUrl) {
|
|
|
994
1012
|
void runAclPreflight();
|
|
995
1013
|
}
|
|
996
1014
|
}
|
|
997
|
-
else if (
|
|
998
|
-
|
|
1015
|
+
else if (bearerEntries.length > 0) {
|
|
1016
|
+
// #572: principal names and budget COUNTS only; budget names are personal data.
|
|
1017
|
+
const principals = staticPrincipals.map((e) => `${e.principal} (${e.allowedBudgets.length} budget(s))`).join(', ');
|
|
1018
|
+
logger.info(`🔒 HTTP authentication enabled (Bearer token required)` +
|
|
1019
|
+
(principals ? `; static principals: ${principals}` : '') +
|
|
1020
|
+
(config.MCP_SSE_AUTHORIZATION ? `; MCP_SSE_AUTHORIZATION also accepted (unrestricted)` : ''));
|
|
999
1021
|
}
|
|
1000
1022
|
else {
|
|
1001
|
-
logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION or OIDC configured)`);
|
|
1023
|
+
logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION, MCP_STATIC_TOKENS or OIDC configured)`);
|
|
1002
1024
|
}
|
|
1003
1025
|
});
|
|
1004
1026
|
// Configure keep-alive to maintain persistent connections
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.23.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/multi_server_session_open_http.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/api_login_fetch.test.js && node tests/unit/multi_server_session_open_http.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|