copilot-tap-extension 2.0.8 → 2.0.9
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 +2 -1
- package/SOUL.md +51 -0
- package/bin/install.mjs +2 -1
- package/dist/copilot-instructions.md +5 -0
- package/dist/extension.mjs +361 -20
- package/dist/version.json +1 -1
- package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
- package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
- package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
- package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
- package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
- package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
- package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
- package/docs/evals.md +41 -0
- package/docs/evolution-of-tap-icon.html +989 -0
- package/docs/providers.md +242 -0
- package/docs/recipes/adaptive-agent.md +303 -0
- package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
- package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
- package/docs/recipes/ambient-guardian.md +314 -0
- package/docs/recipes/browser-bridge.md +162 -0
- package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
- package/docs/recipes/copilot-sdk-canvas.md +147 -0
- package/docs/recipes/deferred-cognition.md +310 -0
- package/docs/recipes/provider-integration-patterns.md +93 -0
- package/docs/recipes/provider-interface-advanced.md +1364 -0
- package/docs/recipes/provider-interface-core-profile.md +568 -0
- package/docs/recipes/tap-control-plane-roadmap.md +60 -0
- package/docs/recipes/universal-tool-gateway.md +202 -0
- package/docs/reference.md +229 -0
- package/docs/use-cases.md +348 -0
- package/package.json +4 -1
- package/providers/detour/README.md +84 -0
- package/providers/detour/bridge.js +219 -0
- package/providers/detour/index.mjs +322 -0
- package/providers/detour/package-lock.json +577 -0
- package/providers/detour/package.json +19 -0
- package/providers/detour/scripts/build.mjs +31 -0
- package/providers/detour/src/bridge.js +256 -0
- package/providers/detour/src/contracts.js +40 -0
- package/providers/detour/src/inspector.js +260 -0
- package/providers/detour/src/inspector.test.mjs +53 -0
- package/providers/detour/src/panel.js +465 -0
- package/providers/detour/src/provider-core.js +233 -0
- package/providers/detour/src/provider-core.test.mjs +185 -0
- package/providers/detour/src/react-context-core.js +143 -0
- package/providers/detour/src/react-context.js +44 -0
- package/providers/detour/src/react-context.test.mjs +41 -0
- package/providers/templates/README.md +23 -0
- package/providers/templates/ci-review-provider.mjs +46 -0
- package/providers/templates/detour-workflow-provider.mjs +41 -0
- package/providers/templates/jira-github-provider.mjs +42 -0
- package/providers/templates/provider-utils.mjs +45 -0
- package/providers/templates/sast-triage-provider.mjs +51 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detour Provider — browser↔agent bridge built on tap-provider-sdk.
|
|
3
|
+
*
|
|
4
|
+
* Serves bridge.js over HTTP, manages browser WS connections,
|
|
5
|
+
* and registers tools with the Copilot session via the SDK.
|
|
6
|
+
*/
|
|
7
|
+
import { createProvider } from "../../sdk/index.mjs";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { MESSAGE_TYPES } from "./src/contracts.js";
|
|
16
|
+
import {
|
|
17
|
+
AUTH_HEADER,
|
|
18
|
+
MAX_LOG_BUFFER,
|
|
19
|
+
applyCorsHeaders,
|
|
20
|
+
buildMessagesResponse,
|
|
21
|
+
clientLabelFrom,
|
|
22
|
+
firstClientIdFrom,
|
|
23
|
+
isAuthorizedRequest,
|
|
24
|
+
isLoopbackAddress,
|
|
25
|
+
isLoopbackHostHeader,
|
|
26
|
+
listBrowserClients,
|
|
27
|
+
planBrowserMessage,
|
|
28
|
+
planToolCall,
|
|
29
|
+
renderBridgeScript,
|
|
30
|
+
routeHttpRequest,
|
|
31
|
+
} from "./src/provider-core.js";
|
|
32
|
+
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const BROWSER_PORT = parseInt(process.env.DETOUR_PORT || "9401", 10);
|
|
35
|
+
const BROWSER_HOST = "127.0.0.1";
|
|
36
|
+
const BRIDGE_TOKEN = process.env.DETOUR_BRIDGE_TOKEN || randomUUID();
|
|
37
|
+
|
|
38
|
+
// ── Browser client state ────────────────────────────────────────────────
|
|
39
|
+
const clients = new Map();
|
|
40
|
+
const consoleLogs = [];
|
|
41
|
+
const pendingEvals = new Map();
|
|
42
|
+
const pageMessages = [];
|
|
43
|
+
const pendingPageAsks = new Map();
|
|
44
|
+
|
|
45
|
+
function firstClientId() {
|
|
46
|
+
return firstClientIdFrom(clients);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clientLabel(id) {
|
|
50
|
+
return clientLabelFrom(clients, id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function appendBounded(buffer, item) {
|
|
54
|
+
buffer.push(item);
|
|
55
|
+
if (buffer.length > MAX_LOG_BUFFER) buffer.shift();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function evalOnClient(clientId, code, timeoutMs = 15000) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const client = clients.get(clientId);
|
|
61
|
+
if (!client || client.ws.readyState !== WebSocket.OPEN) {
|
|
62
|
+
return reject(new Error(`Client ${clientId} not connected`));
|
|
63
|
+
}
|
|
64
|
+
const id = randomUUID().slice(0, 12);
|
|
65
|
+
const timer = setTimeout(() => { pendingEvals.delete(id); reject(new Error(`Eval timed out after ${timeoutMs}ms`)); }, timeoutMs);
|
|
66
|
+
pendingEvals.set(id, { resolve, reject, timer });
|
|
67
|
+
client.ws.send(JSON.stringify({ type: MESSAGE_TYPES.EVAL, id, code }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── HTTP server (serves bridge.js + REST API) ───────────────────────────
|
|
72
|
+
const distBridge = path.join(__dirname, "dist", "bridge.js");
|
|
73
|
+
const srcBridge = path.join(__dirname, "bridge.js");
|
|
74
|
+
const bridgeScript = fs.existsSync(distBridge)
|
|
75
|
+
? fs.readFileSync(distBridge, "utf8")
|
|
76
|
+
: fs.readFileSync(srcBridge, "utf8");
|
|
77
|
+
const renderedBridgeScript = renderBridgeScript(bridgeScript, BROWSER_PORT, BRIDGE_TOKEN);
|
|
78
|
+
|
|
79
|
+
function writeJson(res, statusCode, value, spacing) {
|
|
80
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
81
|
+
res.end(JSON.stringify(value, null, spacing));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readRequestBody(req, onBody) {
|
|
85
|
+
let body = "";
|
|
86
|
+
req.on("data", (chunk) => body += chunk);
|
|
87
|
+
req.on("end", () => onBody(body));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rejectUnauthorized(res) {
|
|
91
|
+
writeJson(res, 401, { error: "Unauthorized" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isSafeHttpRequest(req) {
|
|
95
|
+
return isLoopbackHostHeader(req.headers.host) && isLoopbackAddress(req.socket.remoteAddress);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireHttpAuth(req, res) {
|
|
99
|
+
if (isSafeHttpRequest(req) && isAuthorizedRequest(req, BRIDGE_TOKEN)) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
rejectUnauthorized(res);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleEvalRequest(req, res) {
|
|
107
|
+
if (!requireHttpAuth(req, res)) return;
|
|
108
|
+
readRequestBody(req, async (body) => {
|
|
109
|
+
try {
|
|
110
|
+
const { code, client_id, timeout_ms } = JSON.parse(body);
|
|
111
|
+
const cid = client_id || firstClientId();
|
|
112
|
+
if (!cid) { res.writeHead(400); res.end(JSON.stringify({ error: "No browser clients connected" })); return; }
|
|
113
|
+
const result = await evalOnClient(cid, code, timeout_ms || 15000);
|
|
114
|
+
writeJson(res, 200, { result });
|
|
115
|
+
} catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: err.message })); }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleReplyRequest(req, res) {
|
|
120
|
+
if (!requireHttpAuth(req, res)) return;
|
|
121
|
+
readRequestBody(req, (body) => {
|
|
122
|
+
try {
|
|
123
|
+
const { ask_id, reply } = JSON.parse(body);
|
|
124
|
+
const pending = pendingPageAsks.get(ask_id);
|
|
125
|
+
if (!pending) { res.writeHead(404); res.end(JSON.stringify({ error: "No pending ask" })); return; }
|
|
126
|
+
if (pending.ws.readyState === WebSocket.OPEN) pending.ws.send(JSON.stringify({ type: MESSAGE_TYPES.ASK_REPLY, id: ask_id, reply }));
|
|
127
|
+
pendingPageAsks.delete(ask_id);
|
|
128
|
+
res.writeHead(200); res.end(JSON.stringify({ ok: true }));
|
|
129
|
+
} catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: err.message })); }
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleHttpRequest(req, res) {
|
|
134
|
+
applyCorsHeaders(req, res);
|
|
135
|
+
res.setHeader("Cache-Control", "no-store");
|
|
136
|
+
|
|
137
|
+
switch (routeHttpRequest(req.method, req.url)) {
|
|
138
|
+
case "options":
|
|
139
|
+
res.writeHead(204); res.end(); return;
|
|
140
|
+
case "bridge":
|
|
141
|
+
if (!requireHttpAuth(req, res)) return;
|
|
142
|
+
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
143
|
+
res.end(renderedBridgeScript);
|
|
144
|
+
return;
|
|
145
|
+
case "eval":
|
|
146
|
+
handleEvalRequest(req, res);
|
|
147
|
+
return;
|
|
148
|
+
case "clients":
|
|
149
|
+
if (!requireHttpAuth(req, res)) return;
|
|
150
|
+
writeJson(res, 200, listBrowserClients(clients), 2);
|
|
151
|
+
return;
|
|
152
|
+
case "logs":
|
|
153
|
+
if (!requireHttpAuth(req, res)) return;
|
|
154
|
+
writeJson(res, 200, consoleLogs.slice(-50), 2);
|
|
155
|
+
return;
|
|
156
|
+
case "messages":
|
|
157
|
+
if (!requireHttpAuth(req, res)) return;
|
|
158
|
+
writeJson(res, 200, buildMessagesResponse(pageMessages, req.url));
|
|
159
|
+
return;
|
|
160
|
+
case "reply":
|
|
161
|
+
handleReplyRequest(req, res);
|
|
162
|
+
return;
|
|
163
|
+
default:
|
|
164
|
+
res.writeHead(404); res.end("Not found");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const httpServer = http.createServer(handleHttpRequest);
|
|
169
|
+
|
|
170
|
+
// ── Browser WebSocket server ────────────────────────────────────────────
|
|
171
|
+
const browserServer = new WebSocketServer({
|
|
172
|
+
server: httpServer,
|
|
173
|
+
verifyClient: ({ req }, done) => {
|
|
174
|
+
const ok = isSafeHttpRequest(req) && isAuthorizedRequest(req, BRIDGE_TOKEN);
|
|
175
|
+
done(ok, ok ? undefined : 401, ok ? undefined : "Unauthorized");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
httpServer.listen(BROWSER_PORT, BROWSER_HOST, () => {
|
|
179
|
+
console.log(`🌐 Detour listening on http://${BROWSER_HOST}:${BROWSER_PORT}`);
|
|
180
|
+
console.log(` Bridge: http://${BROWSER_HOST}:${BROWSER_PORT}/bridge.js?token=${encodeURIComponent(BRIDGE_TOKEN)}`);
|
|
181
|
+
console.log(` HTTP API: pass ?token=... or ${AUTH_HEADER}: ...`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function applyBrowserMessagePlan(plan, clientId, ws) {
|
|
185
|
+
switch (plan.kind) {
|
|
186
|
+
case "identify": {
|
|
187
|
+
const client = clients.get(clientId);
|
|
188
|
+
client.url = plan.url;
|
|
189
|
+
client.title = plan.title;
|
|
190
|
+
console.log(plan.logText);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "console":
|
|
194
|
+
appendBounded(consoleLogs, plan.entry);
|
|
195
|
+
break;
|
|
196
|
+
case "evalResult": {
|
|
197
|
+
const pending = pendingEvals.get(plan.id);
|
|
198
|
+
if (pending) {
|
|
199
|
+
clearTimeout(pending.timer);
|
|
200
|
+
pendingEvals.delete(plan.id);
|
|
201
|
+
plan.error ? pending.reject(new Error(plan.error)) : pending.resolve(plan.value);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "pageMessage":
|
|
206
|
+
console.log(plan.logText);
|
|
207
|
+
appendBounded(pageMessages, plan.pageMessage);
|
|
208
|
+
provider.surface(plan.pushText);
|
|
209
|
+
break;
|
|
210
|
+
case "pageAsk":
|
|
211
|
+
console.log(plan.logText);
|
|
212
|
+
pendingPageAsks.set(plan.askId, { ...plan.pendingAsk, ws });
|
|
213
|
+
appendBounded(pageMessages, plan.pageMessage);
|
|
214
|
+
provider.surface(plan.pushText);
|
|
215
|
+
break;
|
|
216
|
+
case "pageContext":
|
|
217
|
+
console.log(plan.logText);
|
|
218
|
+
appendBounded(pageMessages, plan.pageMessage);
|
|
219
|
+
provider.surface(plan.pushText);
|
|
220
|
+
break;
|
|
221
|
+
case "pageAnnotate":
|
|
222
|
+
console.log(plan.logText);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
browserServer.on("connection", (ws) => {
|
|
228
|
+
const clientId = randomUUID().slice(0, 8);
|
|
229
|
+
clients.set(clientId, { ws, url: "unknown", title: "unknown", connectedAt: new Date().toISOString() });
|
|
230
|
+
console.log(`🔗 Browser client connected: ${clientId}`);
|
|
231
|
+
|
|
232
|
+
ws.on("message", (raw) => {
|
|
233
|
+
const plan = planBrowserMessage(raw, {
|
|
234
|
+
clientId,
|
|
235
|
+
from: clientLabel(clientId),
|
|
236
|
+
nowIso: () => new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
applyBrowserMessagePlan(plan, clientId, ws);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
ws.on("close", () => { clients.delete(clientId); console.log(`💔 Disconnected: ${clientId}`); });
|
|
242
|
+
ws.on("error", (err) => console.error(`Client ${clientId} error:`, err.message));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── Tools ───────────────────────────────────────────────────────────────
|
|
246
|
+
const TOOLS = [
|
|
247
|
+
{
|
|
248
|
+
name: "inject_js",
|
|
249
|
+
description: "Execute JavaScript on a browser page connected to Detour. Returns the result.",
|
|
250
|
+
parameters: { type: "object", properties: {
|
|
251
|
+
code: { type: "string", description: "JavaScript to evaluate in page context." },
|
|
252
|
+
client_id: { type: "string", description: "Target client ID. Omit for first connected." },
|
|
253
|
+
timeout_ms: { type: "number", description: "Max wait time in ms (default 15000)." },
|
|
254
|
+
}, required: ["code"] },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "get_console_logs",
|
|
258
|
+
description: "Get captured console logs from connected browser pages.",
|
|
259
|
+
parameters: { type: "object", properties: {
|
|
260
|
+
client_id: { type: "string", description: "Filter by client." },
|
|
261
|
+
level: { type: "string", description: "Filter by level (log/warn/error/info/debug)." },
|
|
262
|
+
limit: { type: "number", description: "Max entries (default 50)." },
|
|
263
|
+
clear: { type: "boolean", description: "Clear buffer after reading." },
|
|
264
|
+
}, required: [] },
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "list_browser_clients",
|
|
268
|
+
description: "List connected browser pages with URL, title, and client ID.",
|
|
269
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "get_page_messages",
|
|
273
|
+
description: "Get messages sent from browser pages via __detourBridge.send() or .ask().",
|
|
274
|
+
parameters: { type: "object", properties: { limit: { type: "number", description: "Max messages (default 20)." } }, required: [] },
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "reply_to_page",
|
|
278
|
+
description: "Reply to a pending browser .ask() question. Resolves the Promise on the page.",
|
|
279
|
+
parameters: { type: "object", properties: {
|
|
280
|
+
ask_id: { type: "string", description: "The askId from the pending question." },
|
|
281
|
+
reply: { type: "string", description: "Reply text." },
|
|
282
|
+
}, required: ["ask_id", "reply"] },
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
// ── Provider (SDK handles auth, reconnect, protocol) ────────────────────
|
|
287
|
+
async function handleToolCall(toolName, args) {
|
|
288
|
+
const plan = planToolCall(toolName, args, {
|
|
289
|
+
firstClientId: firstClientId(),
|
|
290
|
+
clients,
|
|
291
|
+
consoleLogs,
|
|
292
|
+
pageMessages,
|
|
293
|
+
pendingPageAsks,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
switch (plan.kind) {
|
|
297
|
+
case "result":
|
|
298
|
+
return plan.value;
|
|
299
|
+
case "eval": {
|
|
300
|
+
const result = await evalOnClient(plan.clientId, plan.code, plan.timeoutMs);
|
|
301
|
+
return typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
302
|
+
}
|
|
303
|
+
case "consoleLogs":
|
|
304
|
+
if (plan.clear) consoleLogs.length = 0;
|
|
305
|
+
return plan.logs;
|
|
306
|
+
case "replyToPage":
|
|
307
|
+
if (plan.pending.ws.readyState === WebSocket.OPEN) plan.pending.ws.send(JSON.stringify(plan.payload));
|
|
308
|
+
pendingPageAsks.delete(plan.askId);
|
|
309
|
+
return plan.result;
|
|
310
|
+
default:
|
|
311
|
+
throw new Error(`Unknown tool: ${plan.toolName}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const provider = createProvider("detour", {
|
|
316
|
+
tools: TOOLS,
|
|
317
|
+
|
|
318
|
+
onToolCall: handleToolCall,
|
|
319
|
+
|
|
320
|
+
onConnected: () => console.log(` Tools: ${TOOLS.map((t) => t.name).join(", ")}`),
|
|
321
|
+
onShutdown: () => console.log("Session ending..."),
|
|
322
|
+
});
|