@ottocode/server 0.1.230 → 0.1.231
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/package.json +3 -3
- package/src/index.ts +1 -2
- package/src/openapi/paths/config.ts +2 -0
- package/src/openapi/schemas.ts +1 -0
- package/src/routes/config/defaults.ts +4 -0
- package/src/routes/config/main.ts +6 -0
- package/src/routes/config/models.ts +2 -0
- package/src/routes/mcp.ts +62 -58
- package/src/routes/session-stream.ts +46 -45
- package/src/routes/terminals.ts +15 -3
- package/src/routes/tunnel.ts +7 -3
- package/src/runtime/message/service.ts +8 -1
- package/src/runtime/tools/guards.ts +52 -4
- package/src/tools/adapter.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.231",
|
|
4
4
|
"description": "HTTP API server for ottocode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"typecheck": "tsc --noEmit"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@ottocode/sdk": "0.1.
|
|
53
|
-
"@ottocode/database": "0.1.
|
|
52
|
+
"@ottocode/sdk": "0.1.231",
|
|
53
|
+
"@ottocode/database": "0.1.231",
|
|
54
54
|
"drizzle-orm": "^0.44.5",
|
|
55
55
|
"hono": "^4.9.9",
|
|
56
56
|
"zod": "^4.3.6"
|
package/src/index.ts
CHANGED
|
@@ -108,7 +108,6 @@ export type StandaloneAppConfig = {
|
|
|
108
108
|
export function createStandaloneApp(_config?: StandaloneAppConfig) {
|
|
109
109
|
const honoApp = new Hono();
|
|
110
110
|
|
|
111
|
-
// Enable CORS for localhost and local network access
|
|
112
111
|
honoApp.use(
|
|
113
112
|
'*',
|
|
114
113
|
cors({
|
|
@@ -192,6 +191,7 @@ export type EmbeddedAppConfig = {
|
|
|
192
191
|
model?: string;
|
|
193
192
|
agent?: string;
|
|
194
193
|
toolApproval?: 'auto' | 'dangerous' | 'all';
|
|
194
|
+
fullWidthContent?: boolean;
|
|
195
195
|
};
|
|
196
196
|
/** Additional CORS origins for proxies/Tailscale (e.g., ['https://myapp.ts.net', 'https://example.com']) */
|
|
197
197
|
corsOrigins?: string[];
|
|
@@ -214,7 +214,6 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
|
|
|
214
214
|
await next();
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
// Enable CORS for localhost and local network access
|
|
218
217
|
honoApp.use(
|
|
219
218
|
'*',
|
|
220
219
|
cors({
|
|
@@ -178,6 +178,7 @@ export const configPaths = {
|
|
|
178
178
|
agent: { type: 'string' },
|
|
179
179
|
provider: { type: 'string' },
|
|
180
180
|
model: { type: 'string' },
|
|
181
|
+
fullWidthContent: { type: 'boolean' },
|
|
181
182
|
reasoningText: { type: 'boolean' },
|
|
182
183
|
reasoningLevel: {
|
|
183
184
|
type: 'string',
|
|
@@ -208,6 +209,7 @@ export const configPaths = {
|
|
|
208
209
|
agent: { type: 'string' },
|
|
209
210
|
provider: { type: 'string' },
|
|
210
211
|
model: { type: 'string' },
|
|
212
|
+
fullWidthContent: { type: 'boolean' },
|
|
211
213
|
reasoningText: { type: 'boolean' },
|
|
212
214
|
reasoningLevel: {
|
|
213
215
|
type: 'string',
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -196,6 +196,7 @@ export const schemas = {
|
|
|
196
196
|
agent: { type: 'string' },
|
|
197
197
|
provider: { $ref: '#/components/schemas/Provider' },
|
|
198
198
|
model: { type: 'string' },
|
|
199
|
+
fullWidthContent: { type: 'boolean' },
|
|
199
200
|
reasoningText: { type: 'boolean' },
|
|
200
201
|
reasoningLevel: {
|
|
201
202
|
type: 'string',
|
|
@@ -21,6 +21,7 @@ export function registerDefaultsRoute(app: Hono) {
|
|
|
21
21
|
reasoningText?: boolean;
|
|
22
22
|
reasoningLevel?: ReasoningLevel;
|
|
23
23
|
theme?: string;
|
|
24
|
+
fullWidthContent?: boolean;
|
|
24
25
|
scope?: 'global' | 'local';
|
|
25
26
|
}>();
|
|
26
27
|
|
|
@@ -34,6 +35,7 @@ export function registerDefaultsRoute(app: Hono) {
|
|
|
34
35
|
reasoningText: boolean;
|
|
35
36
|
reasoningLevel: ReasoningLevel;
|
|
36
37
|
theme: string;
|
|
38
|
+
fullWidthContent: boolean;
|
|
37
39
|
}> = {};
|
|
38
40
|
|
|
39
41
|
if (body.agent) updates.agent = body.agent;
|
|
@@ -45,6 +47,8 @@ export function registerDefaultsRoute(app: Hono) {
|
|
|
45
47
|
updates.reasoningText = body.reasoningText;
|
|
46
48
|
if (body.reasoningLevel) updates.reasoningLevel = body.reasoningLevel;
|
|
47
49
|
if (body.theme) updates.theme = body.theme;
|
|
50
|
+
if (body.fullWidthContent !== undefined)
|
|
51
|
+
updates.fullWidthContent = body.fullWidthContent;
|
|
48
52
|
|
|
49
53
|
await setConfig(scope, updates, projectRoot);
|
|
50
54
|
|
|
@@ -63,6 +63,12 @@ export function registerMainConfigRoute(app: Hono) {
|
|
|
63
63
|
reasoningText: cfg.defaults.reasoningText ?? true,
|
|
64
64
|
reasoningLevel: cfg.defaults.reasoningLevel ?? 'high',
|
|
65
65
|
theme: cfg.defaults.theme,
|
|
66
|
+
fullWidthContent:
|
|
67
|
+
getDefault(
|
|
68
|
+
undefined,
|
|
69
|
+
embeddedConfig?.defaults?.fullWidthContent,
|
|
70
|
+
cfg.defaults.fullWidthContent,
|
|
71
|
+
) ?? false,
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
return c.json({
|
|
@@ -138,6 +138,7 @@ export function registerModelsRoutes(app: Hono) {
|
|
|
138
138
|
reasoningText: m.reasoningText,
|
|
139
139
|
vision: m.modalities?.input?.includes('image') ?? false,
|
|
140
140
|
attachment: m.attachment ?? false,
|
|
141
|
+
free: m.cost?.input === 0 && m.cost?.output === 0,
|
|
141
142
|
})),
|
|
142
143
|
default: getDefault(
|
|
143
144
|
embeddedConfig?.model,
|
|
@@ -205,6 +206,7 @@ export function registerModelsRoutes(app: Hono) {
|
|
|
205
206
|
reasoningText: m.reasoningText,
|
|
206
207
|
vision: m.modalities?.input?.includes('image') ?? false,
|
|
207
208
|
attachment: m.attachment ?? false,
|
|
209
|
+
free: m.cost?.input === 0 && m.cost?.output === 0,
|
|
208
210
|
})),
|
|
209
211
|
};
|
|
210
212
|
}
|
package/src/routes/mcp.ts
CHANGED
|
@@ -1,36 +1,21 @@
|
|
|
1
1
|
import type { Hono } from 'hono';
|
|
2
2
|
import {
|
|
3
|
+
COPILOT_MCP_SCOPE,
|
|
3
4
|
getMCPManager,
|
|
5
|
+
getCopilotMCPOAuthKey,
|
|
6
|
+
getStoredCopilotMCPToken,
|
|
4
7
|
initializeMCP,
|
|
8
|
+
isGitHubCopilotUrl,
|
|
5
9
|
loadMCPConfig,
|
|
6
10
|
getGlobalConfigDir,
|
|
7
11
|
MCPClientWrapper,
|
|
12
|
+
OAuthCredentialStore,
|
|
8
13
|
addMCPServerToConfig,
|
|
9
14
|
removeMCPServerFromConfig,
|
|
10
15
|
} from '@ottocode/sdk';
|
|
11
|
-
import {
|
|
12
|
-
authorizeCopilot,
|
|
13
|
-
pollForCopilotTokenOnce,
|
|
14
|
-
getAuth,
|
|
15
|
-
setAuth,
|
|
16
|
-
} from '@ottocode/sdk';
|
|
16
|
+
import { authorizeCopilot, pollForCopilotTokenOnce } from '@ottocode/sdk';
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
'api.githubcopilot.com',
|
|
20
|
-
'copilot-proxy.githubusercontent.com',
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
function isGitHubCopilotUrl(url?: string): boolean {
|
|
24
|
-
if (!url) return false;
|
|
25
|
-
try {
|
|
26
|
-
const parsed = new URL(url);
|
|
27
|
-
return GITHUB_COPILOT_HOSTS.some(
|
|
28
|
-
(h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
|
|
29
|
-
);
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
18
|
+
const copilotMCPOAuthStore = new OAuthCredentialStore();
|
|
34
19
|
|
|
35
20
|
const copilotMCPSessions = new Map<
|
|
36
21
|
string,
|
|
@@ -184,13 +169,14 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
184
169
|
);
|
|
185
170
|
|
|
186
171
|
if (isGitHubCopilotUrl(serverConfig.url) && !status?.connected) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
172
|
+
const existingAuth = await getStoredCopilotMCPToken(
|
|
173
|
+
copilotMCPOAuthStore,
|
|
174
|
+
name,
|
|
175
|
+
serverConfig.scope ?? 'global',
|
|
176
|
+
projectRoot,
|
|
177
|
+
);
|
|
192
178
|
|
|
193
|
-
if (!existingAuth || existingAuth.
|
|
179
|
+
if (!existingAuth.token || existingAuth.needsReauth) {
|
|
194
180
|
const deviceData = await authorizeCopilot({ mcp: true });
|
|
195
181
|
const sessionId = crypto.randomUUID();
|
|
196
182
|
copilotMCPSessions.set(sessionId, {
|
|
@@ -256,14 +242,13 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
256
242
|
|
|
257
243
|
if (isGitHubCopilotUrl(serverConfig.url)) {
|
|
258
244
|
try {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
) {
|
|
245
|
+
const existingAuth = await getStoredCopilotMCPToken(
|
|
246
|
+
copilotMCPOAuthStore,
|
|
247
|
+
name,
|
|
248
|
+
serverConfig.scope ?? 'global',
|
|
249
|
+
projectRoot,
|
|
250
|
+
);
|
|
251
|
+
if (existingAuth.token && !existingAuth.needsReauth) {
|
|
267
252
|
return c.json({
|
|
268
253
|
ok: true,
|
|
269
254
|
name,
|
|
@@ -334,29 +319,31 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
334
319
|
const result = await pollForCopilotTokenOnce(session.deviceCode);
|
|
335
320
|
if (result.status === 'complete') {
|
|
336
321
|
copilotMCPSessions.delete(sessionId);
|
|
337
|
-
await setAuth(
|
|
338
|
-
'copilot',
|
|
339
|
-
{
|
|
340
|
-
type: 'oauth',
|
|
341
|
-
refresh: result.accessToken,
|
|
342
|
-
access: result.accessToken,
|
|
343
|
-
expires: 0,
|
|
344
|
-
scopes:
|
|
345
|
-
'repo read:org read:packages gist notifications read:project security_events',
|
|
346
|
-
},
|
|
347
|
-
undefined,
|
|
348
|
-
'global',
|
|
349
|
-
);
|
|
350
322
|
const projectRoot = process.cwd();
|
|
351
323
|
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
352
324
|
const serverConfig = config.servers.find((s) => s.name === name);
|
|
325
|
+
if (!serverConfig) {
|
|
326
|
+
return c.json(
|
|
327
|
+
{ ok: false, error: `Server "${name}" not found` },
|
|
328
|
+
404,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
await copilotMCPOAuthStore.saveTokens(
|
|
332
|
+
getCopilotMCPOAuthKey(
|
|
333
|
+
name,
|
|
334
|
+
serverConfig.scope ?? 'global',
|
|
335
|
+
projectRoot,
|
|
336
|
+
),
|
|
337
|
+
{
|
|
338
|
+
access_token: result.accessToken,
|
|
339
|
+
scope: COPILOT_MCP_SCOPE,
|
|
340
|
+
},
|
|
341
|
+
);
|
|
353
342
|
let mcpMgr = getMCPManager();
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
|
|
357
|
-
}
|
|
358
|
-
await mcpMgr.restartServer(serverConfig);
|
|
343
|
+
if (!mcpMgr) {
|
|
344
|
+
mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
|
|
359
345
|
}
|
|
346
|
+
await mcpMgr.restartServer(serverConfig);
|
|
360
347
|
mcpMgr = getMCPManager();
|
|
361
348
|
const status = mcpMgr
|
|
362
349
|
? (await mcpMgr.getStatusAsync()).find((s) => s.name === name)
|
|
@@ -421,8 +408,13 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
421
408
|
|
|
422
409
|
if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
|
|
423
410
|
try {
|
|
424
|
-
const auth = await
|
|
425
|
-
|
|
411
|
+
const auth = await getStoredCopilotMCPToken(
|
|
412
|
+
copilotMCPOAuthStore,
|
|
413
|
+
name,
|
|
414
|
+
serverConfig.scope ?? 'global',
|
|
415
|
+
projectRoot,
|
|
416
|
+
);
|
|
417
|
+
const authenticated = !!auth.token && !auth.needsReauth;
|
|
426
418
|
return c.json({ authenticated, authType: 'copilot-device' });
|
|
427
419
|
} catch {
|
|
428
420
|
return c.json({ authenticated: false, authType: 'copilot-device' });
|
|
@@ -450,10 +442,22 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
450
442
|
|
|
451
443
|
if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
|
|
452
444
|
try {
|
|
453
|
-
const
|
|
454
|
-
|
|
445
|
+
const key = getCopilotMCPOAuthKey(
|
|
446
|
+
name,
|
|
447
|
+
serverConfig.scope ?? 'global',
|
|
448
|
+
projectRoot,
|
|
449
|
+
);
|
|
450
|
+
await copilotMCPOAuthStore.clearServer(key);
|
|
451
|
+
if (key !== name) {
|
|
452
|
+
await copilotMCPOAuthStore.clearServer(name);
|
|
453
|
+
}
|
|
455
454
|
const manager = getMCPManager();
|
|
456
455
|
if (manager) {
|
|
456
|
+
await manager.clearAuthData(
|
|
457
|
+
name,
|
|
458
|
+
serverConfig.scope ?? 'global',
|
|
459
|
+
projectRoot,
|
|
460
|
+
);
|
|
457
461
|
await manager.stopServer(name);
|
|
458
462
|
}
|
|
459
463
|
return c.json({ ok: true, name });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
1
2
|
import type { Hono } from 'hono';
|
|
2
3
|
import { subscribe } from '../events/bus.ts';
|
|
3
4
|
import type { OttoEvent } from '../events/types.ts';
|
|
@@ -8,54 +9,54 @@ function safeStringify(obj: unknown): string {
|
|
|
8
9
|
);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const encoder = new TextEncoder();
|
|
12
|
+
function handleSessionStream(c: Context) {
|
|
13
|
+
const sessionId = c.req.param('id');
|
|
14
|
+
const headers = new Headers({
|
|
15
|
+
'Content-Type': 'text/event-stream',
|
|
16
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
17
|
+
Connection: 'keep-alive',
|
|
18
|
+
});
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
start(controller) {
|
|
24
|
-
const write = (evt: OttoEvent) => {
|
|
25
|
-
let line: string;
|
|
26
|
-
try {
|
|
27
|
-
line =
|
|
28
|
-
`event: ${evt.type}\n` +
|
|
29
|
-
`data: ${safeStringify(evt.payload ?? {})}\n\n`;
|
|
30
|
-
} catch {
|
|
31
|
-
line = `event: ${evt.type}\ndata: {}\n\n`;
|
|
32
|
-
}
|
|
33
|
-
controller.enqueue(encoder.encode(line));
|
|
34
|
-
};
|
|
35
|
-
const unsubscribe = subscribe(sessionId, write);
|
|
36
|
-
// Initial ping
|
|
37
|
-
controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
|
|
38
|
-
// Heartbeat every 5s to prevent idle timeout (Bun default is 10s)
|
|
39
|
-
const hb = setInterval(() => {
|
|
40
|
-
try {
|
|
41
|
-
controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
|
|
42
|
-
} catch {
|
|
43
|
-
// Controller might be closed
|
|
44
|
-
clearInterval(hb);
|
|
45
|
-
}
|
|
46
|
-
}, 5000);
|
|
20
|
+
const encoder = new TextEncoder();
|
|
47
21
|
|
|
48
|
-
|
|
49
|
-
|
|
22
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
23
|
+
start(controller) {
|
|
24
|
+
const write = (evt: OttoEvent) => {
|
|
25
|
+
let line: string;
|
|
26
|
+
try {
|
|
27
|
+
line =
|
|
28
|
+
`event: ${evt.type}\n` +
|
|
29
|
+
`data: ${safeStringify(evt.payload ?? {})}\n\n`;
|
|
30
|
+
} catch {
|
|
31
|
+
line = `event: ${evt.type}\ndata: {}\n\n`;
|
|
32
|
+
}
|
|
33
|
+
controller.enqueue(encoder.encode(line));
|
|
34
|
+
};
|
|
35
|
+
const unsubscribe = subscribe(sessionId, write);
|
|
36
|
+
controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
|
|
37
|
+
const hb = setInterval(() => {
|
|
38
|
+
try {
|
|
39
|
+
controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
|
|
40
|
+
} catch {
|
|
50
41
|
clearInterval(hb);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
controller.close();
|
|
54
|
-
} catch {}
|
|
55
|
-
});
|
|
56
|
-
},
|
|
57
|
-
});
|
|
42
|
+
}
|
|
43
|
+
}, 5000);
|
|
58
44
|
|
|
59
|
-
|
|
45
|
+
const signal = c.req.raw?.signal as AbortSignal | undefined;
|
|
46
|
+
signal?.addEventListener('abort', () => {
|
|
47
|
+
clearInterval(hb);
|
|
48
|
+
unsubscribe();
|
|
49
|
+
try {
|
|
50
|
+
controller.close();
|
|
51
|
+
} catch {}
|
|
52
|
+
});
|
|
53
|
+
},
|
|
60
54
|
});
|
|
55
|
+
|
|
56
|
+
return new Response(stream, { headers });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerSessionStreamRoute(app: Hono) {
|
|
60
|
+
app.get('/v1/sessions/:id/stream', handleSessionStream);
|
|
61
|
+
app.post('/v1/sessions/:id/stream', handleSessionStream);
|
|
61
62
|
}
|
package/src/routes/terminals.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
1
2
|
import type { Hono } from 'hono';
|
|
2
3
|
import { streamSSE } from 'hono/streaming';
|
|
3
4
|
import type { TerminalManager } from '@ottocode/sdk';
|
|
@@ -77,7 +78,7 @@ export function registerTerminalsRoutes(
|
|
|
77
78
|
return c.json({ terminal: terminal.toJSON() });
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
const handleTerminalOutput = async (c: Context) => {
|
|
81
82
|
const id = c.req.param('id');
|
|
82
83
|
logger.debug('SSE client connecting to terminal', { id });
|
|
83
84
|
const terminal = terminalManager.get(id);
|
|
@@ -91,7 +92,6 @@ export function registerTerminalsRoutes(
|
|
|
91
92
|
|
|
92
93
|
return streamSSE(c, async (stream) => {
|
|
93
94
|
logger.debug('SSE stream started for terminal', { id });
|
|
94
|
-
// Send historical buffer first (unless skipHistory is set)
|
|
95
95
|
const skipHistory = c.req.query('skipHistory') === 'true';
|
|
96
96
|
if (!skipHistory) {
|
|
97
97
|
const history = activeTerminal.read();
|
|
@@ -121,10 +121,19 @@ export function registerTerminalsRoutes(
|
|
|
121
121
|
let resolveStream: (() => void) | null = null;
|
|
122
122
|
let finished = false;
|
|
123
123
|
|
|
124
|
+
const hb = setInterval(async () => {
|
|
125
|
+
try {
|
|
126
|
+
await stream.write(`: hb ${Date.now()}\n\n`);
|
|
127
|
+
} catch {
|
|
128
|
+
clearInterval(hb);
|
|
129
|
+
}
|
|
130
|
+
}, 15000);
|
|
131
|
+
|
|
124
132
|
function cleanup() {
|
|
125
133
|
activeTerminal.removeDataListener(onData);
|
|
126
134
|
activeTerminal.removeExitListener(onExit);
|
|
127
135
|
c.req.raw.signal.removeEventListener('abort', onAbort);
|
|
136
|
+
clearInterval(hb);
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
function finish() {
|
|
@@ -168,7 +177,10 @@ export function registerTerminalsRoutes(
|
|
|
168
177
|
|
|
169
178
|
await waitForClose;
|
|
170
179
|
});
|
|
171
|
-
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
app.get('/v1/terminals/:id/output', handleTerminalOutput);
|
|
183
|
+
app.post('/v1/terminals/:id/output', handleTerminalOutput);
|
|
172
184
|
|
|
173
185
|
app.post('/v1/terminals/:id/input', async (c) => {
|
|
174
186
|
const id = c.req.param('id');
|
package/src/routes/tunnel.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Hono } from 'hono';
|
|
2
|
+
import type { Context } from 'hono';
|
|
2
3
|
import { streamSSE } from 'hono/streaming';
|
|
3
4
|
import {
|
|
4
5
|
OttoTunnel,
|
|
@@ -159,8 +160,8 @@ export function registerTunnelRoutes(app: Hono) {
|
|
|
159
160
|
}
|
|
160
161
|
});
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
return streamSSE(c, async (stream) => {
|
|
163
|
+
const handleTunnelStream = async (c: Context) => {
|
|
164
|
+
return streamSSE(c as Context, async (stream) => {
|
|
164
165
|
const sendEvent = async (data: Record<string, unknown>) => {
|
|
165
166
|
try {
|
|
166
167
|
await stream.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
@@ -202,7 +203,10 @@ export function registerTunnelRoutes(app: Hono) {
|
|
|
202
203
|
|
|
203
204
|
clearInterval(interval);
|
|
204
205
|
});
|
|
205
|
-
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
app.get('/v1/tunnel/stream', handleTunnelStream);
|
|
209
|
+
app.post('/v1/tunnel/stream', handleTunnelStream);
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
export function stopActiveTunnel() {
|
|
@@ -130,7 +130,14 @@ export async function dispatchAssistantMessage(
|
|
|
130
130
|
publish({
|
|
131
131
|
type: 'message.created',
|
|
132
132
|
sessionId,
|
|
133
|
-
payload: {
|
|
133
|
+
payload: {
|
|
134
|
+
id: userMessageId,
|
|
135
|
+
role: 'user',
|
|
136
|
+
agent,
|
|
137
|
+
provider,
|
|
138
|
+
model,
|
|
139
|
+
content: String(content),
|
|
140
|
+
},
|
|
134
141
|
});
|
|
135
142
|
|
|
136
143
|
const assistantMessageId = crypto.randomUUID();
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { resolve as resolvePath } from 'node:path';
|
|
2
|
+
|
|
1
3
|
export type GuardAction =
|
|
2
4
|
| { type: 'block'; reason: string }
|
|
3
5
|
| { type: 'approve'; reason: string }
|
|
4
6
|
| { type: 'allow' };
|
|
5
7
|
|
|
6
|
-
export
|
|
8
|
+
export type GuardContext = {
|
|
9
|
+
projectRoot?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function guardToolCall(
|
|
13
|
+
toolName: string,
|
|
14
|
+
args: unknown,
|
|
15
|
+
context: GuardContext = {},
|
|
16
|
+
): GuardAction {
|
|
7
17
|
const a = (args ?? {}) as Record<string, unknown>;
|
|
8
18
|
|
|
9
19
|
switch (toolName) {
|
|
@@ -12,7 +22,7 @@ export function guardToolCall(toolName: string, args: unknown): GuardAction {
|
|
|
12
22
|
case 'terminal':
|
|
13
23
|
return guardTerminal(a);
|
|
14
24
|
case 'read':
|
|
15
|
-
return guardReadPath(String(a.path ?? ''));
|
|
25
|
+
return guardReadPath(String(a.path ?? ''), context.projectRoot);
|
|
16
26
|
case 'write':
|
|
17
27
|
case 'edit':
|
|
18
28
|
case 'multiedit':
|
|
@@ -118,7 +128,42 @@ const SENSITIVE_READ_PATHS: Array<{ pattern: RegExp; reason: string }> = [
|
|
|
118
128
|
{ pattern: /^~?\/?\.docker\/config\.json$/, reason: 'Docker credentials' },
|
|
119
129
|
];
|
|
120
130
|
|
|
121
|
-
function
|
|
131
|
+
function normalizeForComparison(value: string): string {
|
|
132
|
+
const withForwardSlashes = value.replace(/\\/g, '/');
|
|
133
|
+
return process.platform === 'win32'
|
|
134
|
+
? withForwardSlashes.toLowerCase()
|
|
135
|
+
: withForwardSlashes;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function expandTilde(path: string): string {
|
|
139
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
140
|
+
if (!home) return path;
|
|
141
|
+
if (path === '~') return home;
|
|
142
|
+
if (path.startsWith('~/')) return `${home}/${path.slice(2)}`;
|
|
143
|
+
return path;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isAbsoluteLike(path: string): boolean {
|
|
147
|
+
return (
|
|
148
|
+
path.startsWith('/') || path.startsWith('~') || /^[A-Za-z]:[\\/]/.test(path)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isPathInProject(path: string, projectRoot?: string): boolean {
|
|
153
|
+
if (!projectRoot || !isAbsoluteLike(path)) return false;
|
|
154
|
+
const root = resolvePath(projectRoot);
|
|
155
|
+
const target = resolvePath(expandTilde(path));
|
|
156
|
+
const rootNorm = (() => {
|
|
157
|
+
const normalized = normalizeForComparison(root);
|
|
158
|
+
if (normalized === '/') return '/';
|
|
159
|
+
return normalized.replace(/[\\/]+$/, '');
|
|
160
|
+
})();
|
|
161
|
+
const targetNorm = normalizeForComparison(target);
|
|
162
|
+
const rootWithSlash = rootNorm === '/' ? '/' : `${rootNorm}/`;
|
|
163
|
+
return targetNorm === rootNorm || targetNorm.startsWith(rootWithSlash);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function guardReadPath(path: string, projectRoot?: string): GuardAction {
|
|
122
167
|
if (!path) return { type: 'allow' };
|
|
123
168
|
const p = path.trim();
|
|
124
169
|
|
|
@@ -128,7 +173,10 @@ function guardReadPath(path: string): GuardAction {
|
|
|
128
173
|
for (const { pattern, reason } of SENSITIVE_READ_PATHS) {
|
|
129
174
|
if (pattern.test(p)) return { type: 'approve', reason };
|
|
130
175
|
}
|
|
131
|
-
if (p
|
|
176
|
+
if (isPathInProject(p, projectRoot)) {
|
|
177
|
+
return { type: 'allow' };
|
|
178
|
+
}
|
|
179
|
+
if (isAbsoluteLike(p)) {
|
|
132
180
|
return { type: 'approve', reason: 'Reading path outside project root' };
|
|
133
181
|
}
|
|
134
182
|
return { type: 'allow' };
|
package/src/tools/adapter.ts
CHANGED
|
@@ -393,7 +393,9 @@ export function adaptTools(
|
|
|
393
393
|
args,
|
|
394
394
|
);
|
|
395
395
|
}
|
|
396
|
-
const guard = guardToolCall(name, args
|
|
396
|
+
const guard = guardToolCall(name, args, {
|
|
397
|
+
projectRoot: ctx.projectRoot,
|
|
398
|
+
});
|
|
397
399
|
if (guard.type === 'block') {
|
|
398
400
|
meta.blocked = true;
|
|
399
401
|
meta.blockReason = guard.reason;
|