balladeer 0.0.5 → 1.0.1
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/LICENSE +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +403 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -161
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
2
|
+
import { formatInstant } from "../local-time.js";
|
|
3
|
+
import { commandLine } from "../release.js";
|
|
4
|
+
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
5
|
+
import {} from "../wire.js";
|
|
6
|
+
/**
|
|
7
|
+
* What a person types, and what the ledger stores.
|
|
8
|
+
*
|
|
9
|
+
* "Administrator" is the word every screen uses and the word a person says out
|
|
10
|
+
* loud, and `admin` is the value the ledger holds. Both are accepted so nobody
|
|
11
|
+
* has to know which of the two this command wanted, and the confirmation prints
|
|
12
|
+
* the long form back, because that is the word on the settings page they will
|
|
13
|
+
* see this person listed under.
|
|
14
|
+
*/
|
|
15
|
+
const ROLES = {
|
|
16
|
+
administrator: "admin",
|
|
17
|
+
admin: "admin",
|
|
18
|
+
contributor: "contributor",
|
|
19
|
+
viewer: "viewer",
|
|
20
|
+
};
|
|
21
|
+
export const ROLE_NAMES = {
|
|
22
|
+
admin: "administrator",
|
|
23
|
+
contributor: "contributor",
|
|
24
|
+
viewer: "viewer",
|
|
25
|
+
};
|
|
26
|
+
/** What each role may do, in one clause, so nobody invites by guessing. */
|
|
27
|
+
export const ROLE_MEANINGS = {
|
|
28
|
+
admin: "read this workspace, propose promises, change setup, and invite people",
|
|
29
|
+
contributor: "read this workspace and propose promises",
|
|
30
|
+
viewer: "read this workspace",
|
|
31
|
+
};
|
|
32
|
+
export function parseRole(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return "contributor";
|
|
35
|
+
return ROLES[value.trim().toLowerCase()];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* An address this command will send. Deliberately narrow rather than clever: it
|
|
39
|
+
* refuses here so a typo costs a sentence rather than an invitation nobody can
|
|
40
|
+
* recall, and the server's own schema refuses it again.
|
|
41
|
+
*/
|
|
42
|
+
const EMAIL = /^[^\s@,;]+@[^\s@,;.]+(?:\.[^\s@,;.]+)+$/;
|
|
43
|
+
export function looksLikeEmail(value) {
|
|
44
|
+
return value.length <= 320 && EMAIL.test(value);
|
|
45
|
+
}
|
|
46
|
+
function emit(options, step) {
|
|
47
|
+
if (options.json)
|
|
48
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
49
|
+
}
|
|
50
|
+
function say(options, text) {
|
|
51
|
+
if (!options.json)
|
|
52
|
+
options.write(`${text}\n`);
|
|
53
|
+
}
|
|
54
|
+
function fail(options, reason, message, exitCode) {
|
|
55
|
+
say(options, message);
|
|
56
|
+
emit(options, { step: "error", reason, message, changed: false, exitCode });
|
|
57
|
+
return exitCode;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The refusal a person meets when their approver's role never carried this.
|
|
61
|
+
*
|
|
62
|
+
* Said before anything is sent, from the scopes the session itself reports,
|
|
63
|
+
* because the alternative is a person typing four addresses and being refused
|
|
64
|
+
* by the server four times for a reason that was knowable before the first one.
|
|
65
|
+
*/
|
|
66
|
+
function withoutTheScope(options, session) {
|
|
67
|
+
return fail(options, "invite_not_permitted", [
|
|
68
|
+
`This setup session cannot invite anybody into ${session.workspaceName}.`,
|
|
69
|
+
" Inviting is an administrator's act, and a session carries only what the person who approved it could do unaided.",
|
|
70
|
+
` Next: ask an administrator of ${session.workspaceName} to run this command, or to invite people in Balladeer under workspace settings.`,
|
|
71
|
+
].join("\n"), 4);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Invites teammates by email, one at a time, and says what happened to each.
|
|
75
|
+
*
|
|
76
|
+
* Per address rather than per run. Sending four invitations is four separate
|
|
77
|
+
* things that can each succeed or be refused on their own, and a person reading
|
|
78
|
+
* "3 of 4 sent" cannot tell whose email is missing. Every address gets its own
|
|
79
|
+
* line and its own object, and the exit code says only whether every one of
|
|
80
|
+
* them went.
|
|
81
|
+
*/
|
|
82
|
+
export async function runInvite(options) {
|
|
83
|
+
if (options.emails.length === 0) {
|
|
84
|
+
return fail(options, "usage", `Name at least one email address to invite. For example: ${commandLine(null, "invite alice@example.com")}`, 4);
|
|
85
|
+
}
|
|
86
|
+
const malformed = options.emails.filter((email) => !looksLikeEmail(email));
|
|
87
|
+
if (malformed.length > 0) {
|
|
88
|
+
return fail(options, "usage", `That is not an email address: ${malformed.join(", ")}. Nothing was sent.`, 4);
|
|
89
|
+
}
|
|
90
|
+
let credentials;
|
|
91
|
+
try {
|
|
92
|
+
credentials = readCredentials(options.environment);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return fail(options, error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
96
|
+
}
|
|
97
|
+
const session = findSession(credentials, options.controlPlane);
|
|
98
|
+
if (session === undefined) {
|
|
99
|
+
return fail(options, "no_stored_session", `No Balladeer session is stored for ${options.controlPlane}. Run: ${commandLine(null, "setup")}`, 4);
|
|
100
|
+
}
|
|
101
|
+
// Read off the stored session rather than attempted and reported back. The
|
|
102
|
+
// approval page lists what the person granted, and this is one of the five.
|
|
103
|
+
if (!session.scopes.includes("workspace:invite")) {
|
|
104
|
+
return withoutTheScope(options, session);
|
|
105
|
+
}
|
|
106
|
+
let refused = 0;
|
|
107
|
+
for (const email of options.emails) {
|
|
108
|
+
const sent = await inviteOne(options, session, email);
|
|
109
|
+
if (!sent)
|
|
110
|
+
refused += 1;
|
|
111
|
+
}
|
|
112
|
+
if (refused === 0) {
|
|
113
|
+
say(options, `\nEach person gets an email from Balladeer with a link to accept. Nobody is in ${session.workspaceName} until they accept, and until then they are listed as invited under workspace settings.`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
return 5;
|
|
117
|
+
}
|
|
118
|
+
async function inviteOne(options, session, email) {
|
|
119
|
+
const body = { email, requestedRole: options.role };
|
|
120
|
+
try {
|
|
121
|
+
const answer = await request(options.controlPlane, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
path: "/api/setup/v1/invitations",
|
|
124
|
+
bearer: session.token,
|
|
125
|
+
body,
|
|
126
|
+
});
|
|
127
|
+
const role = ROLE_NAMES[answer.requestedRole];
|
|
128
|
+
say(options, `Invited ${answer.email} to ${answer.workspaceName} as ${role}. Balladeer has sent them the email.`);
|
|
129
|
+
say(options, ` They can ${ROLE_MEANINGS[answer.requestedRole]}. They join when they accept, and not before.`);
|
|
130
|
+
if (answer.expiresAt !== null) {
|
|
131
|
+
say(options, ` The invitation stops working at ${formatInstant(answer.expiresAt)}.`);
|
|
132
|
+
}
|
|
133
|
+
emit(options, {
|
|
134
|
+
step: "invitation",
|
|
135
|
+
status: "sent",
|
|
136
|
+
email: answer.email,
|
|
137
|
+
role: answer.requestedRole,
|
|
138
|
+
workspace: answer.workspaceName,
|
|
139
|
+
expiresAt: answer.expiresAt,
|
|
140
|
+
});
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const { reason, message } = invitationRefusal(options, email, error);
|
|
145
|
+
say(options, message);
|
|
146
|
+
emit(options, {
|
|
147
|
+
step: "invitation",
|
|
148
|
+
status: "refused",
|
|
149
|
+
email,
|
|
150
|
+
role: options.role,
|
|
151
|
+
reason,
|
|
152
|
+
message,
|
|
153
|
+
});
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* One refusal, in the words the person can act on.
|
|
159
|
+
*
|
|
160
|
+
* Every arm names the address, because a run inviting four people prints four
|
|
161
|
+
* lines and a bare "already a member" belongs to one of them. Nothing here
|
|
162
|
+
* invents a remedy the server did not offer: where the server said why, that
|
|
163
|
+
* sentence is what is printed.
|
|
164
|
+
*/
|
|
165
|
+
function invitationRefusal(options, email, error) {
|
|
166
|
+
if (error instanceof ClientTooOldError) {
|
|
167
|
+
return { reason: "client_too_old", message: `${email}: ${error.message}` };
|
|
168
|
+
}
|
|
169
|
+
if (error instanceof TransportError) {
|
|
170
|
+
return {
|
|
171
|
+
reason: "control_plane_unreachable",
|
|
172
|
+
message: `${email}: Balladeer could not be reached at ${options.controlPlane}: ${error.message}. No invitation was sent to this address.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (error instanceof RefusalError) {
|
|
176
|
+
if (error.code === "delegated_authority_refused" || error.code === "action_not_allowed") {
|
|
177
|
+
return {
|
|
178
|
+
reason: error.code,
|
|
179
|
+
message: `${email}: this setup session may not invite anybody. Ask an administrator to run this command, or to invite people in Balladeer under workspace settings.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (error.code === "session_expired" || error.code === "session_revoked") {
|
|
183
|
+
return {
|
|
184
|
+
reason: error.code,
|
|
185
|
+
message: `${email}: this setup session has ended, so nothing was sent. Run \`${commandLine(null, "setup")}\` to pair again.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// The server's own sentence. "That email already has an active membership"
|
|
189
|
+
// and "an incompatible or already-confirmed invitation is still open for
|
|
190
|
+
// that email" are both things a person acts on, and both are already
|
|
191
|
+
// written the way a person reads them.
|
|
192
|
+
return { reason: error.code, message: `${email}: ${error.message}` };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
reason: "invitation_failed",
|
|
196
|
+
message: `${email}: Balladeer could not send this invitation. Nothing was sent to this address.`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type StoredAgent } from "../store.js";
|
|
2
|
+
export { forwarderHeaders, selectAgent, type AgentSelection } from "../agent.js";
|
|
3
|
+
export type McpOptions = Readonly<{
|
|
4
|
+
controlPlane: string;
|
|
5
|
+
/**
|
|
6
|
+
* Which repository's credential to forward. The host config setup writes
|
|
7
|
+
* always names it, because a workspace holds several repositories and each one
|
|
8
|
+
* has its own connection. Without it, the credential belonging to the
|
|
9
|
+
* repository this process was started in is used, and where there is no such
|
|
10
|
+
* credential the command refuses rather than guessing.
|
|
11
|
+
*/
|
|
12
|
+
repositoryId?: string;
|
|
13
|
+
environment: NodeJS.ProcessEnv;
|
|
14
|
+
cwd: string;
|
|
15
|
+
stdin: NodeJS.ReadableStream;
|
|
16
|
+
write: (text: string) => void;
|
|
17
|
+
error: (text: string) => void;
|
|
18
|
+
}>;
|
|
19
|
+
export type ProbeResult = Readonly<{
|
|
20
|
+
kind: "answered";
|
|
21
|
+
repositoryId: string;
|
|
22
|
+
}> | Readonly<{
|
|
23
|
+
kind: "refused";
|
|
24
|
+
reason: string;
|
|
25
|
+
nextAction: string;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* One real call over the connection just issued, before anything claims it
|
|
29
|
+
* works.
|
|
30
|
+
*
|
|
31
|
+
* It is `get_promise_setup` because that tool answers for the bound repository
|
|
32
|
+
* and nothing else, so its answer proves three things at once: the credential
|
|
33
|
+
* authenticated on the MCP endpoint, Balladeer served this repository's
|
|
34
|
+
* connection rather than another's, and the frames this forwarder writes are
|
|
35
|
+
* frames that server accepts. A response for a different repository is a failure
|
|
36
|
+
* here, not a curiosity.
|
|
37
|
+
*
|
|
38
|
+
* It sends what the forwarder sends, over the endpoint the forwarder would use,
|
|
39
|
+
* and refuses the same entries the forwarder refuses. What it cannot prove is
|
|
40
|
+
* whether an agent host has loaded the entry yet, which is why the step that
|
|
41
|
+
* calls this also names the host's own next action rather than implying there
|
|
42
|
+
* is none.
|
|
43
|
+
*/
|
|
44
|
+
export declare function probeAgentConnection(agent: StoredAgent, timeoutMs?: number): Promise<ProbeResult>;
|
|
45
|
+
/**
|
|
46
|
+
* The frame a host is owed when the server refuses this copy of the program.
|
|
47
|
+
*
|
|
48
|
+
* A 426 body is the control plane's JSON, not JSON-RPC, and relaying it to a
|
|
49
|
+
* host would surface as a protocol error with no sentence in it. The host is
|
|
50
|
+
* told in its own protocol what happened and which line fixes it, and the same
|
|
51
|
+
* sentence goes to stderr for whoever is reading the log.
|
|
52
|
+
*/
|
|
53
|
+
export declare function staleClientFrame(id: unknown, update: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* The frame a host is owed when the credential itself is finished.
|
|
56
|
+
*
|
|
57
|
+
* The server refuses a revoked connection at the door, so the model never
|
|
58
|
+
* reaches a tool and never sees the tool-level refusal that would have told it
|
|
59
|
+
* apart from a missing scope. Without this it sees a server that died. The
|
|
60
|
+
* sentence is the same distinction the server's own refusals draw: this is the
|
|
61
|
+
* connection being gone, not this tool being unavailable, and the remedy is a
|
|
62
|
+
* person's.
|
|
63
|
+
*/
|
|
64
|
+
export declare function revokedConnectionFrame(id: unknown, controlPlane: string): string;
|
|
65
|
+
export declare function runMcp(options: McpOptions): Promise<number>;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
|
|
3
|
+
import { noteServerVersion, updateNotice } from "../currency.js";
|
|
4
|
+
import { repositoryHint } from "../repository.js";
|
|
5
|
+
import { StoreError, readCredentials } from "../store.js";
|
|
6
|
+
// The three commands that use an agent connection select and address it through
|
|
7
|
+
// one module. These are re-exported because this is where the forwarder's
|
|
8
|
+
// callers already look for them.
|
|
9
|
+
export { forwarderHeaders, selectAgent } from "../agent.js";
|
|
10
|
+
const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* One real call over the connection just issued, before anything claims it
|
|
14
|
+
* works.
|
|
15
|
+
*
|
|
16
|
+
* It is `get_promise_setup` because that tool answers for the bound repository
|
|
17
|
+
* and nothing else, so its answer proves three things at once: the credential
|
|
18
|
+
* authenticated on the MCP endpoint, Balladeer served this repository's
|
|
19
|
+
* connection rather than another's, and the frames this forwarder writes are
|
|
20
|
+
* frames that server accepts. A response for a different repository is a failure
|
|
21
|
+
* here, not a curiosity.
|
|
22
|
+
*
|
|
23
|
+
* It sends what the forwarder sends, over the endpoint the forwarder would use,
|
|
24
|
+
* and refuses the same entries the forwarder refuses. What it cannot prove is
|
|
25
|
+
* whether an agent host has loaded the entry yet, which is why the step that
|
|
26
|
+
* calls this also names the host's own next action rather than implying there
|
|
27
|
+
* is none.
|
|
28
|
+
*/
|
|
29
|
+
export async function probeAgentConnection(agent, timeoutMs = 15_000) {
|
|
30
|
+
const call = await callAgentTool(agent, "get_promise_setup", {}, timeoutMs);
|
|
31
|
+
switch (call.kind) {
|
|
32
|
+
case "endpoint_refused":
|
|
33
|
+
return {
|
|
34
|
+
kind: "refused",
|
|
35
|
+
reason: call.reason,
|
|
36
|
+
nextAction: "Issue the connection again from the Balladeer setup page.",
|
|
37
|
+
};
|
|
38
|
+
case "unreachable":
|
|
39
|
+
return {
|
|
40
|
+
kind: "refused",
|
|
41
|
+
reason: "Balladeer's MCP endpoint could not be reached from this machine",
|
|
42
|
+
nextAction: "Check this machine's network access, then run this command again.",
|
|
43
|
+
};
|
|
44
|
+
case "client_too_old":
|
|
45
|
+
return {
|
|
46
|
+
kind: "refused",
|
|
47
|
+
reason: "this copy of the command is too old for that Balladeer",
|
|
48
|
+
nextAction: `Update it with: ${call.update}, then run this command again.`,
|
|
49
|
+
};
|
|
50
|
+
case "unauthorized":
|
|
51
|
+
return {
|
|
52
|
+
kind: "refused",
|
|
53
|
+
reason: "Balladeer refused the credential this step just issued",
|
|
54
|
+
nextAction: "Issue the connection again from the Balladeer setup page.",
|
|
55
|
+
};
|
|
56
|
+
case "http":
|
|
57
|
+
return {
|
|
58
|
+
kind: "refused",
|
|
59
|
+
reason: `Balladeer answered ${call.status} to a first call over this connection`,
|
|
60
|
+
nextAction: "Run this command again; if it repeats, report the status above.",
|
|
61
|
+
};
|
|
62
|
+
case "result": {
|
|
63
|
+
const repositoryId = structuredString(call.structured, "repositoryId");
|
|
64
|
+
if (repositoryId === undefined)
|
|
65
|
+
break;
|
|
66
|
+
if (repositoryId !== agent.repositoryId) {
|
|
67
|
+
return {
|
|
68
|
+
kind: "refused",
|
|
69
|
+
reason: "that connection answered for a different repository",
|
|
70
|
+
nextAction: "Issue the connection again from the Balladeer setup page, and do not use it.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { kind: "answered", repositoryId };
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
kind: "refused",
|
|
80
|
+
reason: "Balladeer's answer to a first call over this connection was not a tool result",
|
|
81
|
+
nextAction: "Run this command again; if it repeats, report that to Balladeer.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The frame a host is owed when the server refuses this copy of the program.
|
|
86
|
+
*
|
|
87
|
+
* A 426 body is the control plane's JSON, not JSON-RPC, and relaying it to a
|
|
88
|
+
* host would surface as a protocol error with no sentence in it. The host is
|
|
89
|
+
* told in its own protocol what happened and which line fixes it, and the same
|
|
90
|
+
* sentence goes to stderr for whoever is reading the log.
|
|
91
|
+
*/
|
|
92
|
+
export function staleClientFrame(id, update) {
|
|
93
|
+
return errorFrame(id, `This copy of the Balladeer command is too old for this Balladeer. Update it with: ${update}`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The frame a host is owed when the credential itself is finished.
|
|
97
|
+
*
|
|
98
|
+
* The server refuses a revoked connection at the door, so the model never
|
|
99
|
+
* reaches a tool and never sees the tool-level refusal that would have told it
|
|
100
|
+
* apart from a missing scope. Without this it sees a server that died. The
|
|
101
|
+
* sentence is the same distinction the server's own refusals draw: this is the
|
|
102
|
+
* connection being gone, not this tool being unavailable, and the remedy is a
|
|
103
|
+
* person's.
|
|
104
|
+
*/
|
|
105
|
+
export function revokedConnectionFrame(id, controlPlane) {
|
|
106
|
+
return errorFrame(id, `Balladeer refused this agent connection: it was revoked, or the repository it was bound to is no longer enrolled. This is the connection being gone rather than one tool being unavailable, so retrying will not help. Ask the person to run Balladeer setup again in this repository, or to issue a new connection at ${controlPlane}/setup.`);
|
|
107
|
+
}
|
|
108
|
+
function errorFrame(id, message) {
|
|
109
|
+
return JSON.stringify({
|
|
110
|
+
jsonrpc: "2.0",
|
|
111
|
+
id: id ?? null,
|
|
112
|
+
error: { code: -32000, message },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function frameId(line) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(line);
|
|
118
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
119
|
+
const id = parsed.id;
|
|
120
|
+
if (typeof id === "string" || typeof id === "number")
|
|
121
|
+
return id;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A frame we cannot parse still gets an answer, with a null id.
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
export async function runMcp(options) {
|
|
130
|
+
let credentials;
|
|
131
|
+
try {
|
|
132
|
+
credentials = readCredentials(options.environment);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
options.error(`${error instanceof StoreError ? error.message : String(error)}\n`);
|
|
136
|
+
return 4;
|
|
137
|
+
}
|
|
138
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repositoryId, repositoryHint(options.cwd));
|
|
139
|
+
if (selection.kind === "refused") {
|
|
140
|
+
// Issuing one in the browser is what this used to suggest here, and it is the
|
|
141
|
+
// move that produces this state: the bearer is shown once, there, and the
|
|
142
|
+
// forwarder reads the store on this machine.
|
|
143
|
+
options.error(selection.missingFor === undefined
|
|
144
|
+
? `${selection.reason} Run setup in this repository, or issue the connection at ${options.controlPlane}/setup.\n`
|
|
145
|
+
: `${noAgentCredentialSentence(selection.missingFor)}\n`);
|
|
146
|
+
return 4;
|
|
147
|
+
}
|
|
148
|
+
const { agent } = selection;
|
|
149
|
+
let saidUpdate = false;
|
|
150
|
+
const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
|
|
151
|
+
for await (const line of lines) {
|
|
152
|
+
if (line.trim().length === 0)
|
|
153
|
+
continue;
|
|
154
|
+
if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_BYTES) {
|
|
155
|
+
options.error("Balladeer refused a request frame larger than 1 MiB.\n");
|
|
156
|
+
return 5;
|
|
157
|
+
}
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetch(agent.mcpUrl, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: forwarderHeaders(agent.token),
|
|
163
|
+
body: line,
|
|
164
|
+
redirect: "manual",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
options.error("Balladeer could not be reached. Nothing was changed.\n");
|
|
169
|
+
return 5;
|
|
170
|
+
}
|
|
171
|
+
// The host's own channel for this is the instructions the server sends on
|
|
172
|
+
// initialize, which reach the model. This line reaches whoever is reading
|
|
173
|
+
// the log, once per session rather than once per frame.
|
|
174
|
+
noteServerVersion(response.headers);
|
|
175
|
+
const notice = updateNotice();
|
|
176
|
+
if (notice !== undefined && !saidUpdate) {
|
|
177
|
+
saidUpdate = true;
|
|
178
|
+
options.error(`${notice}\n`);
|
|
179
|
+
}
|
|
180
|
+
if (response.status === 401) {
|
|
181
|
+
options.write(`${revokedConnectionFrame(frameId(line), options.controlPlane)}\n`);
|
|
182
|
+
options.error("Balladeer refused this agent connection. Rotate it in Balladeer.\n");
|
|
183
|
+
return 5;
|
|
184
|
+
}
|
|
185
|
+
// The version floor, answered in the host's own protocol. A stale forwarder
|
|
186
|
+
// that has been running against this control plane for months learns here
|
|
187
|
+
// what to run, and the host sees a sentence rather than a parse failure.
|
|
188
|
+
if (response.status === 426) {
|
|
189
|
+
const update = updateLine(await safeJson(response));
|
|
190
|
+
options.write(`${staleClientFrame(frameId(line), update)}\n`);
|
|
191
|
+
options.error(`This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${update}\n`);
|
|
192
|
+
return 3;
|
|
193
|
+
}
|
|
194
|
+
const text = await response.text();
|
|
195
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
196
|
+
options.error("Balladeer refused a response frame larger than 4 MiB.\n");
|
|
197
|
+
return 5;
|
|
198
|
+
}
|
|
199
|
+
options.write(`${text.replace(/\n+$/, "")}\n`);
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type PrepareOptions = Readonly<{
|
|
2
|
+
controlPlane: string;
|
|
3
|
+
json: boolean;
|
|
4
|
+
promiseId: string;
|
|
5
|
+
/** Prepare a replacement, invalidating the packet nobody has spent. */
|
|
6
|
+
again: boolean;
|
|
7
|
+
/** The repository whose connection to use, named as `owner/name`. */
|
|
8
|
+
repo?: string;
|
|
9
|
+
/** The repository whose connection to use, named by id, as `mcp` takes it. */
|
|
10
|
+
repository?: string;
|
|
11
|
+
environment: NodeJS.ProcessEnv;
|
|
12
|
+
cwd: string;
|
|
13
|
+
write: (text: string) => void;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* A refusal Balladeer wrote, as this terminal has to say it.
|
|
17
|
+
*
|
|
18
|
+
* Two changes and no others. The instants move onto the reader's own clock,
|
|
19
|
+
* because the server writes UTC and "already prepared by Robert Clark on
|
|
20
|
+
* 2026-09-07T18:05:10.698Z" reached somebody whose clock said two in the
|
|
21
|
+
* afternoon. And the argument is named the way this command takes it. Every
|
|
22
|
+
* other word, including what to do next and why, is the server's.
|
|
23
|
+
*/
|
|
24
|
+
export declare function atThisTerminal(text: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* The six keys the sealed run reads, and nothing else.
|
|
27
|
+
*
|
|
28
|
+
* The runner rejects an unknown field outright, so a metadata file that carried
|
|
29
|
+
* a helpful comment, a timestamp, or the whole packet would fail the customer's
|
|
30
|
+
* protected run for a reason that had nothing to do with their behavior. This
|
|
31
|
+
* command therefore writes the packet's own `qualificationMetadata` object and
|
|
32
|
+
* refuses to write anything it does not recognise.
|
|
33
|
+
*/
|
|
34
|
+
declare const METADATA_KEYS: readonly ["schemaVersion", "workspaceLocator", "receiptId", "revisionId", "bindingId", "workflowDigest"];
|
|
35
|
+
type Metadata = Record<(typeof METADATA_KEYS)[number], string>;
|
|
36
|
+
export declare function readQualificationMetadata(structured: unknown): Readonly<{
|
|
37
|
+
ok: true;
|
|
38
|
+
value: Metadata;
|
|
39
|
+
}> | Readonly<{
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: string;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Where the file goes, refusing any path that would leave this repository.
|
|
45
|
+
*
|
|
46
|
+
* The path comes off the server's answer, and the whole point of this command is
|
|
47
|
+
* that it writes a file the person did not type. A server that answered with
|
|
48
|
+
* `../../etc/something` would otherwise have this command write there, so the
|
|
49
|
+
* resolved path has to stay under the directory the command was run in.
|
|
50
|
+
*/
|
|
51
|
+
export declare function metadataDestination(cwd: string, metadataPath: string): Readonly<{
|
|
52
|
+
ok: true;
|
|
53
|
+
path: string;
|
|
54
|
+
}> | Readonly<{
|
|
55
|
+
ok: false;
|
|
56
|
+
reason: string;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Prepares the one-time qualification setup for one promise, and writes the file
|
|
60
|
+
* where the sealed run reads it.
|
|
61
|
+
*
|
|
62
|
+
* It runs over this repository's own agent connection, which is what makes it
|
|
63
|
+
* usable at all: preparing a qualification setup is the agent's job, and the
|
|
64
|
+
* setup session that paired the machine expires. There is no sign-off here and
|
|
65
|
+
* no code to paste. Agreeing the meaning was the person's act; building the
|
|
66
|
+
* check that proves it is this command's caller's.
|
|
67
|
+
*
|
|
68
|
+
* The file is written once. A second run while nobody has published against the
|
|
69
|
+
* first packet is refused, naming who prepared it and when, and `--again` mints
|
|
70
|
+
* a replacement that invalidates the earlier one on the server as well as
|
|
71
|
+
* overwriting the file here.
|
|
72
|
+
*/
|
|
73
|
+
export declare function runPrepare(options: PrepareOptions): Promise<number>;
|
|
74
|
+
export {};
|