@yeaft/webchat-agent 1.0.415 → 1.0.417
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/browser-runtime/chromium.js +192 -0
- package/browser-runtime/cli.js +1 -1
- package/browser-runtime/extension/manifest.json +3 -0
- package/browser-runtime/extension/offscreen.js +201 -25
- package/browser-runtime/extension/popup.js +4 -1
- package/browser-runtime/extension/service-worker.js +47 -7
- package/browser-runtime/extension.js +1 -1
- package/browser-runtime/index.js +3 -0
- package/browser-runtime/local-bridge.js +194 -0
- package/browser-runtime/messages.js +73 -0
- package/browser-runtime/service.js +549 -26
- package/connection/index.js +2 -0
- package/connection/message-router.js +2 -0
- package/index.js +2 -0
- package/local-runtime/agent/container-manager.js +189 -0
- package/local-runtime/server/.env.example +10 -0
- package/local-runtime/server/browser-runtime-routes.js +265 -0
- package/local-runtime/server/client-protocol.js +4 -0
- package/local-runtime/server/config.js +28 -0
- package/local-runtime/server/handlers/agent-browser.js +308 -0
- package/local-runtime/server/handlers/client-browser.js +290 -0
- package/local-runtime/server/ws-agent.js +8 -1
- package/local-runtime/server/ws-client.js +33 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +152 -95
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/scripts/prepare-local-runtime.js +39 -20
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
|
|
6
|
+
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
7
|
+
|
|
8
|
+
export class ContainerAgentError extends Error {
|
|
9
|
+
constructor(code, message = code) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ContainerAgentError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeContainerAgentName(name) {
|
|
17
|
+
const value = String(name || '').trim();
|
|
18
|
+
if (!NAME_PATTERN.test(value)) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_NAME');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function containerNameForAgent(name) {
|
|
23
|
+
return `yeaft-agent-${normalizeContainerAgentName(name)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runDocker(args, { spawnImpl = spawn, allowFailure = false, stdout = 'pipe' } = {}) {
|
|
27
|
+
return new Promise((resolvePromise, reject) => {
|
|
28
|
+
const child = spawnImpl('docker', args, {
|
|
29
|
+
stdio: ['ignore', stdout, 'pipe'],
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
const output = [];
|
|
33
|
+
const errors = [];
|
|
34
|
+
child.stdout?.on('data', chunk => output.push(chunk));
|
|
35
|
+
child.stderr?.on('data', chunk => errors.push(chunk));
|
|
36
|
+
child.once('error', error => reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_UNAVAILABLE', error.message)));
|
|
37
|
+
child.once('close', code => {
|
|
38
|
+
const result = {
|
|
39
|
+
code: code ?? 1,
|
|
40
|
+
stdout: Buffer.concat(output).toString('utf8').trim(),
|
|
41
|
+
stderr: Buffer.concat(errors).toString('utf8').trim(),
|
|
42
|
+
};
|
|
43
|
+
if (result.code === 0 || allowFailure) resolvePromise(result);
|
|
44
|
+
else reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || `docker ${args[0]} failed`));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeAgentSecretFile(path, secret) {
|
|
50
|
+
const value = String(secret || '').trim();
|
|
51
|
+
if (!value) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
|
|
52
|
+
const absolute = resolve(path);
|
|
53
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
54
|
+
await writeFile(absolute, `${value}\n`, { mode: 0o600 });
|
|
55
|
+
await chmod(absolute, 0o600);
|
|
56
|
+
return absolute;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildCreateArgs({
|
|
60
|
+
name,
|
|
61
|
+
serverUrl,
|
|
62
|
+
secretFile,
|
|
63
|
+
image = DEFAULT_AGENT_IMAGE,
|
|
64
|
+
dataVolume,
|
|
65
|
+
workspaceVolume,
|
|
66
|
+
restart = 'unless-stopped',
|
|
67
|
+
}) {
|
|
68
|
+
const agentName = normalizeContainerAgentName(name);
|
|
69
|
+
if (!String(serverUrl || '').match(/^wss?:\/\//)) {
|
|
70
|
+
throw new ContainerAgentError('CONTAINER_AGENT_INVALID_SERVER_URL');
|
|
71
|
+
}
|
|
72
|
+
if (!secretFile) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
|
|
73
|
+
const containerName = containerNameForAgent(agentName);
|
|
74
|
+
const safeImage = String(image || '').trim();
|
|
75
|
+
if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
|
|
76
|
+
return [
|
|
77
|
+
'create', '--name', containerName,
|
|
78
|
+
'--label', 'io.yeaft.container-agent=true',
|
|
79
|
+
'--label', `io.yeaft.agent-name=${agentName}`,
|
|
80
|
+
'--restart', restart,
|
|
81
|
+
'--init',
|
|
82
|
+
'--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft`,
|
|
83
|
+
'--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace`,
|
|
84
|
+
'--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
|
|
85
|
+
'--env', `SERVER_URL=${serverUrl}`,
|
|
86
|
+
'--env', `AGENT_NAME=${agentName}`,
|
|
87
|
+
'--env', 'AGENT_SECRET_FILE=/run/yeaft-host-secret',
|
|
88
|
+
'--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
|
|
89
|
+
'--env', 'WORK_DIR=/workspace',
|
|
90
|
+
safeImage,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify that the Docker client can reach a daemon before the Server advertises
|
|
96
|
+
* container Agent lifecycle support.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} options runDocker overrides used by tests and alternate runtimes
|
|
99
|
+
* @returns {Promise<{serverVersion: string|null}>}
|
|
100
|
+
*/
|
|
101
|
+
export async function checkContainerAgentRuntime(options = {}) {
|
|
102
|
+
const result = await runDocker(['version', '--format', '{{.Server.Version}}'], options);
|
|
103
|
+
return { serverVersion: result.stdout || null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function inspectContainerAgent(name, options = {}) {
|
|
107
|
+
const result = await runDocker([
|
|
108
|
+
'inspect', '--format', '{{json .State}}', containerNameForAgent(name),
|
|
109
|
+
], { ...options, allowFailure: true });
|
|
110
|
+
if (result.code !== 0) {
|
|
111
|
+
if (/no such (object|container)/i.test(result.stderr)) {
|
|
112
|
+
return { exists: false, status: 'absent', running: false };
|
|
113
|
+
}
|
|
114
|
+
throw new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || 'docker inspect failed');
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const state = JSON.parse(result.stdout);
|
|
118
|
+
return {
|
|
119
|
+
exists: true,
|
|
120
|
+
status: state.Status || 'unknown',
|
|
121
|
+
running: state.Running === true,
|
|
122
|
+
startedAt: state.StartedAt || null,
|
|
123
|
+
error: state.Error || null,
|
|
124
|
+
};
|
|
125
|
+
} catch {
|
|
126
|
+
throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DOCKER_RESPONSE');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function createContainerAgent(options, runtime = {}) {
|
|
131
|
+
const current = await inspectContainerAgent(options.name, runtime);
|
|
132
|
+
if (current.exists) throw new ContainerAgentError('CONTAINER_AGENT_ALREADY_EXISTS');
|
|
133
|
+
const containerName = containerNameForAgent(options.name);
|
|
134
|
+
await runDocker(buildCreateArgs(options), runtime);
|
|
135
|
+
try {
|
|
136
|
+
await runDocker(['start', containerName], runtime);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
await runDocker(['rm', '-f', containerName], { ...runtime, allowFailure: true });
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return inspectContainerAgent(options.name, runtime);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function startContainerAgent(name, runtime = {}) {
|
|
145
|
+
await runDocker(['start', containerNameForAgent(name)], runtime);
|
|
146
|
+
return inspectContainerAgent(name, runtime);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function stopContainerAgent(name, runtime = {}) {
|
|
150
|
+
await runDocker(['stop', '--time', '10', containerNameForAgent(name)], runtime);
|
|
151
|
+
return inspectContainerAgent(name, runtime);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isMissingDockerVolume(stderr) {
|
|
155
|
+
return /no such volume/i.test(String(stderr || ''));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function removeContainerAgent(name, { removeVolumes = true, ...runtime } = {}) {
|
|
159
|
+
const containerName = containerNameForAgent(name);
|
|
160
|
+
const current = await inspectContainerAgent(name, runtime);
|
|
161
|
+
if (current.exists) await runDocker(['rm', '-f', containerName], runtime);
|
|
162
|
+
if (removeVolumes) {
|
|
163
|
+
for (const volume of [`${containerName}-data`, `${containerName}-workspace`]) {
|
|
164
|
+
const result = await runDocker(['volume', 'rm', volume], {
|
|
165
|
+
...runtime,
|
|
166
|
+
allowFailure: true,
|
|
167
|
+
});
|
|
168
|
+
if (result.code !== 0 && !isMissingDockerVolume(result.stderr)) {
|
|
169
|
+
throw new ContainerAgentError(
|
|
170
|
+
'CONTAINER_AGENT_DOCKER_FAILED',
|
|
171
|
+
result.stderr || `docker volume rm ${volume} failed`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { exists: false, status: 'absent', running: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function logsContainerAgent(name, { follow = false, ...runtime } = {}) {
|
|
180
|
+
const args = ['logs'];
|
|
181
|
+
if (follow) args.push('--follow');
|
|
182
|
+
args.push(containerNameForAgent(name));
|
|
183
|
+
return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function readSecretInput({ secret, secretFile }) {
|
|
187
|
+
if (secretFile) return (await readFile(resolve(secretFile), 'utf8')).trim();
|
|
188
|
+
return String(secret || '').trim();
|
|
189
|
+
}
|
|
@@ -44,6 +44,16 @@ EMAIL_CODE_EXPIRES_IN=300000
|
|
|
44
44
|
# Agents must provide this secret to connect
|
|
45
45
|
AGENT_SECRET=agent-shared-secret
|
|
46
46
|
|
|
47
|
+
# Browser Runtime (disabled by default)
|
|
48
|
+
# Production deployments should configure endpoint-scoped TURN credentials.
|
|
49
|
+
# BROWSER_RUNTIME_ENABLED=false
|
|
50
|
+
# BROWSER_STUN_URLS=stun:stun.example.com:3478
|
|
51
|
+
# BROWSER_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:443?transport=tcp
|
|
52
|
+
# BROWSER_TURN_SECRET=replace-with-coturn-rest-api-secret
|
|
53
|
+
# BROWSER_TURN_TTL_SECONDS=600
|
|
54
|
+
# BROWSER_ICE_TRANSPORT_POLICY=all
|
|
55
|
+
# BROWSER_ROUTE_TTL_MS=900000
|
|
56
|
+
|
|
47
57
|
# Server-managed Sandbox (disabled by default)
|
|
48
58
|
# Docker socket access is equivalent to Host root. For Docker Compose, use the
|
|
49
59
|
# explicit docker-compose.sandbox.yml override documented in docs/operations/sandbox-agent.md.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
2
|
+
import { CONFIG } from './config.js';
|
|
3
|
+
import { agents, webClients } from './context.js';
|
|
4
|
+
|
|
5
|
+
const MAX_ROUTES = 2048;
|
|
6
|
+
const MAX_PEERS = 4096;
|
|
7
|
+
const MAX_CREATE_REQUESTS = 4096;
|
|
8
|
+
const CREATE_REQUEST_TTL_MS = 10 * 60_000;
|
|
9
|
+
|
|
10
|
+
export const browserRoutes = new Map();
|
|
11
|
+
export const browserPeers = new Map();
|
|
12
|
+
const browserRequests = new Map();
|
|
13
|
+
|
|
14
|
+
function key(agentId, browserSessionId) {
|
|
15
|
+
return `${String(agentId || '')}\0${String(browserSessionId || '')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createKey(agentId, connectionId, requestId) {
|
|
19
|
+
return `${String(agentId || '')}\0${String(connectionId || '')}\0${String(requestId || '')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scopeUsername({ ownerUserId, agentId, browserSessionId, peerId, connectionGeneration, endpointRole, expiresAt }) {
|
|
23
|
+
const scope = Buffer.from(JSON.stringify({
|
|
24
|
+
ownerUserId,
|
|
25
|
+
agentId,
|
|
26
|
+
browserSessionId,
|
|
27
|
+
peerId,
|
|
28
|
+
connectionGeneration,
|
|
29
|
+
endpointRole,
|
|
30
|
+
credentialId: randomUUID(),
|
|
31
|
+
})).toString('base64url');
|
|
32
|
+
return `${Math.floor(expiresAt / 1000)}:${scope}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mintBrowserIceServers(scope, config = CONFIG.browserRuntime) {
|
|
36
|
+
const stunServers = config.stunUrls.length > 0 ? [{ urls: [...config.stunUrls] }] : [];
|
|
37
|
+
if (config.turnUrls.length === 0) return stunServers;
|
|
38
|
+
const expiresAt = Date.now() + config.credentialTtlSeconds * 1000;
|
|
39
|
+
const username = scopeUsername({ ...scope, expiresAt });
|
|
40
|
+
const credential = createHmac('sha1', config.turnSecret).update(username).digest('base64');
|
|
41
|
+
return [
|
|
42
|
+
...stunServers,
|
|
43
|
+
{ urls: [...config.turnUrls], username, credential, expiresAt },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function browserServerIdentity(client) {
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
ownerUserId: client.userId,
|
|
50
|
+
clientId: client.id,
|
|
51
|
+
webConnectionId: client.connectionId,
|
|
52
|
+
webConnectionGeneration: client.connectionGeneration,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function pruneBrowserRuntimeRoutes(now = Date.now()) {
|
|
57
|
+
for (const [requestKey, request] of browserRequests) {
|
|
58
|
+
if (request.expiresAt <= now || !webClients.has(request.clientId)) browserRequests.delete(requestKey);
|
|
59
|
+
}
|
|
60
|
+
for (const [peerId, peer] of browserPeers) {
|
|
61
|
+
const client = webClients.get(peer.clientId);
|
|
62
|
+
if ((peer.expiresAt != null && peer.expiresAt <= now)
|
|
63
|
+
|| !client || client.connectionId !== peer.webConnectionId) {
|
|
64
|
+
browserPeers.delete(peerId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerBrowserCreateRequest({ agentId, client, requestId, digest }) {
|
|
70
|
+
pruneBrowserRuntimeRoutes();
|
|
71
|
+
const requestKey = createKey(agentId, client.connectionId, requestId);
|
|
72
|
+
const existing = browserRequests.get(requestKey);
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (existing.digest !== digest) return { conflict: true, request: existing };
|
|
75
|
+
return { duplicate: true, request: existing };
|
|
76
|
+
}
|
|
77
|
+
if (browserRequests.size >= MAX_CREATE_REQUESTS) return { capacity: true, request: null };
|
|
78
|
+
const request = {
|
|
79
|
+
agentId,
|
|
80
|
+
requestId,
|
|
81
|
+
serverRequestId: randomUUID(),
|
|
82
|
+
clientId: client.id,
|
|
83
|
+
ownerUserId: client.userId,
|
|
84
|
+
webConnectionId: client.connectionId,
|
|
85
|
+
webConnectionGeneration: client.connectionGeneration,
|
|
86
|
+
digest,
|
|
87
|
+
state: 'pending',
|
|
88
|
+
response: null,
|
|
89
|
+
expiresAt: Date.now() + CREATE_REQUEST_TTL_MS,
|
|
90
|
+
};
|
|
91
|
+
browserRequests.set(requestKey, request);
|
|
92
|
+
return { request };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function completeBrowserRequest(agentId, msg, { consume = false } = {}) {
|
|
96
|
+
for (const [requestKey, request] of browserRequests) {
|
|
97
|
+
if (request.agentId !== agentId || request.serverRequestId !== msg.requestId || request.state !== 'pending') continue;
|
|
98
|
+
request.state = msg.type.endsWith('_error') ? 'failed' : 'completed';
|
|
99
|
+
request.response = { ...msg, requestId: request.requestId };
|
|
100
|
+
request.expiresAt = Date.now() + CREATE_REQUEST_TTL_MS;
|
|
101
|
+
if (consume) browserRequests.delete(requestKey);
|
|
102
|
+
return request;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerBrowserRequest({ agentId, client, requestId, kind, browserSessionId = null }) {
|
|
108
|
+
pruneBrowserRuntimeRoutes();
|
|
109
|
+
const requestKey = createKey(agentId, client.connectionId, requestId);
|
|
110
|
+
if (browserRequests.has(requestKey) || browserRequests.size >= MAX_CREATE_REQUESTS) return null;
|
|
111
|
+
const request = {
|
|
112
|
+
agentId,
|
|
113
|
+
requestId,
|
|
114
|
+
serverRequestId: randomUUID(),
|
|
115
|
+
kind,
|
|
116
|
+
browserSessionId,
|
|
117
|
+
clientId: client.id,
|
|
118
|
+
ownerUserId: client.userId,
|
|
119
|
+
webConnectionId: client.connectionId,
|
|
120
|
+
webConnectionGeneration: client.connectionGeneration,
|
|
121
|
+
digest: '',
|
|
122
|
+
state: 'pending',
|
|
123
|
+
response: null,
|
|
124
|
+
expiresAt: Date.now() + CREATE_REQUEST_TTL_MS,
|
|
125
|
+
};
|
|
126
|
+
browserRequests.set(requestKey, request);
|
|
127
|
+
return request;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function installBrowserRoute({ agentId, ownerUserId, msg }) {
|
|
131
|
+
if (!msg.browserSessionId) return null;
|
|
132
|
+
const routeKey = key(agentId, msg.browserSessionId);
|
|
133
|
+
const existing = browserRoutes.get(routeKey);
|
|
134
|
+
if (existing && existing.ownerUserId !== ownerUserId) return null;
|
|
135
|
+
if (!existing && browserRoutes.size >= MAX_ROUTES) return null;
|
|
136
|
+
const route = {
|
|
137
|
+
ownerUserId,
|
|
138
|
+
agentId,
|
|
139
|
+
browserSessionId: msg.browserSessionId,
|
|
140
|
+
revision: Number(msg.revision) || 1,
|
|
141
|
+
state: msg.state || 'ready',
|
|
142
|
+
updatedAt: Date.now(),
|
|
143
|
+
};
|
|
144
|
+
browserRoutes.set(routeKey, route);
|
|
145
|
+
return route;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getBrowserRoute(agentId, browserSessionId) {
|
|
149
|
+
return browserRoutes.get(key(agentId, browserSessionId)) || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function deleteBrowserRoute(agentId, browserSessionId) {
|
|
153
|
+
const routeKey = key(agentId, browserSessionId);
|
|
154
|
+
const deleted = browserRoutes.delete(routeKey);
|
|
155
|
+
for (const [peerId, peer] of browserPeers) {
|
|
156
|
+
if (peer.agentId === agentId && peer.browserSessionId === browserSessionId) browserPeers.delete(peerId);
|
|
157
|
+
}
|
|
158
|
+
return deleted;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function reserveBrowserPeer({ agentId, browserSessionId, client, requestId, connectionGeneration, role = 'viewer' }) {
|
|
162
|
+
pruneBrowserRuntimeRoutes();
|
|
163
|
+
const route = getBrowserRoute(agentId, browserSessionId);
|
|
164
|
+
if (!route || route.ownerUserId !== client.userId || route.state !== 'ready') return { error: 'browser_session_not_found' };
|
|
165
|
+
if (!Number.isSafeInteger(connectionGeneration) || connectionGeneration <= 0) return { error: 'browser_generation_invalid' };
|
|
166
|
+
const duplicate = [...browserPeers.values()].find(peer => (
|
|
167
|
+
peer.agentId === agentId
|
|
168
|
+
&& peer.browserSessionId === browserSessionId
|
|
169
|
+
&& peer.clientId === client.id
|
|
170
|
+
&& peer.webConnectionId === client.connectionId
|
|
171
|
+
&& peer.requestId === requestId
|
|
172
|
+
&& peer.connectionGeneration === connectionGeneration
|
|
173
|
+
));
|
|
174
|
+
if (duplicate) return { peer: duplicate, duplicate: true };
|
|
175
|
+
if (browserPeers.size >= MAX_PEERS) return { error: 'browser_peer_capacity' };
|
|
176
|
+
const peerId = randomUUID();
|
|
177
|
+
const expiresAt = Date.now() + CONFIG.browserRuntime.routeTtlMs;
|
|
178
|
+
const peer = {
|
|
179
|
+
peerId,
|
|
180
|
+
requestId,
|
|
181
|
+
ownerUserId: client.userId,
|
|
182
|
+
agentId,
|
|
183
|
+
browserSessionId,
|
|
184
|
+
clientId: client.id,
|
|
185
|
+
webConnectionId: client.connectionId,
|
|
186
|
+
webConnectionGeneration: client.connectionGeneration,
|
|
187
|
+
connectionGeneration,
|
|
188
|
+
role: role === 'interactive' ? 'interactive' : 'viewer',
|
|
189
|
+
state: 'preparing',
|
|
190
|
+
pendingOffer: null,
|
|
191
|
+
pendingCandidates: [],
|
|
192
|
+
agentCandidateCount: 0,
|
|
193
|
+
webCandidateCount: 0,
|
|
194
|
+
iceTransportPolicy: CONFIG.browserRuntime.iceTransportPolicy,
|
|
195
|
+
expiresAt,
|
|
196
|
+
};
|
|
197
|
+
browserPeers.set(peerId, peer);
|
|
198
|
+
return { peer };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getBrowserPeer(peerId) {
|
|
202
|
+
pruneBrowserRuntimeRoutes();
|
|
203
|
+
return browserPeers.get(String(peerId || '')) || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function browserPeerMatchesClient(peer, client, message = {}) {
|
|
207
|
+
return !!peer && !!client
|
|
208
|
+
&& peer.ownerUserId === client.userId
|
|
209
|
+
&& peer.clientId === client.id
|
|
210
|
+
&& peer.webConnectionId === client.connectionId
|
|
211
|
+
&& peer.webConnectionGeneration === client.connectionGeneration
|
|
212
|
+
&& peer.agentId === message.agentId
|
|
213
|
+
&& peer.browserSessionId === message.browserSessionId
|
|
214
|
+
&& peer.connectionGeneration === Number(message.connectionGeneration);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function deleteBrowserPeer(peerId) {
|
|
218
|
+
return browserPeers.delete(String(peerId || ''));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function clearBrowserRuntimeForClient(client) {
|
|
222
|
+
if (!client) return [];
|
|
223
|
+
const peers = [];
|
|
224
|
+
for (const [peerId, peer] of browserPeers) {
|
|
225
|
+
if (peer.clientId === client.id && peer.webConnectionId === client.connectionId) {
|
|
226
|
+
browserPeers.delete(peerId);
|
|
227
|
+
peers.push(peer);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const [requestKey, request] of browserRequests) {
|
|
231
|
+
if (request.clientId === client.id && request.webConnectionId === client.connectionId) {
|
|
232
|
+
browserRequests.delete(requestKey);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return peers;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function clearBrowserRuntimeForAgent(agentId) {
|
|
239
|
+
for (const [routeKey, route] of browserRoutes) {
|
|
240
|
+
if (route.agentId === agentId) browserRoutes.delete(routeKey);
|
|
241
|
+
}
|
|
242
|
+
for (const [peerId, peer] of browserPeers) {
|
|
243
|
+
if (peer.agentId === agentId) browserPeers.delete(peerId);
|
|
244
|
+
}
|
|
245
|
+
for (const [requestKey, request] of browserRequests) {
|
|
246
|
+
if (request.agentId === agentId) browserRequests.delete(requestKey);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function browserClientForPeer(peer) {
|
|
251
|
+
const client = webClients.get(peer?.clientId);
|
|
252
|
+
if (!client || client.connectionId !== peer.webConnectionId
|
|
253
|
+
|| client.connectionGeneration !== peer.webConnectionGeneration) return null;
|
|
254
|
+
return client;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function browserAgentForPeer(peer) {
|
|
258
|
+
return agents.get(peer?.agentId) || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function __testResetBrowserRuntimeRoutes() {
|
|
262
|
+
browserRoutes.clear();
|
|
263
|
+
browserPeers.clear();
|
|
264
|
+
browserRequests.clear();
|
|
265
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const WORKBENCH_ROUTE_PROTOCOL = 1;
|
|
2
|
+
export const BROWSER_RUNTIME_PROTOCOL = 1;
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Apply the explicit browser protocol hello to one Server-owned client record.
|
|
@@ -10,5 +11,8 @@ export function applyClientHello(client, message) {
|
|
|
10
11
|
if (message.workbenchRouteProtocol === WORKBENCH_ROUTE_PROTOCOL) {
|
|
11
12
|
client.workbenchRouteProtocol = WORKBENCH_ROUTE_PROTOCOL;
|
|
12
13
|
}
|
|
14
|
+
if (message.browserRuntimeProtocol === BROWSER_RUNTIME_PROTOCOL) {
|
|
15
|
+
client.browserRuntimeProtocol = BROWSER_RUNTIME_PROTOCOL;
|
|
16
|
+
}
|
|
13
17
|
return true;
|
|
14
18
|
}
|
|
@@ -80,6 +80,10 @@ function loadUsers() {
|
|
|
80
80
|
const DEFAULT_JWT_SECRET = 'default-secret-change-in-production';
|
|
81
81
|
const DEFAULT_AGENT_SECRET = 'agent-shared-secret';
|
|
82
82
|
|
|
83
|
+
function commaList(value) {
|
|
84
|
+
return String(value || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
export const CONFIG = {
|
|
84
88
|
// Server settings
|
|
85
89
|
port: parseInt(process.env.PORT, 10) || 3456,
|
|
@@ -115,6 +119,19 @@ export const CONFIG = {
|
|
|
115
119
|
// Agent authentication (global fallback — per-user agent_secret is preferred)
|
|
116
120
|
agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
|
|
117
121
|
|
|
122
|
+
// Browser Runtime stays fail-closed until the Server rollout gate is enabled.
|
|
123
|
+
// TURN credentials use the standard time-limited HMAC username scheme; Web and
|
|
124
|
+
// Agent endpoints receive separately scoped usernames from the route ledger.
|
|
125
|
+
browserRuntime: {
|
|
126
|
+
enabled: process.env.BROWSER_RUNTIME_ENABLED === 'true',
|
|
127
|
+
iceTransportPolicy: process.env.BROWSER_ICE_TRANSPORT_POLICY === 'relay' ? 'relay' : 'all',
|
|
128
|
+
stunUrls: commaList(process.env.BROWSER_STUN_URLS),
|
|
129
|
+
turnUrls: commaList(process.env.BROWSER_TURN_URLS),
|
|
130
|
+
turnSecret: process.env.BROWSER_TURN_SECRET || '',
|
|
131
|
+
credentialTtlSeconds: Math.min(3600, Math.max(60, parseInt(process.env.BROWSER_TURN_TTL_SECONDS, 10) || 600)),
|
|
132
|
+
routeTtlMs: Math.min(60 * 60_000, Math.max(60_000, parseInt(process.env.BROWSER_ROUTE_TTL_MS, 10) || 15 * 60_000)),
|
|
133
|
+
},
|
|
134
|
+
|
|
118
135
|
// A Sandbox is an ordinary yeaft-agent container managed by this Server's Docker daemon.
|
|
119
136
|
// The Server controls only the container lifecycle; Agent behavior stays on the existing wire.
|
|
120
137
|
sandbox: {
|
|
@@ -273,6 +290,17 @@ export function validateProductionConfig() {
|
|
|
273
290
|
if (CONFIG.sandbox.enabled && !/^wss?:\/\//.test(CONFIG.sandbox.serverUrl)) {
|
|
274
291
|
errors.push('SANDBOX_SERVER_URL must be the ws:// or wss:// URL that container Agents use to connect');
|
|
275
292
|
}
|
|
293
|
+
if (CONFIG.browserRuntime.enabled) {
|
|
294
|
+
const invalidIceUrl = [...CONFIG.browserRuntime.stunUrls, ...CONFIG.browserRuntime.turnUrls]
|
|
295
|
+
.find(url => !/^(?:stun|stuns|turn|turns):/i.test(url));
|
|
296
|
+
if (invalidIceUrl) errors.push(`Invalid Browser Runtime ICE URL: ${invalidIceUrl}`);
|
|
297
|
+
if (CONFIG.browserRuntime.turnUrls.length > 0 && !CONFIG.browserRuntime.turnSecret) {
|
|
298
|
+
errors.push('BROWSER_TURN_SECRET is required when BROWSER_TURN_URLS is configured');
|
|
299
|
+
}
|
|
300
|
+
if (CONFIG.browserRuntime.iceTransportPolicy === 'relay' && CONFIG.browserRuntime.turnUrls.length === 0) {
|
|
301
|
+
errors.push('BROWSER_TURN_URLS is required when BROWSER_ICE_TRANSPORT_POLICY=relay');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
276
304
|
|
|
277
305
|
// Check that at least one user with a password exists (in DB or config)
|
|
278
306
|
// Only warn (don't block startup) — allows first-time setup via create-user.js
|