@amalgm/chat 0.2.0 → 0.2.1
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 +5 -0
- package/docs/contracts/platform-authorization.md +5 -0
- package/host/platform-egress.js +42 -10
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -92,3 +92,8 @@ npm install
|
|
|
92
92
|
npm test
|
|
93
93
|
npm pack --dry-run
|
|
94
94
|
```
|
|
95
|
+
|
|
96
|
+
Package releases use `.github/workflows/publish-package.yml`. A reviewed main
|
|
97
|
+
commit is tagged exactly `v<package version>`; trusted npm publishing stages
|
|
98
|
+
that package, and a maintainer approves the staged artifact with 2FA. Manual
|
|
99
|
+
dirty-worktree publishing is not a release path.
|
|
@@ -15,3 +15,8 @@ If issuance or proof creation fails, egress returns `503` without contacting
|
|
|
15
15
|
the provider. The provider adapter propagates failure through the ordinary
|
|
16
16
|
runtime pump, and Chat seals the turn as `failed`; stable host error codes are
|
|
17
17
|
preserved in the durable turn error.
|
|
18
|
+
|
|
19
|
+
The local relay accepts only its session capability, removes caller-supplied
|
|
20
|
+
credentials and Amalgm metadata, bounds buffered request bytes, rejects
|
|
21
|
+
redirects, and applies response backpressure. These are part of the egress
|
|
22
|
+
boundary, not provider-specific adapter behavior.
|
package/host/platform-egress.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
|
|
4
|
+
const MAX_EGRESS_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
|
|
1
6
|
const HOP_HEADERS = new Set([
|
|
2
7
|
'accept-encoding',
|
|
3
8
|
'connection',
|
|
4
9
|
'content-encoding',
|
|
5
10
|
'content-length',
|
|
11
|
+
'cookie',
|
|
12
|
+
'dpop',
|
|
6
13
|
'host',
|
|
14
|
+
'proxy-authorization',
|
|
7
15
|
'transfer-encoding',
|
|
8
16
|
]);
|
|
9
17
|
|
|
@@ -25,7 +33,12 @@ function requestHeaders(request) {
|
|
|
25
33
|
const headers = {};
|
|
26
34
|
for (const [name, value] of Object.entries(request.headers || {})) {
|
|
27
35
|
const lower = name.toLowerCase();
|
|
28
|
-
if (
|
|
36
|
+
if (
|
|
37
|
+
HOP_HEADERS.has(lower)
|
|
38
|
+
|| lower === 'authorization'
|
|
39
|
+
|| lower === 'x-api-key'
|
|
40
|
+
|| lower.startsWith('x-amalgm-')
|
|
41
|
+
) continue;
|
|
29
42
|
if (value !== undefined) headers[lower] = Array.isArray(value) ? value[0] : value;
|
|
30
43
|
}
|
|
31
44
|
return headers;
|
|
@@ -43,17 +56,18 @@ function responseHeaders(response) {
|
|
|
43
56
|
export async function forwardPlatformEgress({ request, response, contract, proxy, upstreamPath }) {
|
|
44
57
|
const authorization = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
|
45
58
|
const apiKey = String(request.headers['x-api-key'] || '').trim();
|
|
46
|
-
|
|
59
|
+
const presented = authorization || apiKey;
|
|
60
|
+
if (!contract.auth?.tokenRef || !secretMatches(presented, contract.auth.tokenRef)) {
|
|
47
61
|
response.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
48
62
|
response.end(JSON.stringify({ error: 'Invalid Chat egress token' }));
|
|
49
63
|
return;
|
|
50
64
|
}
|
|
51
65
|
const method = request.method || 'GET';
|
|
52
66
|
const url = `${proxy.baseUrl.replace(/\/$/, '')}${upstreamPath}`;
|
|
53
|
-
const body = ['GET', 'HEAD'].includes(method) ? undefined : await requestBytes(request);
|
|
54
67
|
const fetchImpl = proxy.fetch || fetch;
|
|
55
68
|
let upstream;
|
|
56
69
|
try {
|
|
70
|
+
const body = ['GET', 'HEAD'].includes(method) ? undefined : await requestBytes(request);
|
|
57
71
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
58
72
|
const grant = await proxy.credentialBroker.authorize({
|
|
59
73
|
audience: 'amalgm-api-proxy',
|
|
@@ -70,6 +84,7 @@ export async function forwardPlatformEgress({ request, response, contract, proxy
|
|
|
70
84
|
dpop: grant.dpop,
|
|
71
85
|
},
|
|
72
86
|
body,
|
|
87
|
+
redirect: 'error',
|
|
73
88
|
});
|
|
74
89
|
if (upstream.status !== 401 || attempt === 1) break;
|
|
75
90
|
await upstream.body?.cancel().catch(() => undefined);
|
|
@@ -90,18 +105,19 @@ export async function forwardPlatformEgress({ request, response, contract, proxy
|
|
|
90
105
|
while (true) {
|
|
91
106
|
const { done, value } = await reader.read();
|
|
92
107
|
if (done) break;
|
|
93
|
-
if (value
|
|
108
|
+
if (value && response.write(Buffer.from(value)) === false) await once(response, 'drain');
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
response.end();
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
function scopeForPath(path) {
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
if (
|
|
103
|
-
if (
|
|
104
|
-
if (
|
|
115
|
+
const provider = path.match(/^\/([^/?]+)(?:[/?]|$)/)?.[1];
|
|
116
|
+
if (provider === 'anthropic') return 'llm:anthropic';
|
|
117
|
+
if (provider === 'ai_gateway') return 'llm:gateway';
|
|
118
|
+
if (provider === 'google' || provider === 'gemini') return 'llm:google';
|
|
119
|
+
if (provider === 'xai') return 'llm:xai';
|
|
120
|
+
if (provider === 'openai') return 'llm:openai';
|
|
105
121
|
throw Object.assign(new Error('Platform egress route has no authorization scope'), {
|
|
106
122
|
code: 'EGRESS_SCOPE_UNKNOWN',
|
|
107
123
|
});
|
|
@@ -109,6 +125,22 @@ function scopeForPath(path) {
|
|
|
109
125
|
|
|
110
126
|
async function requestBytes(request) {
|
|
111
127
|
const chunks = [];
|
|
112
|
-
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
for await (const chunk of request) {
|
|
130
|
+
const value = Buffer.from(chunk);
|
|
131
|
+
bytes += value.byteLength;
|
|
132
|
+
if (bytes > MAX_EGRESS_REQUEST_BYTES) {
|
|
133
|
+
throw Object.assign(new Error('Platform egress request exceeds its byte limit'), {
|
|
134
|
+
code: 'EGRESS_REQUEST_TOO_LARGE',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
chunks.push(value);
|
|
138
|
+
}
|
|
113
139
|
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
|
114
140
|
}
|
|
141
|
+
|
|
142
|
+
function secretMatches(presented, expected) {
|
|
143
|
+
const left = Buffer.from(presented);
|
|
144
|
+
const right = Buffer.from(expected);
|
|
145
|
+
return left.byteLength === right.byteLength && timingSafeEqual(left, right);
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amalgm/chat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A provider-agnostic agent chat SDK with ACP content, prepared execution, normalized streams, and usage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -81,8 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"scripts": {
|
|
83
83
|
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
|
84
|
-
"test": "npm run build && node --test
|
|
85
|
-
"test:parity": "npm run build && node --test tests/parity/"
|
|
84
|
+
"test": "npm run build && node --test"
|
|
86
85
|
},
|
|
87
86
|
"dependencies": {
|
|
88
87
|
"@agentclientprotocol/sdk": "^1.4.0",
|