@nimbus-sh/worker 0.1.4 → 0.1.6
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 +37 -0
- package/dist/_shared/crypto.d.ts +15 -0
- package/dist/_shared/crypto.d.ts.map +1 -0
- package/dist/_shared/crypto.js +122 -0
- package/dist/facets/manager.d.ts +24 -0
- package/dist/facets/manager.d.ts.map +1 -1
- package/dist/facets/manager.js +279 -70
- package/dist/facets/process.d.ts +11 -2
- package/dist/facets/process.d.ts.map +1 -1
- package/dist/facets/process.js +187 -21
- package/dist/facets/vite-dev-server.d.ts +2 -0
- package/dist/facets/vite-dev-server.d.ts.map +1 -1
- package/dist/facets/vite-dev-server.js +39 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/loaders/child-process/spawn-facet.d.ts +1 -3
- package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -1
- package/dist/loaders/child-process/spawn-facet.js +1 -12
- package/dist/loaders/child-process/spawn-pool.d.ts +1 -1
- package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
- package/dist/loaders/child-process/spawn-pool.js +1 -6
- package/dist/loaders/loader-pool.d.ts +7 -0
- package/dist/loaders/loader-pool.d.ts.map +1 -1
- package/dist/loaders/loader-pool.js +3 -1
- package/dist/npm/installer.d.ts.map +1 -1
- package/dist/npm/installer.js +47 -27
- package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
- package/dist/npm/pre-bundle-facet.js +2 -1
- package/dist/npm/resolve-one-facet.d.ts.map +1 -1
- package/dist/npm/resolve-one-facet.js +41 -13
- package/dist/npm/resolver.d.ts.map +1 -1
- package/dist/npm/resolver.js +70 -35
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +45 -21
- package/dist/router/remote-api.d.ts.map +1 -1
- package/dist/router/remote-api.js +29 -8
- package/dist/runtime/barrel-synthesizer.d.ts +7 -0
- package/dist/runtime/barrel-synthesizer.d.ts.map +1 -1
- package/dist/runtime/barrel-synthesizer.js +11 -0
- package/dist/runtime/clang-runner.d.ts +2 -2
- package/dist/runtime/clang-runner.d.ts.map +1 -1
- package/dist/runtime/clang-runner.js +64 -47
- package/dist/runtime/esbuild-service.d.ts +12 -5
- package/dist/runtime/esbuild-service.d.ts.map +1 -1
- package/dist/runtime/esbuild-service.js +28 -14
- package/dist/runtime/node-runner.d.ts +2 -0
- package/dist/runtime/node-runner.d.ts.map +1 -1
- package/dist/runtime/node-shims.d.ts +4 -2
- package/dist/runtime/node-shims.d.ts.map +1 -1
- package/dist/runtime/node-shims.js +404 -55
- package/dist/runtime/os-contracts.d.ts +128 -0
- package/dist/runtime/os-contracts.d.ts.map +1 -0
- package/dist/runtime/os-contracts.js +1 -0
- package/dist/runtime/package-manager.d.ts +31 -0
- package/dist/runtime/package-manager.d.ts.map +1 -1
- package/dist/runtime/package-manager.js +162 -15
- package/dist/runtime/port-registry.d.ts +5 -0
- package/dist/runtime/port-registry.d.ts.map +1 -1
- package/dist/runtime/port-registry.js +22 -1
- package/dist/runtime/pyodide-runtime-assets.d.ts +19 -0
- package/dist/runtime/pyodide-runtime-assets.d.ts.map +1 -0
- package/dist/runtime/pyodide-runtime-assets.js +44 -0
- package/dist/runtime/python-repl.d.ts +3 -0
- package/dist/runtime/python-repl.d.ts.map +1 -1
- package/dist/runtime/python-repl.js +18 -32
- package/dist/runtime/python-runner.d.ts +8 -9
- package/dist/runtime/python-runner.d.ts.map +1 -1
- package/dist/runtime/python-runner.js +958 -118
- package/dist/runtime/python-socket-shim.d.ts +11 -0
- package/dist/runtime/python-socket-shim.d.ts.map +1 -0
- package/dist/runtime/python-socket-shim.js +526 -0
- package/dist/runtime/require-resolver.d.ts.map +1 -1
- package/dist/runtime/require-resolver.js +2 -11
- package/dist/runtime/ruby-gems.d.ts +25 -0
- package/dist/runtime/ruby-gems.d.ts.map +1 -0
- package/dist/runtime/ruby-gems.js +582 -0
- package/dist/runtime/ruby-runner.d.ts +2 -4
- package/dist/runtime/ruby-runner.d.ts.map +1 -1
- package/dist/runtime/ruby-runner.js +335 -34
- package/dist/runtime/runtime-catalog.d.ts +16 -4
- package/dist/runtime/runtime-catalog.d.ts.map +1 -1
- package/dist/runtime/runtime-catalog.js +21 -10
- package/dist/runtime/runtime-registry.d.ts +3 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -1
- package/dist/runtime/runtime-registry.js +4 -0
- package/dist/runtime/sqlite-runtime-fs-bridge.d.ts +43 -0
- package/dist/runtime/sqlite-runtime-fs-bridge.d.ts.map +1 -0
- package/dist/runtime/sqlite-runtime-fs-bridge.js +224 -0
- package/dist/runtime/static-server.d.ts +3 -0
- package/dist/runtime/static-server.d.ts.map +1 -0
- package/dist/runtime/static-server.js +121 -0
- package/dist/runtime/vfs-snapshot.d.ts +57 -0
- package/dist/runtime/vfs-snapshot.d.ts.map +1 -0
- package/dist/runtime/vfs-snapshot.js +141 -0
- package/dist/runtime/virtual-socket-kernel.d.ts +16 -0
- package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -0
- package/dist/runtime/virtual-socket-kernel.js +360 -0
- package/dist/runtime/wasi-instance.d.ts +6 -3
- package/dist/runtime/wasi-instance.d.ts.map +1 -1
- package/dist/runtime/wasm-runner.d.ts +1 -25
- package/dist/runtime/wasm-runner.d.ts.map +1 -1
- package/dist/runtime/wasm-runner.js +1 -160
- package/dist/session/agent.d.ts +43 -0
- package/dist/session/agent.d.ts.map +1 -0
- package/dist/session/agent.js +1252 -0
- package/dist/session/init.d.ts.map +1 -1
- package/dist/session/init.js +64 -19
- package/dist/session/nimbus-session.d.ts +14 -2
- package/dist/session/nimbus-session.d.ts.map +1 -1
- package/dist/session/nimbus-session.js +104 -30
- package/dist/session/programmatic.d.ts +10 -0
- package/dist/session/programmatic.d.ts.map +1 -1
- package/dist/session/programmatic.js +121 -0
- package/dist/session/routes.d.ts.map +1 -1
- package/dist/session/routes.js +19 -1
- package/dist/session/rpc.d.ts +11 -0
- package/dist/session/rpc.d.ts.map +1 -1
- package/dist/session/rpc.js +56 -43
- package/dist/session/supervisor-rpc.d.ts +15 -0
- package/dist/session/supervisor-rpc.d.ts.map +1 -1
- package/dist/session/supervisor-rpc.js +32 -0
- package/dist/session/ws.d.ts +4 -3
- package/dist/session/ws.d.ts.map +1 -1
- package/dist/session/ws.js +11 -1
- package/dist/shell/unix-commands.d.ts.map +1 -1
- package/dist/shell/unix-commands.js +52 -51
- package/dist/vfs/path.d.ts +8 -4
- package/dist/vfs/path.d.ts.map +1 -1
- package/dist/vfs/path.js +16 -4
- package/dist/vfs/sqlite-vfs.d.ts +2 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
- package/dist/vfs/sqlite-vfs.js +11 -0
- package/dist/vfs/symlink-registry.d.ts +7 -1
- package/dist/vfs/symlink-registry.d.ts.map +1 -1
- package/dist/vfs/symlink-registry.js +16 -11
- package/package.json +4 -1
- package/public/index.html +240 -0
- package/public/s/index.html +1316 -128
- package/runtime-contracts/pyodide-workerd-adapter.json +18 -0
- package/scripts/bundle-runtime.mjs +99 -24
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session/agent.ts - Nimbus session chat agent and Cloudflare OAuth flow.
|
|
3
|
+
*
|
|
4
|
+
* The agent lives in the session Durable Object because that is where the
|
|
5
|
+
* VFS, shell, process table, port registry, and runtime package manager
|
|
6
|
+
* already live. AI calls go through Cloudflare's account REST API so a
|
|
7
|
+
* connected user can spend their own Workers AI quota instead of the
|
|
8
|
+
* Nimbus deployment owner quota.
|
|
9
|
+
*/
|
|
10
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
11
|
+
import { generateText, isLoopFinished, jsonSchema, streamText, tool as aiTool, } from 'ai';
|
|
12
|
+
import { BASE_PATH_HEADER, TENANT_HEADER } from '../_shared/session-router.js';
|
|
13
|
+
import { base64Utf8, decodeJsonBase64Url, encodeJsonBase64Url, pkceChallenge, randomBase64Url, sealJson, unsealJson, } from '../_shared/crypto.js';
|
|
14
|
+
import { ensureProgrammaticReady, rpcExec, rpcEnsureRuntimes, rpcInstallRuntime, rpcKillProcess, rpcListPorts, rpcListProcesses, rpcProcessLogs, rpcStartProcess, } from './programmatic.js';
|
|
15
|
+
import { resolveVfsPath } from '../vfs/path.js';
|
|
16
|
+
const MESSAGES_KEY = 'nimbus:agent:messages';
|
|
17
|
+
const AUTH_COOKIE = 'nimbus_agent_oauth';
|
|
18
|
+
const STATE_COOKIE = '__Host-nimbus_agent_oauth_state';
|
|
19
|
+
const AUTH_COOKIE_PURPOSE = 'nimbus-agent-oauth-auth';
|
|
20
|
+
const STATE_COOKIE_PURPOSE = 'nimbus-agent-oauth-state';
|
|
21
|
+
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
|
22
|
+
const AUTH_COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
23
|
+
const MAX_STORED_MESSAGES = 80;
|
|
24
|
+
const MAX_TOOL_RESULT_CHARS = 8000;
|
|
25
|
+
const DEFAULT_MODEL = '@cf/moonshotai/kimi-k2.6';
|
|
26
|
+
const DEFAULT_GATEWAY_ID = 'default';
|
|
27
|
+
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
|
|
28
|
+
const CF_OAUTH_AUTH_URL = 'https://dash.cloudflare.com/oauth2/auth';
|
|
29
|
+
const CF_OAUTH_TOKEN_URL = 'https://dash.cloudflare.com/oauth2/token';
|
|
30
|
+
const CF_OAUTH_USERINFO_URL = 'https://dash.cloudflare.com/oauth2/userinfo';
|
|
31
|
+
const SYSTEM_PROMPT = 'You are the Nimbus session agent. You can inspect and edit the session filesystem, run shell commands, install runtimes, manage processes, and inspect preview ports. Be concise. Use tools when needed. Do not claim a command ran unless a tool result proves it.';
|
|
32
|
+
export async function handleAgentRequest(self, request, url) {
|
|
33
|
+
const path = url.pathname;
|
|
34
|
+
if (path === '/api/agent/status' && request.method === 'GET') {
|
|
35
|
+
return agentStatus(self, request, url);
|
|
36
|
+
}
|
|
37
|
+
if (path === '/api/agent/oauth/start' && request.method === 'POST') {
|
|
38
|
+
return oauthStart(self, request, url);
|
|
39
|
+
}
|
|
40
|
+
if (path === '/api/agent/oauth/callback' && request.method === 'GET') {
|
|
41
|
+
return oauthCallback(self, request, url);
|
|
42
|
+
}
|
|
43
|
+
if (path === '/api/agent/oauth/logout' && request.method === 'POST') {
|
|
44
|
+
const headers = new Headers();
|
|
45
|
+
appendCookie(headers, clearAuthCookie(request));
|
|
46
|
+
appendCookie(headers, clearStateCookie());
|
|
47
|
+
return json({ ok: true }, 200, headers);
|
|
48
|
+
}
|
|
49
|
+
if (path === '/api/agent/account' && request.method === 'POST') {
|
|
50
|
+
return selectAccount(self, request);
|
|
51
|
+
}
|
|
52
|
+
if (path === '/api/agent/messages' && request.method === 'GET') {
|
|
53
|
+
return json({ messages: await loadMessages(self) });
|
|
54
|
+
}
|
|
55
|
+
if (path === '/api/agent/messages' && request.method === 'DELETE') {
|
|
56
|
+
await self.ctx.storage.delete(MESSAGES_KEY);
|
|
57
|
+
return json({ ok: true, messages: [] });
|
|
58
|
+
}
|
|
59
|
+
if (path === '/api/agent/messages' && request.method === 'POST') {
|
|
60
|
+
return agentChat(self, request, url);
|
|
61
|
+
}
|
|
62
|
+
return json({ error: 'unknown agent endpoint' }, 404);
|
|
63
|
+
}
|
|
64
|
+
export function parseAgentOAuthStateParam(state) {
|
|
65
|
+
if (!state)
|
|
66
|
+
return null;
|
|
67
|
+
const payload = decodeState(state);
|
|
68
|
+
if (!payload || payload.v !== 1)
|
|
69
|
+
return null;
|
|
70
|
+
if (!isSessionId(payload.sessionId))
|
|
71
|
+
return null;
|
|
72
|
+
if (!isTenantSegment(payload.tenantSegment))
|
|
73
|
+
return null;
|
|
74
|
+
if (!isNonce(payload.nonce))
|
|
75
|
+
return null;
|
|
76
|
+
return payload;
|
|
77
|
+
}
|
|
78
|
+
async function agentStatus(self, request, url) {
|
|
79
|
+
const config = readConfig(self, url);
|
|
80
|
+
const authResult = await loadFreshAuth(self, request);
|
|
81
|
+
const auth = authResult.auth;
|
|
82
|
+
let user = null;
|
|
83
|
+
let accounts = [];
|
|
84
|
+
if (auth?.accessToken) {
|
|
85
|
+
[user, accounts] = await Promise.all([
|
|
86
|
+
fetchUserInfo(auth.accessToken).catch((e) => ({ error: String(e?.message || e) })),
|
|
87
|
+
fetchAccounts(auth.accessToken).catch(() => []),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
const ownerTokenPresent = !!(config.ownerAccountId && config.ownerToken);
|
|
91
|
+
const ownerConfigured = !config.requireUserOAuth && ownerTokenPresent;
|
|
92
|
+
const oauthConfigured = !!config.oauthClientId;
|
|
93
|
+
const connected = !!auth?.accessToken || ownerConfigured;
|
|
94
|
+
const headers = new Headers();
|
|
95
|
+
applyAuthCookieResult(headers, authResult);
|
|
96
|
+
return json({
|
|
97
|
+
ok: true,
|
|
98
|
+
configured: oauthConfigured || ownerConfigured,
|
|
99
|
+
model: config.model,
|
|
100
|
+
gatewayId: config.gatewayId,
|
|
101
|
+
oauth: {
|
|
102
|
+
configured: oauthConfigured,
|
|
103
|
+
connected: !!auth?.accessToken,
|
|
104
|
+
clientId: oauthConfigured ? config.oauthClientId : null,
|
|
105
|
+
scopes: config.oauthScopes,
|
|
106
|
+
user,
|
|
107
|
+
accounts,
|
|
108
|
+
accountId: auth?.accountId ?? null,
|
|
109
|
+
expiresAt: auth?.expiresAt ?? null,
|
|
110
|
+
},
|
|
111
|
+
ownerToken: {
|
|
112
|
+
configured: ownerConfigured,
|
|
113
|
+
accountId: ownerConfigured ? config.ownerAccountId : null,
|
|
114
|
+
disabledByUserOAuthRequired: config.requireUserOAuth && ownerTokenPresent,
|
|
115
|
+
},
|
|
116
|
+
connected,
|
|
117
|
+
capabilities: [
|
|
118
|
+
'chat',
|
|
119
|
+
'exec',
|
|
120
|
+
'files',
|
|
121
|
+
'runtimes',
|
|
122
|
+
'processes',
|
|
123
|
+
'ports',
|
|
124
|
+
],
|
|
125
|
+
}, 200, headers);
|
|
126
|
+
}
|
|
127
|
+
async function oauthStart(self, request, url) {
|
|
128
|
+
const config = readConfig(self, url);
|
|
129
|
+
if (!config.oauthClientId) {
|
|
130
|
+
return json({
|
|
131
|
+
error: 'Cloudflare OAuth is not configured',
|
|
132
|
+
code: 'E_AGENT_OAUTH_NOT_CONFIGURED',
|
|
133
|
+
}, 409);
|
|
134
|
+
}
|
|
135
|
+
const basePath = request.headers.get(BASE_PATH_HEADER) || '';
|
|
136
|
+
const sessionId = basePath.startsWith('/s/') ? basePath.slice(3) : '';
|
|
137
|
+
const tenantSegment = request.headers.get(TENANT_HEADER) || 'legacy:public:_';
|
|
138
|
+
if (!isSessionId(sessionId) || !isTenantSegment(tenantSegment)) {
|
|
139
|
+
return json({ error: 'invalid session route', code: 'E_AGENT_SESSION' }, 400);
|
|
140
|
+
}
|
|
141
|
+
const nonce = randomBase64Url(24);
|
|
142
|
+
const codeVerifier = randomBase64Url(48);
|
|
143
|
+
const codeChallenge = await pkceChallenge(codeVerifier);
|
|
144
|
+
const redirectUri = config.redirectUri;
|
|
145
|
+
const payload = { v: 1, nonce, sessionId, tenantSegment };
|
|
146
|
+
const state = encodeState(payload);
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const stored = {
|
|
149
|
+
...payload,
|
|
150
|
+
codeVerifier,
|
|
151
|
+
redirectUri,
|
|
152
|
+
createdAt: now,
|
|
153
|
+
expiresAt: now + OAUTH_STATE_TTL_MS,
|
|
154
|
+
};
|
|
155
|
+
const authUrl = new URL(CF_OAUTH_AUTH_URL);
|
|
156
|
+
authUrl.searchParams.set('client_id', config.oauthClientId);
|
|
157
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
158
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
159
|
+
authUrl.searchParams.set('state', state);
|
|
160
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
161
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
162
|
+
if (config.oauthScopes.length > 0) {
|
|
163
|
+
authUrl.searchParams.set('scope', config.oauthScopes.join(' '));
|
|
164
|
+
}
|
|
165
|
+
const headers = new Headers();
|
|
166
|
+
try {
|
|
167
|
+
appendCookie(headers, await sealStateCookie(self, stored));
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return json({
|
|
171
|
+
error: e?.message || String(e),
|
|
172
|
+
code: 'E_AGENT_COOKIE_SECRET',
|
|
173
|
+
}, 409);
|
|
174
|
+
}
|
|
175
|
+
return json({ ok: true, authUrl: authUrl.toString(), expiresAt: stored.expiresAt }, 200, headers);
|
|
176
|
+
}
|
|
177
|
+
async function oauthCallback(self, request, url) {
|
|
178
|
+
const code = url.searchParams.get('code');
|
|
179
|
+
const error = url.searchParams.get('error');
|
|
180
|
+
const payload = parseAgentOAuthStateParam(url.searchParams.get('state'));
|
|
181
|
+
if (error)
|
|
182
|
+
return oauthResultHtml(false, 'Cloudflare authorization failed.', payload?.sessionId);
|
|
183
|
+
if (!code || !payload)
|
|
184
|
+
return oauthResultHtml(false, 'OAuth callback is missing code or state.', payload?.sessionId);
|
|
185
|
+
const stored = await loadStateCookie(self, request);
|
|
186
|
+
if (!stored || stored.expiresAt < Date.now()) {
|
|
187
|
+
return oauthResultHtml(false, 'OAuth session expired. Connect again.', payload.sessionId);
|
|
188
|
+
}
|
|
189
|
+
if (stored.sessionId !== payload.sessionId ||
|
|
190
|
+
stored.tenantSegment !== payload.tenantSegment ||
|
|
191
|
+
stored.nonce !== payload.nonce) {
|
|
192
|
+
return oauthResultHtml(false, 'OAuth state did not match this session.', payload.sessionId);
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const token = await exchangeCode(self, code, stored.codeVerifier, stored.redirectUri);
|
|
196
|
+
const accessToken = String(token.access_token || '');
|
|
197
|
+
if (!accessToken)
|
|
198
|
+
throw new Error('Cloudflare did not return an access token');
|
|
199
|
+
const accounts = await fetchAccounts(accessToken).catch(() => []);
|
|
200
|
+
const auth = {
|
|
201
|
+
mode: 'oauth',
|
|
202
|
+
accessToken,
|
|
203
|
+
refreshToken: token.refresh_token ? String(token.refresh_token) : undefined,
|
|
204
|
+
tokenType: token.token_type ? String(token.token_type) : 'Bearer',
|
|
205
|
+
expiresAt: token.expires_in ? Date.now() + Math.max(0, Number(token.expires_in) - 30) * 1000 : null,
|
|
206
|
+
connectedAt: Date.now(),
|
|
207
|
+
accountId: accounts[0]?.id ?? null,
|
|
208
|
+
sessionId: payload.sessionId,
|
|
209
|
+
tenantSegment: payload.tenantSegment,
|
|
210
|
+
};
|
|
211
|
+
const headers = new Headers();
|
|
212
|
+
appendCookie(headers, clearStateCookie());
|
|
213
|
+
appendCookie(headers, await sealAuthCookie(self, request, auth));
|
|
214
|
+
return oauthResultHtml(true, 'Cloudflare connected.', payload.sessionId, headers);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
const headers = new Headers();
|
|
218
|
+
appendCookie(headers, clearStateCookie());
|
|
219
|
+
return oauthResultHtml(false, e?.message || String(e), payload.sessionId, headers);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function selectAccount(self, request) {
|
|
223
|
+
const body = await readJson(request);
|
|
224
|
+
const accountId = String(body?.accountId || '');
|
|
225
|
+
if (!isAccountId(accountId))
|
|
226
|
+
return json({ error: 'invalid account id' }, 400);
|
|
227
|
+
const authResult = await loadFreshAuth(self, request);
|
|
228
|
+
const auth = authResult.auth;
|
|
229
|
+
if (!auth)
|
|
230
|
+
return json({ error: 'not connected' }, 409);
|
|
231
|
+
const accounts = await fetchAccounts(auth.accessToken).catch(() => []);
|
|
232
|
+
if (!accounts.some((a) => a.id === accountId)) {
|
|
233
|
+
return json({ error: 'account is not available for this OAuth token' }, 400);
|
|
234
|
+
}
|
|
235
|
+
const next = { ...auth, accountId };
|
|
236
|
+
const headers = new Headers();
|
|
237
|
+
applyAuthCookieResult(headers, authResult);
|
|
238
|
+
appendCookie(headers, await sealAuthCookie(self, request, next));
|
|
239
|
+
return json({ ok: true, accountId }, 200, headers);
|
|
240
|
+
}
|
|
241
|
+
async function agentChat(self, request, url) {
|
|
242
|
+
const body = await readJson(request);
|
|
243
|
+
const text = String(body?.message || '').trim();
|
|
244
|
+
if (!text)
|
|
245
|
+
return json({ error: 'message is required' }, 400);
|
|
246
|
+
const credentialResult = await loadAiCredentials(self, request, url);
|
|
247
|
+
if (!credentialResult.credentials) {
|
|
248
|
+
const headers = new Headers();
|
|
249
|
+
applyAuthCookieResult(headers, credentialResult.authResult);
|
|
250
|
+
return json({
|
|
251
|
+
error: 'Connect Cloudflare or configure an owner API token before chatting.',
|
|
252
|
+
code: 'E_AGENT_AI_NOT_CONFIGURED',
|
|
253
|
+
}, 409, headers);
|
|
254
|
+
}
|
|
255
|
+
const config = readConfig(self, url);
|
|
256
|
+
const messages = await loadMessages(self);
|
|
257
|
+
const userMessage = makeMessage('user', text);
|
|
258
|
+
messages.push(userMessage);
|
|
259
|
+
await saveMessages(self, messages);
|
|
260
|
+
if (body?.stream === false) {
|
|
261
|
+
return agentChatJson(self, config, credentialResult, messages);
|
|
262
|
+
}
|
|
263
|
+
return agentChatStream(self, config, credentialResult, messages, userMessage);
|
|
264
|
+
}
|
|
265
|
+
async function agentChatJson(self, config, credentialResult, messages) {
|
|
266
|
+
try {
|
|
267
|
+
const result = await runAiSdkTurn(self, config, credentialResult.credentials, messages);
|
|
268
|
+
const assistantMessage = makeMessage('assistant', result.text || 'Done.');
|
|
269
|
+
assistantMessage.parts = result.parts;
|
|
270
|
+
const nextMessages = [...messages, assistantMessage];
|
|
271
|
+
await saveMessages(self, nextMessages);
|
|
272
|
+
const headers = new Headers();
|
|
273
|
+
applyAuthCookieResult(headers, credentialResult.authResult);
|
|
274
|
+
return json({
|
|
275
|
+
ok: true,
|
|
276
|
+
message: assistantMessage,
|
|
277
|
+
messages: trimMessagesForClient(nextMessages),
|
|
278
|
+
}, 200, headers);
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
const headers = new Headers();
|
|
282
|
+
applyAuthCookieResult(headers, credentialResult.authResult);
|
|
283
|
+
return json({
|
|
284
|
+
error: e?.message || String(e),
|
|
285
|
+
code: 'E_AGENT_TURN_FAILED',
|
|
286
|
+
messages: trimMessagesForClient(messages),
|
|
287
|
+
}, 502, headers);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function agentChatStream(self, config, credentialResult, messages, userMessage) {
|
|
291
|
+
const headers = new Headers();
|
|
292
|
+
headers.set('Cache-Control', 'no-store');
|
|
293
|
+
headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
294
|
+
applyAuthCookieResult(headers, credentialResult.authResult);
|
|
295
|
+
const credentials = credentialResult.credentials;
|
|
296
|
+
const stream = new ReadableStream({
|
|
297
|
+
async start(controller) {
|
|
298
|
+
const encoder = new TextEncoder();
|
|
299
|
+
const emit = (event) => {
|
|
300
|
+
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'));
|
|
301
|
+
};
|
|
302
|
+
const parts = [];
|
|
303
|
+
const assistantMessageId = crypto.randomUUID();
|
|
304
|
+
const assistantCreatedAt = Date.now();
|
|
305
|
+
emit({ type: 'start', messages: trimMessagesForClient(messages) });
|
|
306
|
+
emit({ type: 'message', message: userMessage });
|
|
307
|
+
emit({ type: 'assistant-start', messageId: assistantMessageId, createdAt: assistantCreatedAt });
|
|
308
|
+
try {
|
|
309
|
+
const result = streamText({
|
|
310
|
+
model: createCloudflareModel(config, credentials),
|
|
311
|
+
system: SYSTEM_PROMPT,
|
|
312
|
+
messages: buildModelMessages(messages),
|
|
313
|
+
tools: createAiSdkTools(self),
|
|
314
|
+
stopWhen: isLoopFinished(),
|
|
315
|
+
maxRetries: 0,
|
|
316
|
+
});
|
|
317
|
+
for await (const chunk of result.fullStream) {
|
|
318
|
+
if (chunk.type === 'text-delta') {
|
|
319
|
+
appendTextPart(parts, 'text', chunk.text);
|
|
320
|
+
emit({ type: 'text-delta', delta: chunk.text });
|
|
321
|
+
}
|
|
322
|
+
else if (chunk.type === 'reasoning-delta') {
|
|
323
|
+
appendTextPart(parts, 'reasoning', chunk.text);
|
|
324
|
+
emit({ type: 'reasoning-delta', delta: chunk.text });
|
|
325
|
+
}
|
|
326
|
+
else if (chunk.type === 'tool-call') {
|
|
327
|
+
upsertToolPart(parts, {
|
|
328
|
+
toolCallId: chunk.toolCallId,
|
|
329
|
+
toolName: chunk.toolName,
|
|
330
|
+
input: chunk.input,
|
|
331
|
+
status: 'running',
|
|
332
|
+
startedAt: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
emit({
|
|
335
|
+
type: 'tool-call',
|
|
336
|
+
toolCallId: chunk.toolCallId,
|
|
337
|
+
toolName: chunk.toolName,
|
|
338
|
+
input: chunk.input,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else if (chunk.type === 'tool-result') {
|
|
342
|
+
const output = compactStreamValue(chunk.output);
|
|
343
|
+
const status = isToolOutputFailure(output) ? 'error' : 'done';
|
|
344
|
+
upsertToolPart(parts, {
|
|
345
|
+
toolCallId: chunk.toolCallId,
|
|
346
|
+
toolName: chunk.toolName,
|
|
347
|
+
input: chunk.input,
|
|
348
|
+
output,
|
|
349
|
+
status,
|
|
350
|
+
});
|
|
351
|
+
emit({
|
|
352
|
+
type: 'tool-result',
|
|
353
|
+
toolCallId: chunk.toolCallId,
|
|
354
|
+
toolName: chunk.toolName,
|
|
355
|
+
input: chunk.input,
|
|
356
|
+
output,
|
|
357
|
+
status,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else if (chunk.type === 'tool-error') {
|
|
361
|
+
const error = stringifyError(chunk.error);
|
|
362
|
+
upsertToolPart(parts, {
|
|
363
|
+
toolCallId: chunk.toolCallId,
|
|
364
|
+
toolName: chunk.toolName,
|
|
365
|
+
input: chunk.input,
|
|
366
|
+
output: { error },
|
|
367
|
+
error,
|
|
368
|
+
status: 'error',
|
|
369
|
+
});
|
|
370
|
+
emit({
|
|
371
|
+
type: 'tool-error',
|
|
372
|
+
toolCallId: chunk.toolCallId,
|
|
373
|
+
toolName: chunk.toolName,
|
|
374
|
+
input: chunk.input,
|
|
375
|
+
error,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
else if (chunk.type === 'finish-step') {
|
|
379
|
+
emit({
|
|
380
|
+
type: 'finish-step',
|
|
381
|
+
finishReason: chunk.finishReason,
|
|
382
|
+
usage: chunk.usage,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const assistantMessage = {
|
|
387
|
+
id: assistantMessageId,
|
|
388
|
+
role: 'assistant',
|
|
389
|
+
content: textFromParts(parts) || 'Done.',
|
|
390
|
+
createdAt: assistantCreatedAt,
|
|
391
|
+
parts,
|
|
392
|
+
};
|
|
393
|
+
const nextMessages = [...messages, assistantMessage];
|
|
394
|
+
await saveMessages(self, nextMessages);
|
|
395
|
+
emit({
|
|
396
|
+
type: 'done',
|
|
397
|
+
message: assistantMessage,
|
|
398
|
+
messages: trimMessagesForClient(nextMessages),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
emit({
|
|
403
|
+
type: 'error',
|
|
404
|
+
error: e?.message || String(e),
|
|
405
|
+
code: 'E_AGENT_TURN_FAILED',
|
|
406
|
+
messages: trimMessagesForClient(messages),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
controller.close();
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
return new Response(stream, { status: 200, headers });
|
|
415
|
+
}
|
|
416
|
+
async function runAiSdkTurn(self, config, credentials, messages) {
|
|
417
|
+
const model = createCloudflareModel(config, credentials);
|
|
418
|
+
const result = await generateText({
|
|
419
|
+
model,
|
|
420
|
+
system: SYSTEM_PROMPT,
|
|
421
|
+
messages: buildModelMessages(messages),
|
|
422
|
+
tools: createAiSdkTools(self),
|
|
423
|
+
stopWhen: isLoopFinished(),
|
|
424
|
+
maxRetries: 0,
|
|
425
|
+
});
|
|
426
|
+
const parts = collectTurnParts(result);
|
|
427
|
+
return {
|
|
428
|
+
text: textFromParts(parts) || String(result.text || '').trim(),
|
|
429
|
+
parts,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function createCloudflareModel(config, credentials) {
|
|
433
|
+
const headers = {};
|
|
434
|
+
if (config.gatewayId)
|
|
435
|
+
headers['cf-aig-gateway-id'] = config.gatewayId;
|
|
436
|
+
const provider = createOpenAICompatible({
|
|
437
|
+
name: 'nimbusCloudflare',
|
|
438
|
+
apiKey: credentials.accessToken,
|
|
439
|
+
baseURL: `${CLOUDFLARE_API}/accounts/${encodeURIComponent(credentials.accountId)}/ai/v1`,
|
|
440
|
+
headers,
|
|
441
|
+
});
|
|
442
|
+
return provider.chatModel(config.model);
|
|
443
|
+
}
|
|
444
|
+
function buildModelMessages(messages) {
|
|
445
|
+
const modelMessages = [];
|
|
446
|
+
for (const message of messages) {
|
|
447
|
+
if (message.role === 'user') {
|
|
448
|
+
modelMessages.push({ role: 'user', content: message.content });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (message.role === 'assistant') {
|
|
452
|
+
appendAssistantModelMessages(modelMessages, message);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (message.role === 'tool') {
|
|
456
|
+
const payload = parseLegacyToolPayload(message);
|
|
457
|
+
modelMessages.push({
|
|
458
|
+
role: 'assistant',
|
|
459
|
+
content: `Tool ${payload.tool || message.name || 'tool'} result:\n${safeJsonStringify(payload.output ?? message.content)}`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return modelMessages;
|
|
464
|
+
}
|
|
465
|
+
function createAiSdkTools(self) {
|
|
466
|
+
return {
|
|
467
|
+
exec: aiTool({
|
|
468
|
+
description: 'Run a shell command in the Nimbus session.',
|
|
469
|
+
inputSchema: toolSchema({
|
|
470
|
+
command: stringProp('Command line to execute.'),
|
|
471
|
+
cwd: stringProp('Working directory. Defaults to /home/user.'),
|
|
472
|
+
timeoutMs: numberProp('Command timeout in milliseconds.'),
|
|
473
|
+
}, ['command']),
|
|
474
|
+
execute: async (args) => runTool(self, 'exec', args),
|
|
475
|
+
}),
|
|
476
|
+
read_file: aiTool({
|
|
477
|
+
description: 'Read a UTF-8 file from the Nimbus VFS.',
|
|
478
|
+
inputSchema: toolSchema({
|
|
479
|
+
path: stringProp('Absolute or /home/user-relative path.'),
|
|
480
|
+
}, ['path']),
|
|
481
|
+
execute: async (args) => runTool(self, 'read_file', args),
|
|
482
|
+
}),
|
|
483
|
+
write_file: aiTool({
|
|
484
|
+
description: 'Write a UTF-8 file into the Nimbus VFS, creating parent directories.',
|
|
485
|
+
inputSchema: toolSchema({
|
|
486
|
+
path: stringProp('Absolute or /home/user-relative path.'),
|
|
487
|
+
content: stringProp('File content.'),
|
|
488
|
+
}, ['path', 'content']),
|
|
489
|
+
execute: async (args) => runTool(self, 'write_file', args),
|
|
490
|
+
}),
|
|
491
|
+
list_files: aiTool({
|
|
492
|
+
description: 'List files in a Nimbus VFS directory.',
|
|
493
|
+
inputSchema: toolSchema({
|
|
494
|
+
path: stringProp('Directory path. Defaults to /home/user.'),
|
|
495
|
+
}, []),
|
|
496
|
+
execute: async (args) => runTool(self, 'list_files', args),
|
|
497
|
+
}),
|
|
498
|
+
install_runtime: aiTool({
|
|
499
|
+
description: 'Install or ensure a Nimbus runtime package.',
|
|
500
|
+
inputSchema: toolSchema({
|
|
501
|
+
spec: stringProp('Runtime spec such as python, clang, or ruby.'),
|
|
502
|
+
}, ['spec']),
|
|
503
|
+
execute: async (args) => runTool(self, 'install_runtime', args),
|
|
504
|
+
}),
|
|
505
|
+
ensure_runtime: aiTool({
|
|
506
|
+
description: 'Ensure a Nimbus runtime package is installed.',
|
|
507
|
+
inputSchema: toolSchema({
|
|
508
|
+
spec: stringProp('Runtime spec such as python, clang, or ruby.'),
|
|
509
|
+
}, ['spec']),
|
|
510
|
+
execute: async (args) => runTool(self, 'ensure_runtime', args),
|
|
511
|
+
}),
|
|
512
|
+
start_process: aiTool({
|
|
513
|
+
description: 'Start a long-running command and return process and port metadata.',
|
|
514
|
+
inputSchema: toolSchema({
|
|
515
|
+
command: stringProp('Command line to start.'),
|
|
516
|
+
cwd: stringProp('Working directory. Defaults to /home/user.'),
|
|
517
|
+
}, ['command']),
|
|
518
|
+
execute: async (args) => runTool(self, 'start_process', args),
|
|
519
|
+
}),
|
|
520
|
+
list_processes: aiTool({
|
|
521
|
+
description: 'List session processes.',
|
|
522
|
+
inputSchema: toolSchema({}, []),
|
|
523
|
+
execute: async (args) => runTool(self, 'list_processes', args),
|
|
524
|
+
}),
|
|
525
|
+
kill_process: aiTool({
|
|
526
|
+
description: 'Kill a session process by pid.',
|
|
527
|
+
inputSchema: toolSchema({
|
|
528
|
+
pid: numberProp('Process id.'),
|
|
529
|
+
}, ['pid']),
|
|
530
|
+
execute: async (args) => runTool(self, 'kill_process', args),
|
|
531
|
+
}),
|
|
532
|
+
process_logs: aiTool({
|
|
533
|
+
description: 'Read recent process logs.',
|
|
534
|
+
inputSchema: toolSchema({
|
|
535
|
+
pid: numberProp('Process id.'),
|
|
536
|
+
lines: numberProp('Number of log lines. Defaults to 200.'),
|
|
537
|
+
}, ['pid']),
|
|
538
|
+
execute: async (args) => runTool(self, 'process_logs', args),
|
|
539
|
+
}),
|
|
540
|
+
list_ports: aiTool({
|
|
541
|
+
description: 'List exposed preview ports.',
|
|
542
|
+
inputSchema: toolSchema({}, []),
|
|
543
|
+
execute: async (args) => runTool(self, 'list_ports', args),
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function collectTurnParts(result) {
|
|
548
|
+
const parts = [];
|
|
549
|
+
for (const step of result.steps || []) {
|
|
550
|
+
for (const part of step.content || []) {
|
|
551
|
+
if (part?.type === 'text') {
|
|
552
|
+
appendTextPart(parts, 'text', String(part.text || ''));
|
|
553
|
+
}
|
|
554
|
+
else if (part?.type === 'reasoning') {
|
|
555
|
+
appendTextPart(parts, 'reasoning', String(part.text || ''));
|
|
556
|
+
}
|
|
557
|
+
else if (part?.type === 'tool-call') {
|
|
558
|
+
upsertToolPart(parts, {
|
|
559
|
+
toolCallId: String(part.toolCallId || crypto.randomUUID()),
|
|
560
|
+
toolName: String(part.toolName || 'tool'),
|
|
561
|
+
input: part.input,
|
|
562
|
+
status: 'running',
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
else if (part?.type === 'tool-result') {
|
|
566
|
+
const output = compactStreamValue(part.output);
|
|
567
|
+
upsertToolPart(parts, {
|
|
568
|
+
toolCallId: String(part.toolCallId || crypto.randomUUID()),
|
|
569
|
+
toolName: String(part.toolName || 'tool'),
|
|
570
|
+
input: part.input,
|
|
571
|
+
output,
|
|
572
|
+
status: isToolOutputFailure(output) ? 'error' : 'done',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
else if (part?.type === 'tool-error') {
|
|
576
|
+
const error = stringifyError(part.error);
|
|
577
|
+
upsertToolPart(parts, {
|
|
578
|
+
toolCallId: String(part.toolCallId || crypto.randomUUID()),
|
|
579
|
+
toolName: String(part.toolName || 'tool'),
|
|
580
|
+
input: part.input,
|
|
581
|
+
output: { error },
|
|
582
|
+
error,
|
|
583
|
+
status: 'error',
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (parts.length === 0 && result.text)
|
|
589
|
+
appendTextPart(parts, 'text', String(result.text));
|
|
590
|
+
return parts;
|
|
591
|
+
}
|
|
592
|
+
function appendTextPart(parts, type, delta) {
|
|
593
|
+
if (!delta)
|
|
594
|
+
return;
|
|
595
|
+
const last = parts[parts.length - 1];
|
|
596
|
+
if (last?.type === type) {
|
|
597
|
+
last.text += delta;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
parts.push({ type, text: delta });
|
|
601
|
+
}
|
|
602
|
+
function upsertToolPart(parts, patch) {
|
|
603
|
+
let part = parts.find((item) => (item.type === 'tool' && item.toolCallId === patch.toolCallId));
|
|
604
|
+
if (!part) {
|
|
605
|
+
part = {
|
|
606
|
+
type: 'tool',
|
|
607
|
+
toolCallId: patch.toolCallId,
|
|
608
|
+
toolName: patch.toolName,
|
|
609
|
+
status: patch.status || 'running',
|
|
610
|
+
};
|
|
611
|
+
parts.push(part);
|
|
612
|
+
}
|
|
613
|
+
const startedAt = part.startedAt;
|
|
614
|
+
Object.assign(part, patch);
|
|
615
|
+
if (startedAt && patch.status && patch.status !== 'running' && !part.durationMs) {
|
|
616
|
+
part.durationMs = Date.now() - startedAt;
|
|
617
|
+
}
|
|
618
|
+
return part;
|
|
619
|
+
}
|
|
620
|
+
function textFromParts(parts) {
|
|
621
|
+
return parts
|
|
622
|
+
.filter((part) => part.type === 'text')
|
|
623
|
+
.map((part) => part.text)
|
|
624
|
+
.join('')
|
|
625
|
+
.trim();
|
|
626
|
+
}
|
|
627
|
+
function appendAssistantModelMessages(modelMessages, message) {
|
|
628
|
+
const parts = normalizeMessageParts(message);
|
|
629
|
+
if (parts.length === 0) {
|
|
630
|
+
if (message.content)
|
|
631
|
+
modelMessages.push({ role: 'assistant', content: message.content });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let assistantContent = [];
|
|
635
|
+
let toolContent = [];
|
|
636
|
+
const flush = () => {
|
|
637
|
+
if (assistantContent.length > 0) {
|
|
638
|
+
modelMessages.push({ role: 'assistant', content: assistantContent });
|
|
639
|
+
assistantContent = [];
|
|
640
|
+
}
|
|
641
|
+
if (toolContent.length > 0) {
|
|
642
|
+
modelMessages.push({ role: 'tool', content: toolContent });
|
|
643
|
+
toolContent = [];
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
for (const part of parts) {
|
|
647
|
+
if (part.type === 'text') {
|
|
648
|
+
if (toolContent.length > 0)
|
|
649
|
+
flush();
|
|
650
|
+
if (part.text)
|
|
651
|
+
assistantContent.push({ type: 'text', text: part.text });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (part.type === 'reasoning') {
|
|
655
|
+
if (toolContent.length > 0)
|
|
656
|
+
flush();
|
|
657
|
+
if (part.text)
|
|
658
|
+
assistantContent.push({ type: 'reasoning', text: part.text });
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (part.type !== 'tool' || !part.toolCallId || !part.toolName)
|
|
662
|
+
continue;
|
|
663
|
+
assistantContent.push({
|
|
664
|
+
type: 'tool-call',
|
|
665
|
+
toolCallId: part.toolCallId,
|
|
666
|
+
toolName: part.toolName,
|
|
667
|
+
input: part.input ?? {},
|
|
668
|
+
});
|
|
669
|
+
if (part.output !== undefined || part.error) {
|
|
670
|
+
toolContent.push({
|
|
671
|
+
type: 'tool-result',
|
|
672
|
+
toolCallId: part.toolCallId,
|
|
673
|
+
toolName: part.toolName,
|
|
674
|
+
output: toToolModelOutput(part.output ?? { error: part.error }, part.status === 'error' || !!part.error),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
flush();
|
|
679
|
+
}
|
|
680
|
+
function normalizeMessageParts(message) {
|
|
681
|
+
if (Array.isArray(message.parts))
|
|
682
|
+
return message.parts.filter((part) => part && typeof part === 'object');
|
|
683
|
+
if (message.role !== 'assistant' || !message.content)
|
|
684
|
+
return [];
|
|
685
|
+
return [{ type: 'text', text: message.content }];
|
|
686
|
+
}
|
|
687
|
+
function toToolModelOutput(value, error) {
|
|
688
|
+
if (error)
|
|
689
|
+
return { type: 'error-text', value: stringifyError(value) };
|
|
690
|
+
if (typeof value === 'string')
|
|
691
|
+
return { type: 'text', value };
|
|
692
|
+
return { type: 'json', value: value === undefined ? null : value };
|
|
693
|
+
}
|
|
694
|
+
function parseLegacyToolPayload(message) {
|
|
695
|
+
try {
|
|
696
|
+
const parsed = JSON.parse(message.content || '{}');
|
|
697
|
+
if (parsed && typeof parsed === 'object')
|
|
698
|
+
return parsed;
|
|
699
|
+
}
|
|
700
|
+
catch { }
|
|
701
|
+
return { tool: message.name || 'tool', output: message.content || '' };
|
|
702
|
+
}
|
|
703
|
+
function isToolOutputFailure(output) {
|
|
704
|
+
if (!output || typeof output !== 'object')
|
|
705
|
+
return false;
|
|
706
|
+
const record = output;
|
|
707
|
+
if (typeof record.error === 'string' && record.error.trim())
|
|
708
|
+
return true;
|
|
709
|
+
if (record.success === false)
|
|
710
|
+
return true;
|
|
711
|
+
if (typeof record.exitCode === 'number' && record.exitCode !== 0)
|
|
712
|
+
return true;
|
|
713
|
+
if (record.exit && typeof record.exit === 'object') {
|
|
714
|
+
const exit = record.exit;
|
|
715
|
+
if (typeof exit.code === 'number' && exit.code !== 0)
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
function stringifyError(error) {
|
|
721
|
+
if (error instanceof Error)
|
|
722
|
+
return error.message;
|
|
723
|
+
if (typeof error === 'object' && error !== null) {
|
|
724
|
+
try {
|
|
725
|
+
return JSON.stringify(error);
|
|
726
|
+
}
|
|
727
|
+
catch { }
|
|
728
|
+
}
|
|
729
|
+
return String(error);
|
|
730
|
+
}
|
|
731
|
+
function compactStreamValue(value) {
|
|
732
|
+
if (typeof value === 'string')
|
|
733
|
+
return truncate(value, MAX_TOOL_RESULT_CHARS);
|
|
734
|
+
const text = safeJsonStringify(value);
|
|
735
|
+
if (text.length <= MAX_TOOL_RESULT_CHARS)
|
|
736
|
+
return value;
|
|
737
|
+
return truncate(text, MAX_TOOL_RESULT_CHARS);
|
|
738
|
+
}
|
|
739
|
+
function toolSchema(properties, required) {
|
|
740
|
+
return jsonSchema({
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties,
|
|
743
|
+
required,
|
|
744
|
+
additionalProperties: false,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async function runTool(self, name, args) {
|
|
748
|
+
try {
|
|
749
|
+
if (name === 'exec') {
|
|
750
|
+
return rpcExec(self, String(args.command || ''), {
|
|
751
|
+
cwd: args.cwd ? String(args.cwd) : '/home/user',
|
|
752
|
+
timeoutMs: clampNumber(args.timeoutMs, 1_000, 120_000, 30_000),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
if (name === 'read_file') {
|
|
756
|
+
await ensureProgrammaticReady(self);
|
|
757
|
+
const path = normalizeAgentVfsPath(args.path || '/home/user');
|
|
758
|
+
return { path: '/' + path, content: self.sqliteFs.readFileString(path) };
|
|
759
|
+
}
|
|
760
|
+
if (name === 'write_file') {
|
|
761
|
+
await ensureProgrammaticReady(self);
|
|
762
|
+
const path = normalizeAgentVfsPath(args.path || '/home/user/file.txt');
|
|
763
|
+
ensureParentDirs(self.sqliteFs, path);
|
|
764
|
+
self.sqliteFs.writeFile(path, String(args.content ?? ''));
|
|
765
|
+
return { ok: true, path: '/' + path, bytes: String(args.content ?? '').length };
|
|
766
|
+
}
|
|
767
|
+
if (name === 'list_files') {
|
|
768
|
+
await ensureProgrammaticReady(self);
|
|
769
|
+
const path = normalizeAgentVfsPath(args.path || '/home/user');
|
|
770
|
+
const base = trimTrailingSlash(path);
|
|
771
|
+
const entries = self.sqliteFs.readdir(path).map((entry) => ({
|
|
772
|
+
name: entry.name,
|
|
773
|
+
type: entry.type,
|
|
774
|
+
path: '/' + base + '/' + entry.name,
|
|
775
|
+
}));
|
|
776
|
+
return { path: '/' + path, entries };
|
|
777
|
+
}
|
|
778
|
+
if (name === 'install_runtime') {
|
|
779
|
+
const spec = String(args.spec || '').trim();
|
|
780
|
+
if (!spec)
|
|
781
|
+
return { error: 'spec is required' };
|
|
782
|
+
return rpcInstallRuntime(self, spec);
|
|
783
|
+
}
|
|
784
|
+
if (name === 'ensure_runtime') {
|
|
785
|
+
const spec = String(args.spec || '').trim();
|
|
786
|
+
if (!spec)
|
|
787
|
+
return { error: 'spec is required' };
|
|
788
|
+
return rpcEnsureRuntimes(self, [spec]);
|
|
789
|
+
}
|
|
790
|
+
if (name === 'start_process') {
|
|
791
|
+
return rpcStartProcess(self, String(args.command || ''), {
|
|
792
|
+
cwd: args.cwd ? String(args.cwd) : '/home/user',
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (name === 'list_processes')
|
|
796
|
+
return rpcListProcesses(self);
|
|
797
|
+
if (name === 'kill_process')
|
|
798
|
+
return rpcKillProcess(self, Number(args.pid));
|
|
799
|
+
if (name === 'process_logs') {
|
|
800
|
+
return rpcProcessLogs(self, Number(args.pid), {
|
|
801
|
+
lines: clampNumber(args.lines, 1, 1000, 200),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (name === 'list_ports')
|
|
805
|
+
return rpcListPorts(self);
|
|
806
|
+
return { error: `unknown tool: ${name}` };
|
|
807
|
+
}
|
|
808
|
+
catch (e) {
|
|
809
|
+
return { error: e?.message || String(e) };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function loadAiCredentials(self, request, url) {
|
|
813
|
+
const config = readConfig(self, url);
|
|
814
|
+
const authResult = await loadFreshAuth(self, request);
|
|
815
|
+
const auth = authResult.auth;
|
|
816
|
+
if (auth?.accessToken && auth.accountId) {
|
|
817
|
+
return {
|
|
818
|
+
authResult,
|
|
819
|
+
credentials: {
|
|
820
|
+
mode: 'oauth',
|
|
821
|
+
accessToken: auth.accessToken,
|
|
822
|
+
accountId: auth.accountId,
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
if (!config.requireUserOAuth && config.ownerToken && config.ownerAccountId) {
|
|
827
|
+
return {
|
|
828
|
+
authResult,
|
|
829
|
+
credentials: {
|
|
830
|
+
mode: 'owner-token',
|
|
831
|
+
accessToken: config.ownerToken,
|
|
832
|
+
accountId: config.ownerAccountId,
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return { credentials: null, authResult };
|
|
837
|
+
}
|
|
838
|
+
async function loadFreshAuth(self, request) {
|
|
839
|
+
const auth = await loadAuth(self, request);
|
|
840
|
+
if (!auth)
|
|
841
|
+
return { auth: null };
|
|
842
|
+
if (!auth.expiresAt || auth.expiresAt > Date.now() + 60_000)
|
|
843
|
+
return { auth };
|
|
844
|
+
if (!auth.refreshToken) {
|
|
845
|
+
return { auth: null, clearCookie: clearAuthCookie(request) };
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
const token = await refreshToken(self, auth.refreshToken);
|
|
849
|
+
const next = {
|
|
850
|
+
...auth,
|
|
851
|
+
accessToken: String(token.access_token || auth.accessToken),
|
|
852
|
+
refreshToken: token.refresh_token ? String(token.refresh_token) : auth.refreshToken,
|
|
853
|
+
tokenType: token.token_type ? String(token.token_type) : auth.tokenType,
|
|
854
|
+
expiresAt: token.expires_in ? Date.now() + Math.max(0, Number(token.expires_in) - 30) * 1000 : auth.expiresAt,
|
|
855
|
+
};
|
|
856
|
+
return {
|
|
857
|
+
auth: next,
|
|
858
|
+
setCookie: await sealAuthCookie(self, request, next),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return { auth: null, clearCookie: clearAuthCookie(request) };
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function exchangeCode(self, code, codeVerifier, redirectUri) {
|
|
866
|
+
const config = readConfig(self, new URL(redirectUri));
|
|
867
|
+
return tokenRequest(config, {
|
|
868
|
+
grant_type: 'authorization_code',
|
|
869
|
+
code,
|
|
870
|
+
redirect_uri: redirectUri,
|
|
871
|
+
code_verifier: codeVerifier,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
async function refreshToken(self, refreshTokenValue) {
|
|
875
|
+
const config = readConfig(self, null);
|
|
876
|
+
return tokenRequest(config, {
|
|
877
|
+
grant_type: 'refresh_token',
|
|
878
|
+
refresh_token: refreshTokenValue,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
async function tokenRequest(config, fields) {
|
|
882
|
+
if (!config.oauthClientId)
|
|
883
|
+
throw new Error('OAuth client id is not configured');
|
|
884
|
+
const body = new URLSearchParams({
|
|
885
|
+
client_id: config.oauthClientId,
|
|
886
|
+
...fields,
|
|
887
|
+
});
|
|
888
|
+
const headers = new Headers({
|
|
889
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
890
|
+
Accept: 'application/json',
|
|
891
|
+
});
|
|
892
|
+
if (config.oauthClientSecret) {
|
|
893
|
+
headers.set('Authorization', 'Basic ' + base64Utf8(`${config.oauthClientId}:${config.oauthClientSecret}`));
|
|
894
|
+
}
|
|
895
|
+
const response = await fetch(CF_OAUTH_TOKEN_URL, {
|
|
896
|
+
method: 'POST',
|
|
897
|
+
headers,
|
|
898
|
+
body,
|
|
899
|
+
});
|
|
900
|
+
const payload = await response.json().catch(() => null);
|
|
901
|
+
if (!response.ok) {
|
|
902
|
+
const detail = payload?.error_description || payload?.error || response.statusText;
|
|
903
|
+
throw new Error(`Cloudflare token exchange failed: ${detail}`);
|
|
904
|
+
}
|
|
905
|
+
return payload;
|
|
906
|
+
}
|
|
907
|
+
async function fetchUserInfo(accessToken) {
|
|
908
|
+
const response = await fetch(CF_OAUTH_USERINFO_URL, {
|
|
909
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
910
|
+
});
|
|
911
|
+
if (!response.ok)
|
|
912
|
+
throw new Error('userinfo request failed');
|
|
913
|
+
return response.json();
|
|
914
|
+
}
|
|
915
|
+
async function fetchAccounts(accessToken) {
|
|
916
|
+
const response = await fetch(`${CLOUDFLARE_API}/accounts`, {
|
|
917
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
918
|
+
});
|
|
919
|
+
const payload = await response.json().catch(() => null);
|
|
920
|
+
if (!response.ok)
|
|
921
|
+
throw new Error('accounts request failed');
|
|
922
|
+
const accounts = Array.isArray(payload?.result) ? payload.result : [];
|
|
923
|
+
return accounts
|
|
924
|
+
.map((account) => ({ id: String(account.id || ''), name: String(account.name || account.id || '') }))
|
|
925
|
+
.filter((account) => isAccountId(account.id));
|
|
926
|
+
}
|
|
927
|
+
async function loadAuth(self, request) {
|
|
928
|
+
const value = readCookie(request, AUTH_COOKIE);
|
|
929
|
+
if (!value)
|
|
930
|
+
return null;
|
|
931
|
+
const auth = await unsealCookie(self, value, AUTH_COOKIE_PURPOSE).catch(() => null);
|
|
932
|
+
if (!auth || auth.mode !== 'oauth' || !auth.accessToken)
|
|
933
|
+
return null;
|
|
934
|
+
const route = routeContext(request);
|
|
935
|
+
if (auth.sessionId !== route.sessionId ||
|
|
936
|
+
auth.tenantSegment !== route.tenantSegment) {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
return auth;
|
|
940
|
+
}
|
|
941
|
+
async function loadStateCookie(self, request) {
|
|
942
|
+
const value = readCookie(request, STATE_COOKIE);
|
|
943
|
+
if (!value)
|
|
944
|
+
return null;
|
|
945
|
+
const state = await unsealCookie(self, value, STATE_COOKIE_PURPOSE).catch(() => null);
|
|
946
|
+
if (!state || state.v !== 1 || !isNonce(state.nonce))
|
|
947
|
+
return null;
|
|
948
|
+
if (!isSessionId(state.sessionId) || !isTenantSegment(state.tenantSegment))
|
|
949
|
+
return null;
|
|
950
|
+
if (!state.codeVerifier || !state.redirectUri)
|
|
951
|
+
return null;
|
|
952
|
+
return state;
|
|
953
|
+
}
|
|
954
|
+
async function sealStateCookie(self, state) {
|
|
955
|
+
return serializeCookie(STATE_COOKIE, await sealCookie(self, state, STATE_COOKIE_PURPOSE), {
|
|
956
|
+
path: '/',
|
|
957
|
+
maxAge: Math.ceil(OAUTH_STATE_TTL_MS / 1000),
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
async function sealAuthCookie(self, request, auth) {
|
|
961
|
+
return serializeCookie(AUTH_COOKIE, await sealCookie(self, auth, AUTH_COOKIE_PURPOSE), {
|
|
962
|
+
path: authCookiePath(request),
|
|
963
|
+
maxAge: AUTH_COOKIE_TTL_SECONDS,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
function clearStateCookie() {
|
|
967
|
+
return serializeCookie(STATE_COOKIE, '', { path: '/', maxAge: 0 });
|
|
968
|
+
}
|
|
969
|
+
function clearAuthCookie(request) {
|
|
970
|
+
return serializeCookie(AUTH_COOKIE, '', { path: authCookiePath(request), maxAge: 0 });
|
|
971
|
+
}
|
|
972
|
+
function applyAuthCookieResult(headers, result) {
|
|
973
|
+
if (!result)
|
|
974
|
+
return;
|
|
975
|
+
if (result.clearCookie)
|
|
976
|
+
appendCookie(headers, result.clearCookie);
|
|
977
|
+
if (result.setCookie)
|
|
978
|
+
appendCookie(headers, result.setCookie);
|
|
979
|
+
}
|
|
980
|
+
function appendCookie(headers, cookie) {
|
|
981
|
+
headers.append('Set-Cookie', cookie);
|
|
982
|
+
}
|
|
983
|
+
async function sealCookie(self, value, purpose) {
|
|
984
|
+
return sealJson(value, cookieSecret(self), { purpose });
|
|
985
|
+
}
|
|
986
|
+
async function unsealCookie(self, value, purpose) {
|
|
987
|
+
return unsealJson(value, cookieSecret(self), { purpose });
|
|
988
|
+
}
|
|
989
|
+
function cookieSecret(self) {
|
|
990
|
+
const env = self.env;
|
|
991
|
+
const secret = envString(env, 'NIMBUS_AGENT_COOKIE_SECRET') || envString(env, 'JWT_SECRET');
|
|
992
|
+
if (!secret || secret.length < 32) {
|
|
993
|
+
throw new Error('Set NIMBUS_AGENT_COOKIE_SECRET or JWT_SECRET to a 32+ character value before enabling Cloudflare OAuth');
|
|
994
|
+
}
|
|
995
|
+
return secret;
|
|
996
|
+
}
|
|
997
|
+
function serializeCookie(name, value, opts) {
|
|
998
|
+
return [
|
|
999
|
+
`${name}=${value}`,
|
|
1000
|
+
`Path=${opts.path}`,
|
|
1001
|
+
`Max-Age=${Math.max(0, Math.floor(opts.maxAge))}`,
|
|
1002
|
+
'HttpOnly',
|
|
1003
|
+
'Secure',
|
|
1004
|
+
'SameSite=Lax',
|
|
1005
|
+
].join('; ');
|
|
1006
|
+
}
|
|
1007
|
+
function readCookie(request, name) {
|
|
1008
|
+
const header = request.headers.get('Cookie') || request.headers.get('cookie') || '';
|
|
1009
|
+
const target = name + '=';
|
|
1010
|
+
for (const part of header.split(';')) {
|
|
1011
|
+
const item = part.trim();
|
|
1012
|
+
if (item.startsWith(target))
|
|
1013
|
+
return item.slice(target.length);
|
|
1014
|
+
}
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
function authCookiePath(request) {
|
|
1018
|
+
const base = request.headers.get(BASE_PATH_HEADER) || '';
|
|
1019
|
+
return base.startsWith('/s/') ? base : '/s';
|
|
1020
|
+
}
|
|
1021
|
+
function routeContext(request) {
|
|
1022
|
+
const base = request.headers.get(BASE_PATH_HEADER) || '';
|
|
1023
|
+
const sessionId = base.startsWith('/s/') ? base.slice(3).split('/')[0] : '';
|
|
1024
|
+
return {
|
|
1025
|
+
sessionId,
|
|
1026
|
+
tenantSegment: request.headers.get(TENANT_HEADER) || 'legacy:public:_',
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
async function loadMessages(self) {
|
|
1030
|
+
const messages = await self.ctx.storage.get(MESSAGES_KEY);
|
|
1031
|
+
return Array.isArray(messages) ? messages : [];
|
|
1032
|
+
}
|
|
1033
|
+
async function saveMessages(self, messages) {
|
|
1034
|
+
await self.ctx.storage.put(MESSAGES_KEY, trimMessagesForClient(messages));
|
|
1035
|
+
}
|
|
1036
|
+
function trimMessagesForClient(messages) {
|
|
1037
|
+
return messages.slice(-MAX_STORED_MESSAGES);
|
|
1038
|
+
}
|
|
1039
|
+
function makeMessage(role, content, name) {
|
|
1040
|
+
return {
|
|
1041
|
+
id: crypto.randomUUID(),
|
|
1042
|
+
role,
|
|
1043
|
+
content,
|
|
1044
|
+
createdAt: Date.now(),
|
|
1045
|
+
...(name ? { name } : {}),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
function readConfig(self, url) {
|
|
1049
|
+
const env = self.env;
|
|
1050
|
+
const origin = url?.origin || '';
|
|
1051
|
+
const redirectUri = envString(env, 'NIMBUS_CF_OAUTH_REDIRECT_URI')
|
|
1052
|
+
|| (origin ? `${origin}/api/nimbus/oauth/callback` : '');
|
|
1053
|
+
return {
|
|
1054
|
+
oauthClientId: envString(env, 'NIMBUS_CF_OAUTH_CLIENT_ID'),
|
|
1055
|
+
oauthClientSecret: envString(env, 'NIMBUS_CF_OAUTH_CLIENT_SECRET'),
|
|
1056
|
+
oauthScopes: splitWords(envString(env, 'NIMBUS_CF_OAUTH_SCOPES')),
|
|
1057
|
+
redirectUri,
|
|
1058
|
+
ownerAccountId: envString(env, 'NIMBUS_CLOUDFLARE_ACCOUNT_ID'),
|
|
1059
|
+
ownerToken: envString(env, 'NIMBUS_CLOUDFLARE_API_TOKEN'),
|
|
1060
|
+
requireUserOAuth: envBool(env, 'NIMBUS_AGENT_REQUIRE_USER_OAUTH'),
|
|
1061
|
+
model: envString(env, 'NIMBUS_AGENT_MODEL') || DEFAULT_MODEL,
|
|
1062
|
+
gatewayId: envString(env, 'NIMBUS_AGENT_GATEWAY_ID') || DEFAULT_GATEWAY_ID,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function envString(env, key) {
|
|
1066
|
+
const value = env?.[key];
|
|
1067
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
1068
|
+
}
|
|
1069
|
+
function envBool(env, key) {
|
|
1070
|
+
const value = envString(env, key).toLowerCase();
|
|
1071
|
+
return value === '1' || value === 'true' || value === 'yes';
|
|
1072
|
+
}
|
|
1073
|
+
function splitWords(value) {
|
|
1074
|
+
const out = [];
|
|
1075
|
+
let cur = '';
|
|
1076
|
+
for (let i = 0; i < value.length; i++) {
|
|
1077
|
+
const ch = value[i];
|
|
1078
|
+
if (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') {
|
|
1079
|
+
if (cur)
|
|
1080
|
+
out.push(cur);
|
|
1081
|
+
cur = '';
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
cur += ch;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (cur)
|
|
1088
|
+
out.push(cur);
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
1091
|
+
function stringProp(description) {
|
|
1092
|
+
return { type: 'string', description };
|
|
1093
|
+
}
|
|
1094
|
+
function numberProp(description) {
|
|
1095
|
+
return { type: 'number', description };
|
|
1096
|
+
}
|
|
1097
|
+
function normalizeAgentVfsPath(input) {
|
|
1098
|
+
const raw = String(input || '/home/user').trim() || '/home/user';
|
|
1099
|
+
return resolveVfsPath(raw, '/home/user') || 'home/user';
|
|
1100
|
+
}
|
|
1101
|
+
function ensureParentDirs(vfs, path) {
|
|
1102
|
+
const parts = path.split('/');
|
|
1103
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1104
|
+
const dir = parts.slice(0, i).join('/');
|
|
1105
|
+
if (dir && !vfs.exists(dir))
|
|
1106
|
+
vfs.mkdir(dir, { recursive: true });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function clampNumber(value, min, max, fallback) {
|
|
1110
|
+
const n = Number(value);
|
|
1111
|
+
if (!Number.isFinite(n))
|
|
1112
|
+
return fallback;
|
|
1113
|
+
return Math.max(min, Math.min(max, Math.round(n)));
|
|
1114
|
+
}
|
|
1115
|
+
function truncate(value, maxChars) {
|
|
1116
|
+
const text = String(value);
|
|
1117
|
+
return text.length <= maxChars ? text : text.slice(0, maxChars) + '\n[truncated]';
|
|
1118
|
+
}
|
|
1119
|
+
function safeJsonStringify(value) {
|
|
1120
|
+
try {
|
|
1121
|
+
const text = JSON.stringify(value);
|
|
1122
|
+
return typeof text === 'string' ? text : String(value);
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return String(value);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
async function readJson(request) {
|
|
1129
|
+
try {
|
|
1130
|
+
return await request.json();
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function json(body, status = 200, headers) {
|
|
1137
|
+
const responseHeaders = new Headers(headers);
|
|
1138
|
+
responseHeaders.set('Cache-Control', 'no-store');
|
|
1139
|
+
return Response.json(body, {
|
|
1140
|
+
status,
|
|
1141
|
+
headers: responseHeaders,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
function oauthResultHtml(ok, message, sessionId, headers) {
|
|
1145
|
+
const sessionPath = sessionId && isSessionId(sessionId) ? `/s/${sessionId}/?agent=1` : '/';
|
|
1146
|
+
const safeMessage = escapeHtml(message);
|
|
1147
|
+
const safePath = escapeHtml(sessionPath);
|
|
1148
|
+
const responseHeaders = new Headers(headers);
|
|
1149
|
+
responseHeaders.set('Content-Type', 'text/html; charset=utf-8');
|
|
1150
|
+
responseHeaders.set('Cache-Control', 'no-store');
|
|
1151
|
+
return new Response(`<!DOCTYPE html>
|
|
1152
|
+
<html lang="en"><head>
|
|
1153
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1154
|
+
<title>Nimbus Agent</title>
|
|
1155
|
+
<style>
|
|
1156
|
+
body{margin:0;background:#0d1117;color:#c9d1d9;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;display:grid;place-items:center;min-height:100vh}
|
|
1157
|
+
main{max-width:520px;padding:28px;text-align:center}
|
|
1158
|
+
h1{font-size:20px;color:${ok ? '#3fb950' : '#ff7b72'};margin:0 0 10px}
|
|
1159
|
+
a{color:#58a6ff}
|
|
1160
|
+
</style></head><body><main>
|
|
1161
|
+
<h1>${ok ? 'Connected' : 'Connection failed'}</h1>
|
|
1162
|
+
<p>${safeMessage}</p>
|
|
1163
|
+
<p><a href="${safePath}">Return to Nimbus</a></p>
|
|
1164
|
+
</main>
|
|
1165
|
+
<script>
|
|
1166
|
+
try { if (window.opener) window.opener.postMessage({ type: 'nimbus-agent-oauth', ok: ${ok ? 'true' : 'false'} }, location.origin); } catch {}
|
|
1167
|
+
setTimeout(function(){ try { window.close(); } catch {} }, 700);
|
|
1168
|
+
</script>
|
|
1169
|
+
</body></html>`, {
|
|
1170
|
+
status: ok ? 200 : 400,
|
|
1171
|
+
headers: responseHeaders,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
function encodeState(payload) {
|
|
1175
|
+
return encodeJsonBase64Url(payload);
|
|
1176
|
+
}
|
|
1177
|
+
function decodeState(state) {
|
|
1178
|
+
try {
|
|
1179
|
+
return decodeJsonBase64Url(state);
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function trimTrailingSlash(value) {
|
|
1186
|
+
let end = value.length;
|
|
1187
|
+
while (end > 0 && value[end - 1] === '/')
|
|
1188
|
+
end--;
|
|
1189
|
+
return value.slice(0, end);
|
|
1190
|
+
}
|
|
1191
|
+
function isSessionId(value) {
|
|
1192
|
+
if (value.length < 1 || value.length > 128)
|
|
1193
|
+
return false;
|
|
1194
|
+
for (let i = 0; i < value.length; i++) {
|
|
1195
|
+
const ch = value.charCodeAt(i);
|
|
1196
|
+
const ok = (ch >= 48 && ch <= 57) ||
|
|
1197
|
+
(ch >= 97 && ch <= 122) ||
|
|
1198
|
+
ch === 45;
|
|
1199
|
+
if (!ok)
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
function isTenantSegment(value) {
|
|
1205
|
+
if (value.length < 3 || value.length > 256)
|
|
1206
|
+
return false;
|
|
1207
|
+
for (let i = 0; i < value.length; i++) {
|
|
1208
|
+
const ch = value.charCodeAt(i);
|
|
1209
|
+
const ok = (ch >= 48 && ch <= 57) ||
|
|
1210
|
+
(ch >= 65 && ch <= 90) ||
|
|
1211
|
+
(ch >= 97 && ch <= 122) ||
|
|
1212
|
+
ch === 45 || ch === 46 || ch === 58 || ch === 95;
|
|
1213
|
+
if (!ok)
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
function isNonce(value) {
|
|
1219
|
+
if (value.length < 16 || value.length > 128)
|
|
1220
|
+
return false;
|
|
1221
|
+
for (let i = 0; i < value.length; i++) {
|
|
1222
|
+
const ch = value.charCodeAt(i);
|
|
1223
|
+
const ok = (ch >= 48 && ch <= 57) ||
|
|
1224
|
+
(ch >= 65 && ch <= 90) ||
|
|
1225
|
+
(ch >= 97 && ch <= 122) ||
|
|
1226
|
+
ch === 45 || ch === 95;
|
|
1227
|
+
if (!ok)
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
function isAccountId(value) {
|
|
1233
|
+
if (value.length < 16 || value.length > 64)
|
|
1234
|
+
return false;
|
|
1235
|
+
for (let i = 0; i < value.length; i++) {
|
|
1236
|
+
const ch = value.charCodeAt(i);
|
|
1237
|
+
const ok = (ch >= 48 && ch <= 57) ||
|
|
1238
|
+
(ch >= 65 && ch <= 70) ||
|
|
1239
|
+
(ch >= 97 && ch <= 102);
|
|
1240
|
+
if (!ok)
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
function escapeHtml(value) {
|
|
1246
|
+
return String(value)
|
|
1247
|
+
.replaceAll('&', '&')
|
|
1248
|
+
.replaceAll('<', '<')
|
|
1249
|
+
.replaceAll('>', '>')
|
|
1250
|
+
.replaceAll('"', '"')
|
|
1251
|
+
.replaceAll("'", ''');
|
|
1252
|
+
}
|