@karma42/agent 0.1.0-beta.11 → 0.1.0-beta.12
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 +15 -13
- package/package.json +1 -1
- package/runtime/control-v2.mjs +6 -6
- package/runtime/manifest.json +2 -3
- package/runtime/project-tools.json +1 -1
- package/skills/karma42/SKILL.md +22 -4
- package/src/cli.mjs +3 -15
- package/src/layout.mjs +20 -0
- package/src/operation-lock.mjs +46 -0
- package/src/owner-client.mjs +143 -19
- package/src/reusable-stdio.mjs +77 -105
- package/src/system.mjs +19 -19
- package/src/version.mjs +3 -1
- package/src/worker-runtime.mjs +4 -2
- package/src/browser-batch.mjs +0 -130
- package/src/browser-pairing.mjs +0 -140
package/src/reusable-stdio.mjs
CHANGED
|
@@ -8,31 +8,26 @@ import { verifyBundle } from './system.mjs';
|
|
|
8
8
|
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, digest, load, lstatExists, readPrivate } from './state.mjs';
|
|
9
9
|
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
10
10
|
import { projectIdentity } from './project-identity.mjs';
|
|
11
|
-
import { VERSION } from './version.mjs';
|
|
12
|
-
import { createBrowserOperations } from './browser-pairing.mjs';
|
|
13
|
-
import { createBatchBrowserOperations } from './browser-batch.mjs';
|
|
11
|
+
import { VERSION, GUIDE_REVISION } from './version.mjs';
|
|
14
12
|
import { serveSession } from './session-stdio.mjs';
|
|
15
13
|
import { createProjectWorker } from './worker-runtime.mjs';
|
|
16
14
|
import { workerRecoveryStatus, cancelWorkerRecovery } from './worker-recovery.mjs';
|
|
17
|
-
import { createOwnerClient, OWNER_TOOLS } from './owner-client.mjs';
|
|
15
|
+
import { createOwnerClient, OWNER_TOOLS, startQuestion } from './owner-client.mjs';
|
|
18
16
|
import { createDiscovery, DISCOVERY_TOOLS } from './discovery.mjs';
|
|
19
17
|
|
|
20
18
|
const objectSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
21
|
-
const approvalWaitSchema = { type: 'integer', minimum: 0, maximum: 60, default: 0, description: 'Wait without a model for browser approval, up to this many seconds. Reuse the same reference if still pending; no typed done is required.' };
|
|
22
19
|
const reference = { type: 'string', description: 'Use the opaque connection_ref returned for this exact project agent. There is no default connection.' };
|
|
23
20
|
const GUIDE = readFileSync(new URL('../skills/karma42/SKILL.md', import.meta.url), 'utf8');
|
|
24
21
|
const BOOTSTRAP = [
|
|
25
22
|
{ name:'get_karma42_guide', description:'Read the Karma42 owner/team orchestration skill before managing projects or assembling agents. No sign-in or connection needed.', annotations:{readOnlyHint:true}, inputSchema:objectSchema({},[]) },
|
|
26
|
-
{ name: 'connect_project_agent', description: 'Connect one
|
|
27
|
-
{ name: 'finish_project_agent_connection', description: 'Check this binding’s browser approval and bind when approved. Optional wait_seconds waits within a bounded budget without blocking other agents. Retry the same connection_ref after a lost reply. Never edit client settings or create a new pairing merely to retry.', inputSchema: objectSchema({ connection_ref: reference, wait_seconds: approvalWaitSchema, connection_id: { type: 'string', description: 'Optional crosscheck with the connection ID returned during onboarding.' } }, ['connection_ref']) },
|
|
23
|
+
{ name: 'connect_project_agent', description: 'Connect one existing project agent of your human’s by its exact ID and name. Reuses its saved local connection, or connects it through this computer’s one-time approval; there is no browser step per agent. A computer that is not approved yet is approved once with connect_owner_account. Returns an explicit connection_ref required by every project tool. No client settings, model or permissions are changed. To create a new agent use add_project_agent.', inputSchema: objectSchema({ project_url: { type: 'string' }, agent_id: { type: 'string' }, agent_name: { type: 'string' }, folder: { type: 'string' } }, ['project_url', 'agent_id', 'agent_name', 'folder']) },
|
|
28
24
|
{ name: 'disconnect_project_agent', description: 'Stop and release only this project-agent binding. Other bindings in a shared MCP process remain available. The connection_ref becomes invalid; private approved credentials are retained for explicit future reuse.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
29
25
|
];
|
|
30
26
|
const BATCH_TOOLS = [
|
|
31
|
-
{ name: 'connect_project_agents', description: 'Connect an exact batch of one to ten of your own project agents
|
|
32
|
-
{ name: 'finish_project_agents_connection', description: 'Check the exact batch_ref returned during onboarding and bind each approved agent separately. Optional wait_seconds waits within a bounded budget without blocking other agents. Retry this same batch after a lost reply. No worker is started.', inputSchema: objectSchema({ batch_ref: { type:'string' }, wait_seconds: approvalWaitSchema }, ['batch_ref']) },
|
|
27
|
+
{ name: 'connect_project_agents', description: 'Connect an exact batch of one to ten of your human’s own existing project agents through this computer’s one-time approval; there is no browser step. Each credential and connection_ref remains independent; this does not start workers or grant work authorization.', inputSchema: objectSchema({ project_url: { type: 'string' }, agents: { type: 'array', minItems: 1, maxItems: 10, items: objectSchema({ agent_id: { type:'string' }, agent_name: { type:'string' }, folder: { type:'string' } }, ['agent_id','agent_name','folder']) } }, ['project_url','agents']) },
|
|
33
28
|
];
|
|
34
29
|
const WORKER_TOOLS = [
|
|
35
|
-
{ name: 'start_project_worker', description: 'After your human
|
|
30
|
+
{ name: 'start_project_worker', description: 'After your human approves this work, start one supervised worker for this verified project agent. Uses the existing coordination connection and guarded native permissions. Return promptly; the supervisor can steer, pause or stop through worker controls. An agent added with add_project_agent works in its own folder: leave folder out, after cloning the project repository there. Any other agent requires a separate approved Git checkout/worktree matching the project repository, distinct from the connection/main-chat folder. Agents may self-organize within the shared approved mission; fixed roles are optional.', inputSchema: objectSchema({ connection_ref: reference, recovery_ref: { type: 'string', format: 'uuid', description: 'Only after explicit approval to recover the exact saved mission from get_project_worker. Repeat its saved client/model/scope/folder and omit minutes. Preserves the original deadline; never takes over a surviving supervisor or native process.' }, client: { type: 'string', enum: ['claude', 'codex'] }, model: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$', description: 'Optional human-approved model ID or client alias. Omit for the client default; verify the actual model through worker status.' }, minutes: { type: 'integer', minimum: 15, maximum: 240, multipleOf: 15, default: 60 }, scope: { type: 'string', minLength: 1, maxLength: 4000 }, folder: { type: 'string' }, approved: { type: 'boolean', const: true, description: 'Set true only after the human explicitly approves starting this worker under its limits. Adding or connecting an agent alone is insufficient.' } }, ['connection_ref', 'client', 'scope', 'approved']) },
|
|
36
31
|
{ name: 'get_project_worker', description: 'Read this binding’s actual supervised worker status or saved mission recovery details after its supervisor ended. Reports model, last-turn usage, permission observations, original deadline, requested/applied steering and safe failure stage/code. Saved records are not live process observations. This does not restart work or extend the deadline.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
37
32
|
{ name: 'control_project_worker', description: 'Steer, pause, resume or stop only this binding’s supervised worker. Steering waits for the next natural model turn without interrupting active work; check requested/applied sequence rather than assuming delivery. Explicit pause/stop still interrupts execution. Resuming preserves the original deadline. Instruction is required only for steer.', inputSchema: objectSchema({ connection_ref: reference, action: { type: 'string', enum: ['pause', 'resume', 'stop', 'steer'] }, instruction: { type: 'string', minLength: 1, maxLength: 2000 } }, ['connection_ref', 'action']) },
|
|
38
33
|
];
|
|
@@ -41,7 +36,7 @@ const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }
|
|
|
41
36
|
|
|
42
37
|
// MCP hosts may share a server across conversations. Every tool carries an explicit binding
|
|
43
38
|
// reference; neither a process nor a shared state file ever has a default active project.
|
|
44
|
-
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process,
|
|
39
|
+
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), openApproval = () => false, ...boundOptions }) {
|
|
45
40
|
const bundled = verifyBundle();
|
|
46
41
|
const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
|
|
47
42
|
...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
|
|
@@ -53,10 +48,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
53
48
|
let ownerConnected = false;
|
|
54
49
|
const catalog = [...OWNER_TOOLS, ...DISCOVERY_TOOLS, localList, ...BOOTSTRAP, ...BATCH_TOOLS, ...WORKER_TOOLS, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
|
|
55
50
|
home = canonicalPath(home);
|
|
56
|
-
const
|
|
57
|
-
const batchBrowser = batchBrowserFactory({ home, io: { say() {} } });
|
|
58
|
-
const bindings = new Map(), identities = new Map(), batches = new Map();
|
|
51
|
+
const bindings = new Map(), identities = new Map();
|
|
59
52
|
const approvalTasks = new Map();
|
|
53
|
+
let openedOwnerApproval = null;
|
|
60
54
|
let clientName = 'MCP client', stopped = false;
|
|
61
55
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
62
56
|
const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) void closeContext(context).catch(() => {}); };
|
|
@@ -64,7 +58,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
64
58
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.on(signal, stop);
|
|
65
59
|
const send = rpc => { if (!stopped) output.write(`${JSON.stringify(rpc)}\n`); };
|
|
66
60
|
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
67
|
-
const state = context => ({ state: context.stopped || context.stopping || context.closing ? 'stopped' : context.verified ? 'connected' : context.record ? 'bound' : context.observedState ?? 'pending', verified: !!context.verified && !context.stopped && !context.stopping && !context.closing, connection_ref: context.ref, connection_id: context.selected.id, agent_id: context.selected.agentId, agent_name: context.selected.agentName, project_url: context.selected.service, folder: context.selected.folder });
|
|
61
|
+
const state = context => ({ state: context.stopped || context.stopping || context.closing ? 'stopped' : context.verified ? 'connected' : context.record ? 'bound' : context.observedState ?? 'pending', verified: !!context.verified && !context.stopped && !context.stopping && !context.closing, connection_ref: context.ref, connection_id: context.selected.id, agent_id: context.selected.agentId, agent_name: context.selected.agentName, project_url: context.selected.service, folder: context.selected.folder, guide_revision: GUIDE_REVISION });
|
|
68
62
|
function validateRecord(record, context) {
|
|
69
63
|
validateSession(record, context.selected.id);
|
|
70
64
|
if (record.service !== context.selected.service || record.agentId !== context.selected.agentId || record.agentName !== context.selected.agentName || record.folder !== context.selected.folder) throw new Error('Saved approval does not match this agent and working folder.');
|
|
@@ -106,10 +100,10 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
106
100
|
for (const pending of context.waiting.values()) pending.reject(new Error('Bound connection stopped.'));
|
|
107
101
|
context.waiting.clear();
|
|
108
102
|
});
|
|
109
|
-
return { ...state(context), next_step: 'Call get_project with this connection_ref and verify your identity, then read the goals, rules, repository and state.
|
|
103
|
+
return { ...state(context), next_step: 'Call get_project with this connection_ref and verify your identity, then read the goals, rules, repository and state. Follow the guide for when work may start.' };
|
|
110
104
|
}
|
|
111
105
|
async function connect(args) {
|
|
112
|
-
if (Object.keys(args).some(key => !['project_url', 'agent_id', 'agent_name', 'folder'
|
|
106
|
+
if (Object.keys(args).some(key => !['project_url', 'agent_id', 'agent_name', 'folder'].includes(key)) || !/^[\w-]{1,200}$/.test(args.agent_id ?? '') || !/^[\w-]{1,64}$/.test(args.agent_name ?? '')) throw new Error('Use the project-specific agent ID and name supplied by your human. A connected agent keeps its connection; there is no replacing it.');
|
|
113
107
|
if (typeof args.project_url !== 'string' || typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Invalid project or folder.');
|
|
114
108
|
const folder = canonicalPath(args.folder);
|
|
115
109
|
assertStateOutsideWorkspace(home, folder);
|
|
@@ -124,24 +118,31 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
124
118
|
if (context) {
|
|
125
119
|
assertLive(context);
|
|
126
120
|
if (context.selected.agentName !== selected.agentName || context.selected.folder !== selected.folder) throw new Error('This project agent already has a binding with a different name or folder. Disconnect its exact connection_ref before changing it.');
|
|
127
|
-
if (context.record)
|
|
128
|
-
if (args.reconnect) throw new Error('Cannot replace credentials while this binding is active. Disconnect its connection_ref before explicitly reconnecting.');
|
|
129
|
-
return state(context);
|
|
130
|
-
}
|
|
121
|
+
if (context.record) return state(context);
|
|
131
122
|
} else {
|
|
132
123
|
context = { ref: randomUUID(), selected, verified: false }; bindings.set(context.ref, context); identities.set(selected.id, context.ref);
|
|
133
124
|
}
|
|
125
|
+
try { return bind(await savedOrApproved(selected, context), context); }
|
|
126
|
+
catch (error) { forget(context); throw error; }
|
|
127
|
+
}
|
|
128
|
+
// A connect that did not complete leaves nothing behind: its reference was never returned to the caller.
|
|
129
|
+
function forget(context) {
|
|
130
|
+
if (context.record || bindings.get(context.ref) !== context) return;
|
|
131
|
+
bindings.delete(context.ref); identities.delete(context.selected.id);
|
|
132
|
+
}
|
|
133
|
+
// The one validation: a connection this computer already saved, or this computer's approval. The approval
|
|
134
|
+
// connects the exact existing agent the caller named; without it the owner client says how to approve the computer.
|
|
135
|
+
async function savedOrApproved(selected, context) {
|
|
134
136
|
const dir = connectionDir(home, selected.id);
|
|
135
|
-
if (lstatExists(join(dir, 'connection.json'))) validateSession(load(dir), selected.id);
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
if (lstatExists(join(dir, 'connection.json'))) return validateSession(load(dir), selected.id);
|
|
138
|
+
const url = new URL(selected.service);
|
|
139
|
+
if (url.origin !== owner.origin) throw new Error('Use a project on the Karma42 site this computer is set up for.');
|
|
140
|
+
const added = await owner.addAgent({ project_id: url.pathname.split('/')[2], agent_name: selected.agentName, folder: selected.folder }, clientName, { exact: selected.agentId, own: false });
|
|
138
141
|
assertLive(context);
|
|
139
|
-
|
|
140
|
-
context.observedState = response.status;
|
|
141
|
-
return { ...state(context), state: response.status, approval_url: response.approvalUrl, request_check: response.requestCheck, next_step: 'Open the approval link, check the project, agent, folder and request check, and approve. Wait for browser approval with finish_project_agent_connection, this connection_ref and wait_seconds:60; no typed done is required.' };
|
|
142
|
+
return added.record;
|
|
142
143
|
}
|
|
143
144
|
async function connectBatch(args) {
|
|
144
|
-
if (Object.keys(args).some(key => !['project_url','agents'
|
|
145
|
+
if (Object.keys(args).some(key => !['project_url','agents'].includes(key)) || !Array.isArray(args.agents) || args.agents.length < 1 || args.agents.length > 10) throw new Error('Use one to ten explicitly selected agents for one project.');
|
|
145
146
|
const selected = args.agents.map(agent => {
|
|
146
147
|
if (!agent || Object.keys(agent).some(key => !['agent_id','agent_name','folder'].includes(key)) || !/^[\w-]{1,200}$/.test(agent.agent_id ?? '') || !/^[\w-]{1,64}$/.test(agent.agent_name ?? '') || typeof agent.folder !== 'string' || !isAbsolute(agent.folder)) throw new Error('Use each agent’s exact ID, name and approved absolute working folder.');
|
|
147
148
|
const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
|
|
@@ -159,80 +160,24 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
159
160
|
const context = contexts[index]; if (!context) continue;
|
|
160
161
|
assertLive(context);
|
|
161
162
|
if (context.selected.agentName !== selected[index].agentName || context.selected.folder !== selected[index].folder) throw new Error('This project agent already has a different name or folder. Disconnect its exact connection_ref first.');
|
|
162
|
-
if (context.record && args.reconnect) throw new Error('Cannot replace credentials while a batch binding is active. Disconnect its exact connection_ref first.');
|
|
163
163
|
}
|
|
164
|
-
|
|
165
|
-
if (contexts.some(context => context?.record)) throw new Error('Cannot replace credentials for active agents in a batch. Select only unbound agents or disconnect their exact connection_ref first.');
|
|
166
|
-
const response = await batchBrowser.connect({ ...args, clientName });
|
|
167
|
-
if (stopped) throw new Error('The MCP session closed before the batch could be bound.');
|
|
168
|
-
for (const context of contexts) if (context) assertLive(context);
|
|
169
|
-
const key = response.id;
|
|
170
|
-
let batch = [...batches.values()].find(candidate => candidate.id === key);
|
|
171
|
-
if (!batch) { batch = { ref:randomUUID(), id:key, contexts:selected.map((agent,index) => {
|
|
164
|
+
const batch = selected.map((agent,index) => {
|
|
172
165
|
const context = contexts[index] ?? { ref:randomUUID(), selected:agent, verified:false };
|
|
173
166
|
bindings.set(context.ref,context); identities.set(agent.id,context.ref); return context;
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Validate every record before binding any, so a mismatch cannot select another agent.
|
|
181
|
-
if (response.records.length !== batch.contexts.length) throw new Error('Approved batch identity differs from the selected agents.');
|
|
182
|
-
for (const context of batch.contexts) validateRecord(response.records.find(record => record.id === context.selected.id),context);
|
|
183
|
-
for (const context of batch.contexts) if (!context.record) bind(response.records.find(record => record.id === context.selected.id),context);
|
|
184
|
-
return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
185
|
-
}
|
|
186
|
-
for (const context of batch.contexts) context.observedState = response.status;
|
|
187
|
-
return { state:response.status, batch_ref:batch.ref, approval_url:response.approvalUrl, request_check:response.requestCheck, agents:batch.contexts.map(state), next_step:'Approve this exact batch of your own agents in Karma42, wait for the result with finish_project_agents_connection, this batch_ref and wait_seconds:60; no typed done is required. Connection approval does not start work.' };
|
|
188
|
-
}
|
|
189
|
-
function queueApprovalWait(rpc, args, once) {
|
|
190
|
-
const key = `${once === finishBatch ? 'batch' : 'agent'}:${args.batch_ref ?? args.connection_ref}`;
|
|
191
|
-
if (approvalTasks.has(key)) throw new Error('Use the existing approval wait for this same reference; it is still running.');
|
|
192
|
-
if (approvalTasks.size >= 20) throw new Error('Approval wait limit reached. Finish or cancel a pending request first.');
|
|
193
|
-
// Waiting for a browser must not block steering or stopping another bound agent.
|
|
194
|
-
const task = waitForApproval(args, once).then(value => send({ jsonrpc:'2.0', id:rpc.id, result:result(value) }), error => {
|
|
195
|
-
const safe = /^(Use |Approval wait|This binding|This project agent|Browser setup|Another operation|Another browser)/.test(error.message ?? '');
|
|
196
|
-
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.'));
|
|
197
|
-
}).finally(() => approvalTasks.delete(key));
|
|
198
|
-
approvalTasks.set(key, task);
|
|
199
|
-
return task;
|
|
200
|
-
}
|
|
201
|
-
async function waitForApproval(args, once) {
|
|
202
|
-
const { wait_seconds = 0, ...selection } = args;
|
|
203
|
-
if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Approval wait must be between 0 and 60 seconds.');
|
|
204
|
-
let remaining = wait_seconds * 1000;
|
|
205
|
-
while (true) {
|
|
206
|
-
const response = await once(selection);
|
|
207
|
-
if (response.state !== 'pending' || remaining <= 0 || stopped) return response;
|
|
208
|
-
const delay = Math.min(5000, remaining);
|
|
209
|
-
await approvalWait(delay); remaining -= delay;
|
|
210
|
-
if (stopped) return response;
|
|
167
|
+
});
|
|
168
|
+
// Each agent is connected and bound on its own; one that fails leaves the others bound, and
|
|
169
|
+
// repeating the same call connects the rest.
|
|
170
|
+
for (const context of batch) {
|
|
171
|
+
assertLive(context);
|
|
172
|
+
if (!context.record) try { bind(await savedOrApproved(context.selected, context), context); } catch (error) { for (const other of batch) forget(other); throw error; }
|
|
211
173
|
}
|
|
212
|
-
|
|
213
|
-
async function finishBatch(args) {
|
|
214
|
-
if (Object.keys(args).some(key => key !== 'batch_ref') || !batches.has(args.batch_ref)) throw new Error('Use the exact batch_ref returned during onboarding in this MCP process.');
|
|
215
|
-
const batch = batches.get(args.batch_ref);
|
|
216
|
-
for (const context of batch.contexts) assertLive(context);
|
|
217
|
-
if (batch.contexts.every(context => context.record)) return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state) };
|
|
218
|
-
return batchResponse(await batchBrowser.prepare({ connection:batch.id }),batch);
|
|
174
|
+
return { state:'bound', agents:batch.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
219
175
|
}
|
|
220
176
|
function contextFor(args) {
|
|
221
177
|
const context = bindings.get(args.connection_ref);
|
|
222
178
|
if (!context) throw new Error('Use an exact connection_ref returned by connect_project_agent in this MCP process. There is no default connection.');
|
|
223
179
|
return context;
|
|
224
180
|
}
|
|
225
|
-
async function finish(args) {
|
|
226
|
-
if (Object.keys(args).some(key => !['connection_ref', 'connection_id'].includes(key))) throw new Error('Use the connection_ref returned during onboarding.');
|
|
227
|
-
const context = contextFor(args);
|
|
228
|
-
if (args.connection_id !== undefined && args.connection_id !== context.selected.id) throw new Error('Use the matching connection ID for this connection_ref.');
|
|
229
|
-
assertLive(context);
|
|
230
|
-
if (context.record) return state(context);
|
|
231
|
-
const response = await browser.prepare({ connection: context.selected.id });
|
|
232
|
-
assertLive(context);
|
|
233
|
-
context.observedState = response.status;
|
|
234
|
-
return response.schema === 'mcp-connection-1' ? bind(response, context) : { ...state(context), state: response.status, next_step: response.status === 'pending' ? 'Approval is still pending. Ask the human to approve in Karma42; retry this same connection_ref.' : 'The request was cancelled or expired. Ask your human before starting a fresh connection request.' };
|
|
235
|
-
}
|
|
236
181
|
async function closeContext(context) {
|
|
237
182
|
context.closing ??= (async () => {
|
|
238
183
|
// Stop the model process before aborting/releasing its shared coordination run.
|
|
@@ -269,8 +214,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
269
214
|
if (args.approved !== true) throw new Error('Worker start requires your human’s explicit approval for this worker, duration, folder and work scope. Connection approval is insufficient.');
|
|
270
215
|
if (!['claude', 'codex'].includes(args.client) || !Number.isInteger(minutes) || minutes < 15 || minutes > 240 || minutes % 15 !== 0) throw new Error('Choose a supported worker client and 15–240 minutes in 15-minute increments.');
|
|
271
216
|
if (typeof args.scope !== 'string' || !args.scope.trim() || args.scope.length > 4000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(args.scope)) throw new Error('Use a clear, human-approved work scope of at most 4000 characters.');
|
|
272
|
-
|
|
273
|
-
|
|
217
|
+
const ownFolder = context.record.ownFolder === true ? context.record.folder : undefined;
|
|
218
|
+
if (args.folder === undefined ? !ownFolder : typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Use the approved absolute worker checkout folder.');
|
|
219
|
+
const folder = canonicalPath(args.folder ?? ownFolder); assertStateOutsideWorkspace(home, folder);
|
|
274
220
|
for (const other of bindings.values()) {
|
|
275
221
|
if (other === context || other.stopped || other.closing) continue;
|
|
276
222
|
const candidates = [other.selected.folder, ...(workerOwnsMutations(other) && other.workerFolder ? [other.workerFolder] : [])];
|
|
@@ -343,13 +289,43 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
343
289
|
if (name === 'connect_owner_account') {
|
|
344
290
|
if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
|
|
345
291
|
const account = await owner.connect(clientName); ownerConnected = true;
|
|
346
|
-
|
|
292
|
+
// Open the approval page once per request in this process, as for an agent's approval; the link is still returned.
|
|
293
|
+
let opened = false;
|
|
294
|
+
if (account.status === 'pending' && openedOwnerApproval !== account.approval_url) {
|
|
295
|
+
try { opened = !!openApproval(account.approval_url); } catch { opened = false; }
|
|
296
|
+
if (opened) openedOwnerApproval = account.approval_url;
|
|
297
|
+
}
|
|
298
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, ...(account.status === 'pending' ? { browser_opened: opened, next_step: opened ? 'The approval page is open in their browser. Tell the human so in message text, with a labeled link to approval_url and the request check, then call finish_owner_account_connection with wait_seconds 60 in the same turn.' : 'No browser could be opened. Make a labeled link to approval_url and the request check your whole reply and end your turn, so the human sees it; call finish_owner_account_connection when they say they approved.' } : {}), owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) });
|
|
347
299
|
} else {
|
|
348
300
|
if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
|
|
349
301
|
const { owner_ref, ...fields } = args;
|
|
350
|
-
|
|
302
|
+
if (name === 'add_project_agent') {
|
|
303
|
+
// The saved connection binds exactly as an approved one does; its reference is then verified with get_project.
|
|
304
|
+
const added = await owner.addAgent(fields, clientName);
|
|
305
|
+
const bound = await connect({ project_url: added.record.service, agent_id: added.record.agentId, agent_name: added.record.agentName, folder: added.record.folder });
|
|
306
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...bound, created: added.created, project_name: added.project_name, project_id: added.record.service.split('/p/')[1], work_settings: added.settings, folder: added.record.folder, repository: added.repo, start_question: startQuestion(added.record.agentName, added.project_name, added.settings), next_step: 'Call get_project with this connection_ref and check you.agentName. If project.started is true and project.paused is false, ask start_question with your question tool (AskUserQuestion in Claude Code), using exactly its question and its three options; use a numbered list only when your client has no question tool, and never ask it as a plain sentence. If the project has not started or is paused, ask nothing: say in one line what it is waiting for. Clone the repository into folder only after the human answers Yes. The guide section “Ask once before starting” says what each answer means.', owner_ref: ownerReference }) });
|
|
307
|
+
} else if (name === 'finish_owner_account_connection') {
|
|
308
|
+
const { wait_seconds = 0, ...extra } = fields;
|
|
309
|
+
if (Object.keys(extra).length) throw new Error('Owner finish takes only owner_ref and wait_seconds.');
|
|
310
|
+
if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Use an approval wait between 0 and 60 seconds.');
|
|
311
|
+
if (approvalTasks.has('owner')) throw new Error('Use the existing approval wait for this same reference; it is still running.');
|
|
312
|
+
// Waiting for the browser must not block steering or stopping a bound agent.
|
|
313
|
+
const task = (async () => {
|
|
314
|
+
for (let remaining = wait_seconds * 1000; ; ) {
|
|
315
|
+
const account = await owner.finish();
|
|
316
|
+
if (account.status !== 'pending' || remaining <= 0 || stopped) return account;
|
|
317
|
+
const delay = Math.min(5000, remaining); await approvalWait(delay); remaining -= delay;
|
|
318
|
+
}
|
|
319
|
+
})().then(account => send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) }),
|
|
320
|
+
error => send(fail(rpc.id, -32000, /^Owner /.test(error.message ?? '') ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.')))
|
|
321
|
+
.finally(() => approvalTasks.delete('owner'));
|
|
322
|
+
approvalTasks.set('owner', task);
|
|
323
|
+
await (wait_seconds === 0 ? task : Promise.resolve());
|
|
324
|
+
} else {
|
|
325
|
+
const account = await owner.operation(name, fields);
|
|
351
326
|
if (name === 'disconnect_owner_account') ownerConnected = false;
|
|
352
|
-
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
|
|
327
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) });
|
|
328
|
+
}
|
|
353
329
|
}
|
|
354
330
|
}
|
|
355
331
|
else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
|
|
@@ -362,9 +338,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
362
338
|
send({ jsonrpc: '2.0', id: rpc.id, result: result({ scope: 'this_mcp_process', agents: page.map(context => ({ ...state(context), worker: workerStatus(context) })), next: entries.length > offset + 20 ? page.at(-1).ref : null, active_workers: entries.filter(workerOwnsMutations).length, presence: 'local observation; not service online state' }) });
|
|
363
339
|
}
|
|
364
340
|
else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
|
|
365
|
-
else if (name === 'finish_project_agents_connection') await queueApprovalWait(rpc, args, finishBatch);
|
|
366
341
|
else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
|
|
367
|
-
else if (name === 'finish_project_agent_connection') await queueApprovalWait(rpc, args, finish);
|
|
368
342
|
else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
|
|
369
343
|
else if (name === 'get_project_worker') {
|
|
370
344
|
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
|
|
@@ -375,12 +349,11 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
375
349
|
else if (name === 'disconnect_project_agent') {
|
|
376
350
|
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
|
|
377
351
|
const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
|
|
378
|
-
for (const [key,batch] of batches) if (batch.contexts.includes(context)) batches.delete(key);
|
|
379
352
|
send({ jsonrpc: '2.0', id: rpc.id, result: result({ state: 'disconnected', connection_ref: context.ref }) });
|
|
380
353
|
} else if (!allowed.has(name)) send(fail(rpc.id, -32602, 'Unknown Karma42 tool.'));
|
|
381
354
|
else {
|
|
382
355
|
const context = contextFor(args);
|
|
383
|
-
if (!context.record) throw new Error('
|
|
356
|
+
if (!context.record) throw new Error('This project agent is not connected. Call connect_project_agent or add_project_agent again and use the connection_ref it returns.');
|
|
384
357
|
if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
|
|
385
358
|
if (context.closing || context.stopping) throw new Error('This binding is stopping. Wait for the worker to stop before reconnecting.');
|
|
386
359
|
if (workerOwnsMutations(context) && !READ_TOOLS.has(name)) throw new Error('This worker owns project mutations. Use control_project_worker to steer or stop it; the supervisor must not mutate concurrently.');
|
|
@@ -397,7 +370,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
397
370
|
}
|
|
398
371
|
}
|
|
399
372
|
} catch (error) {
|
|
400
|
-
const safe = /^(Owner |Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|Private connection state|Use |Saved approval|This project agent|This binding|Cannot replace|Complete browser|Invalid project|Invalid private credential|Tool arguments|No browser setup|Browser setup|Browser approval reply|Unexpected browser|Approved identity|A browser setup|Another operation|Another browser|The connection lock|Unsupported or invalid MCP)/.test(error.message ?? '');
|
|
373
|
+
const safe = /^(Owner |Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|Private connection state|Use |Saved approval|This project agent|This binding|This computer is not approved|Adding an agent|An unfinished request|Saved agent request|Cannot replace|Complete browser|Invalid project|Invalid private credential|Tool arguments|No browser setup|Browser setup|Browser approval reply|Unexpected browser|Approved identity|A browser setup|Another operation|Another browser|The connection lock|Unsupported or invalid MCP)/.test(error.message ?? '');
|
|
401
374
|
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
|
|
402
375
|
}
|
|
403
376
|
}
|
|
@@ -409,10 +382,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
409
382
|
const name = rpc.params?.name, args = rpc.params?.arguments;
|
|
410
383
|
if (rpc.method !== 'tools/call' || ['get_karma42_guide', 'list_project_agents', 'get_project_worker'].includes(name)) return { priority: true };
|
|
411
384
|
if (['control_project_worker', 'disconnect_project_agent'].includes(name)) return { priority: true, lane: `control:${args?.connection_ref}` };
|
|
412
|
-
if (OWNER_TOOLS.some(tool => tool.name === name)) return { lane: 'owner' };
|
|
385
|
+
if (OWNER_TOOLS.some(tool => tool.name === name) || ['connect_project_agent', 'connect_project_agents'].includes(name)) return { lane: 'owner' };
|
|
413
386
|
if (DISCOVERY_TOOLS.some(tool => tool.name === name)) return { lane: 'discovery' };
|
|
414
|
-
if (
|
|
415
|
-
if (name === 'finish_project_agent_connection' || name === 'finish_project_agents_connection') return {};
|
|
387
|
+
if (name === 'start_project_worker') return { lane: 'setup' };
|
|
416
388
|
return allowed.has(name) ? { lane: `project:${args?.connection_ref}` } : {};
|
|
417
389
|
}
|
|
418
390
|
function dispatch(rpc) {
|
package/src/system.mjs
CHANGED
|
@@ -2,25 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
|
|
6
|
-
export async function request(service, route, body, token) {
|
|
7
|
-
let response;
|
|
8
|
-
try {
|
|
9
|
-
response = await fetch(`${service}/api/join/${route}`, {
|
|
10
|
-
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
11
|
-
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
12
|
-
body: JSON.stringify(body),
|
|
13
|
-
});
|
|
14
|
-
} catch { throw new Error('Browser pairing reply unavailable. Retry the same pending connection.'); }
|
|
15
|
-
if (!response.ok) {
|
|
16
|
-
if (response.status === 401 || response.status === 403) throw new Error('This connection is no longer authorized. Check membership and reconnect in My agents.');
|
|
17
|
-
if (response.status === 429) throw new Error('Too many requests. Wait before retrying.');
|
|
18
|
-
throw new Error('The project service could not complete this request.');
|
|
19
|
-
}
|
|
20
|
-
const raw = await response.text();
|
|
21
|
-
if (raw.length > 16_384) throw new Error('Unexpected project service response.');
|
|
22
|
-
try { return JSON.parse(raw); } catch { throw new Error('Unexpected project service response.'); }
|
|
23
|
-
}
|
|
24
7
|
export function verifyBundle() {
|
|
25
8
|
const root = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
26
9
|
let manifest;
|
|
@@ -36,4 +19,21 @@ export function verifyBundle() {
|
|
|
36
19
|
}
|
|
37
20
|
return { files, fingerprint: createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex') };
|
|
38
21
|
}
|
|
39
|
-
|
|
22
|
+
|
|
23
|
+
// Opens only a Karma42 approval page, in the person's default browser. Returns whether an attempt was made;
|
|
24
|
+
// the caller still shows the link, because a launched command is not proof that a page appeared.
|
|
25
|
+
export function openApprovalPage(address, { platform = process.platform, env = process.env, spawnImpl = spawn } = {}) {
|
|
26
|
+
let url;
|
|
27
|
+
try { url = new URL(address); } catch { return false; }
|
|
28
|
+
const karma = url.protocol === 'https:' && ['karma42.ai', 'karma42-beta.web.app'].includes(url.hostname) && !url.port;
|
|
29
|
+
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
30
|
+
if (!(karma || local) || url.username || url.password || !url.pathname.startsWith('/viewer/')) return false;
|
|
31
|
+
// No desktop to open a page on: remote shells, CI, and Linux without a display.
|
|
32
|
+
if (env.KARMA42_NO_BROWSER || env.CI || env.SSH_CONNECTION || env.SSH_TTY || (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)) return false;
|
|
33
|
+
const [command, args] = platform === 'darwin' ? ['open', [url.href]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url.href]] : ['xdg-open', [url.href]];
|
|
34
|
+
try {
|
|
35
|
+
const child = spawnImpl(command, args, { detached: true, stdio: 'ignore' });
|
|
36
|
+
child.on?.('error', () => {}); child.unref?.();
|
|
37
|
+
return true;
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
package/src/version.mjs
CHANGED
package/src/worker-runtime.mjs
CHANGED
|
@@ -27,14 +27,16 @@ const bounded = (value, label, max = 2000) => {
|
|
|
27
27
|
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) throw new Error(`Provide a clear ${label}.`);
|
|
28
28
|
return value.trim();
|
|
29
29
|
};
|
|
30
|
-
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', '
|
|
30
|
+
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', 'runtime_conflict', 'run_active', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
|
|
31
31
|
const contactIsTransient = failure => failure.fatal === false || failure.name === 'TimeoutError'
|
|
32
32
|
|| (failure instanceof TypeError && failure.message === 'fetch failed')
|
|
33
33
|
|| ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(failure.cause?.code);
|
|
34
34
|
export function validateWorkerWorkspace(folder, record) {
|
|
35
35
|
if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
|
|
36
36
|
const cwd = canonicalPath(folder);
|
|
37
|
-
|
|
37
|
+
// An agent added through the approved computer has a folder of its own, which is also its checkout.
|
|
38
|
+
// A connection made from a chat folder still needs a separate one, so the chat and worker never share files.
|
|
39
|
+
if (!(record.ownFolder === true && cwd === record.folder)) try { assertStateOutsideWorkspace(record.folder, cwd); } catch { throw new Error('Use separate sibling checkouts or worktrees so the worker and current chat do not edit the same files.'); }
|
|
38
40
|
const git = args => { const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 16384, env: { PATH: process.env.PATH, HOME: process.env.HOME, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } }); return result.status === 0 ? result.stdout.trim() : null; };
|
|
39
41
|
const root = git(['rev-parse', '--show-toplevel']), origin = git(['remote', 'get-url', 'origin']);
|
|
40
42
|
if (!root || canonicalPath(root) !== cwd || !origin || origin.replace(/\.git$/, '').replace(/\/$/, '') !== record.repo.replace(/\/$/, '')) throw new Error('The worker folder must be a Git repository root with the project’s HTTPS GitHub origin. No repository was changed.');
|
package/src/browser-batch.mjs
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
2
|
-
import { join, isAbsolute } from 'node:path';
|
|
3
|
-
import { projectIdentity } from './project-identity.mjs';
|
|
4
|
-
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, digest, load, lstatExists, readPrivate, save, writePrivate } from './state.mjs';
|
|
5
|
-
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
6
|
-
import { browserApprovalUrl, withBrowserLock } from './browser-pairing.mjs';
|
|
7
|
-
import { system } from './system.mjs';
|
|
8
|
-
|
|
9
|
-
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
-
const TOKEN = /^[\w-]{43}$/;
|
|
11
|
-
const uuid = /^[a-f0-9-]{36}$/;
|
|
12
|
-
const validClient = value => typeof value === 'string' && value.trim() && value.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(value);
|
|
13
|
-
const batchPath = dir => join(dir, 'pending-batch.json');
|
|
14
|
-
const agentPath = dir => join(dir, 'pending-browser.json');
|
|
15
|
-
function select(options, home) {
|
|
16
|
-
if (!validClient(options.clientName) || !Array.isArray(options.agents) || options.agents.length < 1 || options.agents.length > 10) throw new Error('Use a valid client name and one to ten explicitly selected agents for one project.');
|
|
17
|
-
const agents = options.agents.map(agent => {
|
|
18
|
-
if (!agent || Object.keys(agent).some(key => !['agent_id', 'agent_name', 'folder'].includes(key)) || !/^[\w-]{1,200}$/.test(agent.agent_id ?? '') || !/^[\w-]{1,64}$/.test(agent.agent_name ?? '') || typeof agent.folder !== 'string' || !isAbsolute(agent.folder)) throw new Error('Use each agent’s exact ID, name and approved absolute working folder.');
|
|
19
|
-
const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
|
|
20
|
-
const plan = projectIdentity({ projectUrl: options.project_url, folder });
|
|
21
|
-
return { id: sessionId(plan.projectUrl, agent.agent_id), service: plan.projectUrl, agentId: agent.agent_id, agentName: agent.agent_name, folder };
|
|
22
|
-
}).sort((a,b) => a.agentId.localeCompare(b.agentId));
|
|
23
|
-
if (new Set(agents.map(agent => agent.agentId)).size !== agents.length) throw new Error('Use distinct agent IDs in a batch.');
|
|
24
|
-
return agents;
|
|
25
|
-
}
|
|
26
|
-
function validate(receipt, id, home) {
|
|
27
|
-
if (receipt?.schema !== 'mcp-browser-batch-1' || receipt.id !== id || !uuid.test(receipt.batchId ?? '') || !validClient(receipt.clientName) || !Array.isArray(receipt.agents)) throw new Error('Invalid private batch receipt. Existing connections preserved.');
|
|
28
|
-
const agents = select({ project_url: receipt.service, clientName: receipt.clientName, agents: receipt.agents.map(agent => ({ agent_id: agent.agentId, agent_name: agent.agentName, folder: agent.folder })) }, home);
|
|
29
|
-
const expected = sessionId(receipt.service, `batch-${digest([receipt.clientName, agents]).slice(0,32)}`);
|
|
30
|
-
if (expected !== id || agents.length !== receipt.agents.length) throw new Error('Invalid private batch identity. Existing connections preserved.');
|
|
31
|
-
for (let index = 0; index < agents.length; index++) {
|
|
32
|
-
const request = receipt.agents[index];
|
|
33
|
-
if (JSON.stringify(agents[index]) !== JSON.stringify({ id: request.id, service: request.service, agentId: request.agentId, agentName: request.agentName, folder: request.folder }) || !uuid.test(request.requestId ?? '') || !TOKEN.test(request.candidateToken ?? '') || !TOKEN.test(request.pollingSecret ?? '')) throw new Error('Invalid private batch request. Existing connections preserved.');
|
|
34
|
-
}
|
|
35
|
-
if (new Set(receipt.agents.map(agent => agent.requestId)).size !== agents.length) throw new Error('Invalid private batch request IDs.');
|
|
36
|
-
return receipt;
|
|
37
|
-
}
|
|
38
|
-
export function createBatchBrowserOperations({ home = sessionHome(), io, sys = system } = {}) {
|
|
39
|
-
home = canonicalPath(home);
|
|
40
|
-
const locks = (agents, action, index = 0) => index === agents.length ? action() : withBrowserLock(connectionDir(home, agents[index].id), () => locks(agents, action, index + 1));
|
|
41
|
-
const summary = receipt => ({ id: receipt.id, batchId: receipt.batchId, status: receipt.status, approvalUrl: browserApprovalUrl({ service: receipt.service, requestId: receipt.batchId }), requestCheck: receipt.verificationCode, agents: receipt.agents.map(({ id, agentId, agentName, folder }) => ({ id, agentId, agentName, folder })) });
|
|
42
|
-
function writeAgent(receipt, agent) {
|
|
43
|
-
writePrivate(agentPath(connectionDir(home, agent.id)), { schema: 'mcp-browser-pairing-1', ...agent, clientName: receipt.clientName, batchId: receipt.batchId, createdAt: receipt.createdAt, registered: true, expiresAt: receipt.expiresAt, status: agent.status === 'saved' ? 'saved' : receipt.status });
|
|
44
|
-
}
|
|
45
|
-
async function register(dir, receipt) {
|
|
46
|
-
let reply;
|
|
47
|
-
try { reply = await sys.request(receipt.service, 'pairing-batches/start', { batchId: receipt.batchId, requests: receipt.agents.map(agent => ({ requestId: agent.requestId, agentId: agent.agentId, clientName: receipt.clientName, folder: agent.folder, pollingSecret: agent.pollingSecret, candidateTokenHash: hash(agent.candidateToken) })) }); }
|
|
48
|
-
catch { throw new Error(`Browser batch reply unavailable. Your private batch is saved. Retry this exact batch with connection ${receipt.id}; do not create another request.`); }
|
|
49
|
-
if (reply?.batchId !== receipt.batchId || !['pending', 'approved', 'cancelled', 'expired'].includes(reply.status) || !/^[A-F0-9]{8}$/.test(reply.verificationCode ?? '')) throw new Error('Unexpected browser batch reply. Private requests preserved.');
|
|
50
|
-
receipt = { ...receipt, registered: true, status: reply.status, expiresAt: reply.expiresAt, verificationCode: reply.verificationCode };
|
|
51
|
-
writePrivate(batchPath(dir), receipt);
|
|
52
|
-
for (const agent of receipt.agents) writeAgent(receipt, agent);
|
|
53
|
-
return receipt;
|
|
54
|
-
}
|
|
55
|
-
function recordFor(agent) {
|
|
56
|
-
const dir = connectionDir(home, agent.id), record = validateSession(load(dir), agent.id);
|
|
57
|
-
if (record.service !== agent.service || record.agentId !== agent.agentId || record.agentName !== agent.agentName || record.folder !== agent.folder || readPrivate(join(dir,'token')).trim() !== agent.candidateToken) throw new Error('Browser batch was superseded by another connection. Use the current exact agent approval; credentials were preserved.');
|
|
58
|
-
return record;
|
|
59
|
-
}
|
|
60
|
-
function currentAgents(receipt) {
|
|
61
|
-
for (const agent of receipt.agents) {
|
|
62
|
-
const path = agentPath(connectionDir(home,agent.id));
|
|
63
|
-
if (!lstatExists(path)) throw new Error('Browser batch agent receipt is missing. Existing connections preserved.');
|
|
64
|
-
const current = readPrivate(path,true);
|
|
65
|
-
if (current.batchId !== receipt.batchId || current.requestId !== agent.requestId || current.candidateToken !== agent.candidateToken || current.pollingSecret !== agent.pollingSecret || current.folder !== agent.folder) throw new Error('Browser batch was superseded by another setup. Finish the current exact request; credentials were preserved.');
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
async connect(options) {
|
|
70
|
-
const agents = select(options, home), service = agents[0].service;
|
|
71
|
-
if (options.reconnect !== undefined && typeof options.reconnect !== 'boolean') throw new Error('Use an explicit human-approved reconnect choice.');
|
|
72
|
-
sys.verifyBundle();
|
|
73
|
-
const id = sessionId(service, `batch-${digest([options.clientName, agents]).slice(0,32)}`), dir = connectionDir(home, id);
|
|
74
|
-
return withBrowserLock(dir, () => locks(agents, async () => {
|
|
75
|
-
let receipt = lstatExists(batchPath(dir)) ? validate(readPrivate(batchPath(dir), true), id, home) : null;
|
|
76
|
-
if (receipt?.status === 'saved' && !options.reconnect) return { id, batchId: receipt.batchId, status: 'saved', records: receipt.agents.map(recordFor) };
|
|
77
|
-
if (receipt && ['saved', 'expired', 'cancelled'].includes(receipt.status) && options.reconnect) receipt = null;
|
|
78
|
-
if (!receipt) {
|
|
79
|
-
for (const agent of agents) {
|
|
80
|
-
const path = agentPath(connectionDir(home, agent.id));
|
|
81
|
-
if (lstatExists(path)) {
|
|
82
|
-
const pending = readPrivate(path, true);
|
|
83
|
-
if (!['saved','cancelled','expired'].includes(pending.status)) throw new Error('Another browser setup is pending for an agent in this batch. Finish or cancel that exact request first.');
|
|
84
|
-
}
|
|
85
|
-
if (lstatExists(join(connectionDir(home, agent.id), 'connection.json'))) validateSession(load(connectionDir(home, agent.id)), agent.id);
|
|
86
|
-
}
|
|
87
|
-
receipt = { schema: 'mcp-browser-batch-1', id, service, batchId: randomUUID(), clientName: options.clientName, createdAt: sys.now(), status: 'pending', registered: false, agents: agents.map(agent => ({ ...agent, requestId: randomUUID(), candidateToken: randomBytes(32).toString('base64url'), pollingSecret: randomBytes(32).toString('base64url') })) };
|
|
88
|
-
// The full immutable manifest and every candidate are durable before any network call.
|
|
89
|
-
writePrivate(batchPath(dir), receipt);
|
|
90
|
-
for (const agent of receipt.agents) writeAgent(receipt, agent);
|
|
91
|
-
}
|
|
92
|
-
currentAgents(receipt);
|
|
93
|
-
if (!receipt.registered) receipt = await register(dir, receipt);
|
|
94
|
-
return summary(receipt);
|
|
95
|
-
}));
|
|
96
|
-
},
|
|
97
|
-
async prepare({ connection }) {
|
|
98
|
-
const dir = connectionDir(home, connection);
|
|
99
|
-
return withBrowserLock(dir, async () => {
|
|
100
|
-
if (!lstatExists(batchPath(dir))) throw new Error('No browser batch found. Retry the exact batch returned during onboarding.');
|
|
101
|
-
let receipt = validate(readPrivate(batchPath(dir), true), connection, home);
|
|
102
|
-
return locks(receipt.agents, async () => {
|
|
103
|
-
currentAgents(receipt);
|
|
104
|
-
if (!receipt.registered) receipt = await register(dir, receipt);
|
|
105
|
-
if (['cancelled','expired'].includes(receipt.status)) return summary(receipt);
|
|
106
|
-
const records = [];
|
|
107
|
-
for (const agent of receipt.agents) {
|
|
108
|
-
if (agent.status === 'saved') { records.push(recordFor(agent)); continue; }
|
|
109
|
-
let reply;
|
|
110
|
-
try { reply = await sys.request(receipt.service, `pairings/${agent.requestId}/poll`, {}, agent.pollingSecret); }
|
|
111
|
-
catch { throw new Error(`Browser batch approval reply unavailable. Retry connection ${connection}; saved credentials and remaining requests are preserved.`); }
|
|
112
|
-
if (!['pending','approved','cancelled','expired'].includes(reply?.status)) throw new Error('Unexpected browser batch approval reply. Existing connections preserved.');
|
|
113
|
-
if (reply.status !== 'approved') {
|
|
114
|
-
if (reply.status !== 'pending') { receipt.status = reply.status; writePrivate(batchPath(dir), receipt); for (const request of receipt.agents) writeAgent(receipt, request); }
|
|
115
|
-
return { ...summary(receipt), status: reply.status, savedAgents: records.map(record => record.agentId) };
|
|
116
|
-
}
|
|
117
|
-
if (reply.agentId !== agent.agentId || reply.agentName !== agent.agentName || !Number.isFinite(reply.approvedAt)) throw new Error('Approved batch identity differs from the requested agent. Existing connection preserved.');
|
|
118
|
-
const plan = projectIdentity({ projectUrl: receipt.service, repo: reply.repoUrl, folder: agent.folder });
|
|
119
|
-
const record = validateSession({ schema:'mcp-connection-1', id:agent.id, service:receipt.service, agentId:agent.agentId, agentName:agent.agentName, repo:plan.repo, folder:plan.folder, createdAt:sys.now(), approvedAt:reply.approvedAt }, agent.id);
|
|
120
|
-
const agentDir = connectionDir(home, agent.id);
|
|
121
|
-
writePrivate(join(agentDir, 'token'), agent.candidateToken); save(agentDir, record);
|
|
122
|
-
agent.status = 'saved'; writeAgent(receipt, agent); writePrivate(batchPath(dir), receipt); records.push(record);
|
|
123
|
-
}
|
|
124
|
-
receipt.status = 'saved'; writePrivate(batchPath(dir), receipt);
|
|
125
|
-
return { id: receipt.id, batchId: receipt.batchId, status:'saved', records };
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|