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,32 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
const REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
4
|
+
const HOST_HINT_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
|
|
5
|
+
/**
|
|
6
|
+
* The two display hints, and nothing else. They exist so a person can recognise
|
|
7
|
+
* their own pairing on the approval page, and the server refuses anything that
|
|
8
|
+
* is not the shape of an owner/name or a hostname, so a hint can never be
|
|
9
|
+
* written to read like Balladeer's own assertion.
|
|
10
|
+
*/
|
|
11
|
+
export function repositoryHint(cwd = process.cwd()) {
|
|
12
|
+
let remote;
|
|
13
|
+
try {
|
|
14
|
+
remote = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
}).trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "unknown/unknown";
|
|
21
|
+
}
|
|
22
|
+
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(remote);
|
|
23
|
+
if (!match)
|
|
24
|
+
return "unknown/unknown";
|
|
25
|
+
const hint = `${match[1]}/${match[2]}`;
|
|
26
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : "unknown/unknown";
|
|
27
|
+
}
|
|
28
|
+
export function hostHint() {
|
|
29
|
+
const raw = hostname().split(".")[0] ?? "";
|
|
30
|
+
const cleaned = raw.replace(/[^A-Za-z0-9.-]/g, "").slice(0, 63);
|
|
31
|
+
return HOST_HINT_PATTERN.test(cleaned) ? cleaned : "unknown";
|
|
32
|
+
}
|
package/dist/seals.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a promise's sealed files live, and where the package that seals them
|
|
3
|
+
* lives. Both are fixed by the runner: `materials[].path` is refused unless it
|
|
4
|
+
* starts with `.continuity/promises/<promise-id>/`, and the sealed package is
|
|
5
|
+
* written to `.continuity/packages/<promise-id>.json`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PROMISE_TREE_ROOT = ".continuity/promises";
|
|
8
|
+
export declare const PACKAGE_DIRECTORY = ".continuity/packages";
|
|
9
|
+
/** How many sealed directories one report names before it says how many more. */
|
|
10
|
+
export declare const SEALED_PATHS_SHOWN = 10;
|
|
11
|
+
/**
|
|
12
|
+
* One promise's seal, as this checkout carries it.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is read out of the customer's own sealed package file. None
|
|
15
|
+
* of it is a judgement about whether the seal still holds: that verdict comes
|
|
16
|
+
* from the pinned runner, which is the only thing entitled to give it.
|
|
17
|
+
*/
|
|
18
|
+
export type SealedPromise = Readonly<{
|
|
19
|
+
promiseId: string;
|
|
20
|
+
/** The directory whose every file the package digest-locks, with a trailing slash. */
|
|
21
|
+
sealedPath: string;
|
|
22
|
+
/** The sealed package file, repository-relative. */
|
|
23
|
+
packageFile: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
owner?: string;
|
|
26
|
+
promiseUrl?: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Every promise this checkout has a sealed package for, in id order.
|
|
30
|
+
*
|
|
31
|
+
* It reads the package files and nothing else, so it works with no network, no
|
|
32
|
+
* credential, and no runner checkout. A file that is not a readable sealed
|
|
33
|
+
* package is skipped rather than reported: a half-written draft in that
|
|
34
|
+
* directory is not a seal, and refusing to say anything about the other
|
|
35
|
+
* promises because of it would be worse than leaving it out.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sealedPromises(repositoryRoot: string): SealedPromise[];
|
|
38
|
+
/**
|
|
39
|
+
* Which promise a changed path belongs to, or nothing when the path is
|
|
40
|
+
* ordinary source.
|
|
41
|
+
*
|
|
42
|
+
* Two paths belong to a promise: a file inside its sealed directory, whose
|
|
43
|
+
* bytes the package locks, and the sealed package itself, which is what a
|
|
44
|
+
* reseal rewrites. A change to either one is the change worth checking; every
|
|
45
|
+
* other path in a repository is somebody's ordinary work and this says so by
|
|
46
|
+
* answering nothing.
|
|
47
|
+
*/
|
|
48
|
+
export declare function promiseForPath(path: string): string | undefined;
|
package/dist/seals.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Where a promise's sealed files live, and where the package that seals them
|
|
5
|
+
* lives. Both are fixed by the runner: `materials[].path` is refused unless it
|
|
6
|
+
* starts with `.continuity/promises/<promise-id>/`, and the sealed package is
|
|
7
|
+
* written to `.continuity/packages/<promise-id>.json`.
|
|
8
|
+
*/
|
|
9
|
+
export const PROMISE_TREE_ROOT = ".continuity/promises";
|
|
10
|
+
export const PACKAGE_DIRECTORY = ".continuity/packages";
|
|
11
|
+
/** How many sealed directories one report names before it says how many more. */
|
|
12
|
+
export const SEALED_PATHS_SHOWN = 10;
|
|
13
|
+
/** A promise id as the runner spells it. Anything else is not one of ours. */
|
|
14
|
+
const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
|
|
15
|
+
function readableString(value, limit) {
|
|
16
|
+
if (typeof value !== "string")
|
|
17
|
+
return undefined;
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (trimmed === "")
|
|
20
|
+
return undefined;
|
|
21
|
+
// Bounded, and stripped of anything that could reflow a terminal: this text
|
|
22
|
+
// came out of a file in the customer's repository and is about to be printed.
|
|
23
|
+
return trimmed.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Every promise this checkout has a sealed package for, in id order.
|
|
27
|
+
*
|
|
28
|
+
* It reads the package files and nothing else, so it works with no network, no
|
|
29
|
+
* credential, and no runner checkout. A file that is not a readable sealed
|
|
30
|
+
* package is skipped rather than reported: a half-written draft in that
|
|
31
|
+
* directory is not a seal, and refusing to say anything about the other
|
|
32
|
+
* promises because of it would be worse than leaving it out.
|
|
33
|
+
*/
|
|
34
|
+
export function sealedPromises(repositoryRoot) {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(join(repositoryRoot, PACKAGE_DIRECTORY));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const entry of entries.sort()) {
|
|
44
|
+
if (!entry.endsWith(".json"))
|
|
45
|
+
continue;
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(join(repositoryRoot, PACKAGE_DIRECTORY, entry), "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const promise = parsed.promise;
|
|
54
|
+
const attribution = parsed.attribution;
|
|
55
|
+
const promiseId = typeof promise?.id === "string" ? promise.id : undefined;
|
|
56
|
+
if (promiseId === undefined || !PROMISE_ID.test(promiseId))
|
|
57
|
+
continue;
|
|
58
|
+
if (!Array.isArray(parsed.materials) || parsed.materials.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
found.push({
|
|
61
|
+
promiseId,
|
|
62
|
+
sealedPath: `${PROMISE_TREE_ROOT}/${promiseId}/`,
|
|
63
|
+
packageFile: `${PACKAGE_DIRECTORY}/${entry}`,
|
|
64
|
+
...(readableString(promise?.title, 160) === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { title: readableString(promise?.title, 160) }),
|
|
67
|
+
...(readableString(attribution?.owner, 160) === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { owner: readableString(attribution?.owner, 160) }),
|
|
70
|
+
...(readableString(attribution?.promiseUrl, 300) === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { promiseUrl: readableString(attribution?.promiseUrl, 300) }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return found.sort((left, right) => left.promiseId.localeCompare(right.promiseId));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Which promise a changed path belongs to, or nothing when the path is
|
|
79
|
+
* ordinary source.
|
|
80
|
+
*
|
|
81
|
+
* Two paths belong to a promise: a file inside its sealed directory, whose
|
|
82
|
+
* bytes the package locks, and the sealed package itself, which is what a
|
|
83
|
+
* reseal rewrites. A change to either one is the change worth checking; every
|
|
84
|
+
* other path in a repository is somebody's ordinary work and this says so by
|
|
85
|
+
* answering nothing.
|
|
86
|
+
*/
|
|
87
|
+
export function promiseForPath(path) {
|
|
88
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
89
|
+
const treePrefix = `${PROMISE_TREE_ROOT}/`;
|
|
90
|
+
const packagePrefix = `${PACKAGE_DIRECTORY}/`;
|
|
91
|
+
if (normalized.startsWith(treePrefix)) {
|
|
92
|
+
const rest = normalized.slice(treePrefix.length);
|
|
93
|
+
const slash = rest.indexOf("/");
|
|
94
|
+
// A file directly under the tree root belongs to no promise: the id is a
|
|
95
|
+
// directory, and something dropped beside those directories is not sealed.
|
|
96
|
+
if (slash > 0 && rest.length > slash + 1) {
|
|
97
|
+
const id = rest.slice(0, slash);
|
|
98
|
+
if (PROMISE_ID.test(id))
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
if (normalized.startsWith(packagePrefix)) {
|
|
104
|
+
const rest = normalized.slice(packagePrefix.length);
|
|
105
|
+
if (rest.endsWith(".json") && !rest.slice(0, -5).includes("/")) {
|
|
106
|
+
const id = rest.slice(0, -5);
|
|
107
|
+
if (PROMISE_ID.test(id))
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The id that ties what an agent was told to what it then wrote.
|
|
3
|
+
*
|
|
4
|
+
* A retrieval read happens before the commit exists, so nothing joins the two
|
|
5
|
+
* on its own. The agent's own working session is what spans them: it reads the
|
|
6
|
+
* index under this id, and writes the same id into the commit trailer. That is
|
|
7
|
+
* the whole mechanism, and it lives here rather than on the server because the
|
|
8
|
+
* server never sees a commit message.
|
|
9
|
+
*
|
|
10
|
+
* The id is opaque and says nothing about the work. It is not a secret either:
|
|
11
|
+
* it is written into commit messages, which is exactly where people will read
|
|
12
|
+
* it. What it must be is stable across one piece of work and different across
|
|
13
|
+
* two, which is what the marker file below is for.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The shape of a session id, and the line that carries it into a commit.
|
|
17
|
+
*
|
|
18
|
+
* Written out here rather than imported. This command is published to npm on
|
|
19
|
+
* its own and depends on nothing, so the grammar it uses has to live inside it;
|
|
20
|
+
* a packaging test compares these three against the control plane's own
|
|
21
|
+
* definitions, so a drift fails here rather than as a token the server refuses
|
|
22
|
+
* out of a customer's commit message.
|
|
23
|
+
*/
|
|
24
|
+
export declare const AGENT_SESSION_ID_PATTERN: RegExp;
|
|
25
|
+
/** The trailer key an agent writes into the commit or the pull-request body. */
|
|
26
|
+
export declare const AGENT_SESSION_TRAILER = "Balladeer-Session";
|
|
27
|
+
/** The exact line to paste, so every carrier spells the trailer one way. */
|
|
28
|
+
export declare function agentSessionTrailerLine(sessionId: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* The session id a commit message or pull-request body carries, if it carries
|
|
31
|
+
* one this release would recognise.
|
|
32
|
+
*
|
|
33
|
+
* A body carrying two different session ids is refused rather than resolved:
|
|
34
|
+
* two answers to "which session wrote this" is not one answer, and guessing
|
|
35
|
+
* which of them to record would put an invented join in front of a number.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readAgentSessionTrailer(text: string): string | undefined;
|
|
38
|
+
/** Where the current session for a repository is remembered. */
|
|
39
|
+
export declare const SESSION_FILE = "sessions.json";
|
|
40
|
+
/**
|
|
41
|
+
* How long one session id stays current.
|
|
42
|
+
*
|
|
43
|
+
* A working session is a sitting, not a calendar day. Twelve hours is long
|
|
44
|
+
* enough that a morning's work and the commit that ends it share an id, and
|
|
45
|
+
* short enough that a machine left running overnight starts the next day's work
|
|
46
|
+
* under a new one rather than attributing tomorrow's commits to yesterday's
|
|
47
|
+
* reads. `--new` is the manual answer for anyone whose sitting ends earlier.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SESSION_LIFETIME_MS: number;
|
|
50
|
+
export type SessionMark = Readonly<{
|
|
51
|
+
/** Which repository this session belongs to, as the credential names it. */
|
|
52
|
+
repository: string;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
}>;
|
|
56
|
+
type SessionFile = {
|
|
57
|
+
version: 1;
|
|
58
|
+
sessions: SessionMark[];
|
|
59
|
+
};
|
|
60
|
+
/** A fresh id: the prefix a person can recognise, and 128 bits of nothing else. */
|
|
61
|
+
export declare function mintSessionId(): string;
|
|
62
|
+
export declare function isSessionId(value: string): boolean;
|
|
63
|
+
export declare function readSessionMarks(environment?: NodeJS.ProcessEnv): SessionFile;
|
|
64
|
+
export type CurrentSession = Readonly<{
|
|
65
|
+
sessionId: string;
|
|
66
|
+
startedAt: string;
|
|
67
|
+
/** True when this call minted it, false when it was already current. */
|
|
68
|
+
minted: boolean;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* The session id for this repository right now, minting one when there is none
|
|
72
|
+
* current.
|
|
73
|
+
*
|
|
74
|
+
* Reusing a live one is the point. An agent that asks twice in one sitting must
|
|
75
|
+
* get the same answer, or its reads and its commit end up under two ids and the
|
|
76
|
+
* join it exists to make is broken by the act of asking for it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function currentSession(input: Readonly<{
|
|
79
|
+
repository: string;
|
|
80
|
+
now: string;
|
|
81
|
+
forceNew?: boolean;
|
|
82
|
+
environment?: NodeJS.ProcessEnv;
|
|
83
|
+
}>): CurrentSession;
|
|
84
|
+
export {};
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertSafeStoreLocation, configHome, writeStoreFile } from "./store.js";
|
|
5
|
+
/**
|
|
6
|
+
* The id that ties what an agent was told to what it then wrote.
|
|
7
|
+
*
|
|
8
|
+
* A retrieval read happens before the commit exists, so nothing joins the two
|
|
9
|
+
* on its own. The agent's own working session is what spans them: it reads the
|
|
10
|
+
* index under this id, and writes the same id into the commit trailer. That is
|
|
11
|
+
* the whole mechanism, and it lives here rather than on the server because the
|
|
12
|
+
* server never sees a commit message.
|
|
13
|
+
*
|
|
14
|
+
* The id is opaque and says nothing about the work. It is not a secret either:
|
|
15
|
+
* it is written into commit messages, which is exactly where people will read
|
|
16
|
+
* it. What it must be is stable across one piece of work and different across
|
|
17
|
+
* two, which is what the marker file below is for.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The shape of a session id, and the line that carries it into a commit.
|
|
21
|
+
*
|
|
22
|
+
* Written out here rather than imported. This command is published to npm on
|
|
23
|
+
* its own and depends on nothing, so the grammar it uses has to live inside it;
|
|
24
|
+
* a packaging test compares these three against the control plane's own
|
|
25
|
+
* definitions, so a drift fails here rather than as a token the server refuses
|
|
26
|
+
* out of a customer's commit message.
|
|
27
|
+
*/
|
|
28
|
+
export const AGENT_SESSION_ID_PATTERN = /^bs_[0-9a-f]{32}$/;
|
|
29
|
+
/** The trailer key an agent writes into the commit or the pull-request body. */
|
|
30
|
+
export const AGENT_SESSION_TRAILER = "Balladeer-Session";
|
|
31
|
+
/** The exact line to paste, so every carrier spells the trailer one way. */
|
|
32
|
+
export function agentSessionTrailerLine(sessionId) {
|
|
33
|
+
return `${AGENT_SESSION_TRAILER}: ${sessionId}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The session id a commit message or pull-request body carries, if it carries
|
|
37
|
+
* one this release would recognise.
|
|
38
|
+
*
|
|
39
|
+
* A body carrying two different session ids is refused rather than resolved:
|
|
40
|
+
* two answers to "which session wrote this" is not one answer, and guessing
|
|
41
|
+
* which of them to record would put an invented join in front of a number.
|
|
42
|
+
*/
|
|
43
|
+
export function readAgentSessionTrailer(text) {
|
|
44
|
+
const pattern = new RegExp(`^[ \\t]*${AGENT_SESSION_TRAILER}[ \\t]*:[ \\t]*(\\S+)[ \\t]*$`, "gim");
|
|
45
|
+
const found = new Set();
|
|
46
|
+
for (const match of text.matchAll(pattern)) {
|
|
47
|
+
const value = match[1];
|
|
48
|
+
if (value !== undefined && isSessionId(value))
|
|
49
|
+
found.add(value);
|
|
50
|
+
}
|
|
51
|
+
if (found.size !== 1)
|
|
52
|
+
return undefined;
|
|
53
|
+
return [...found][0];
|
|
54
|
+
}
|
|
55
|
+
/** Where the current session for a repository is remembered. */
|
|
56
|
+
export const SESSION_FILE = "sessions.json";
|
|
57
|
+
/**
|
|
58
|
+
* How long one session id stays current.
|
|
59
|
+
*
|
|
60
|
+
* A working session is a sitting, not a calendar day. Twelve hours is long
|
|
61
|
+
* enough that a morning's work and the commit that ends it share an id, and
|
|
62
|
+
* short enough that a machine left running overnight starts the next day's work
|
|
63
|
+
* under a new one rather than attributing tomorrow's commits to yesterday's
|
|
64
|
+
* reads. `--new` is the manual answer for anyone whose sitting ends earlier.
|
|
65
|
+
*/
|
|
66
|
+
export const SESSION_LIFETIME_MS = 12 * 60 * 60 * 1000;
|
|
67
|
+
const EMPTY = { version: 1, sessions: [] };
|
|
68
|
+
/** A fresh id: the prefix a person can recognise, and 128 bits of nothing else. */
|
|
69
|
+
export function mintSessionId() {
|
|
70
|
+
return `bs_${randomBytes(16).toString("hex")}`;
|
|
71
|
+
}
|
|
72
|
+
export function isSessionId(value) {
|
|
73
|
+
return AGENT_SESSION_ID_PATTERN.test(value);
|
|
74
|
+
}
|
|
75
|
+
function sessionPath(environment) {
|
|
76
|
+
return join(configHome(environment), SESSION_FILE);
|
|
77
|
+
}
|
|
78
|
+
export function readSessionMarks(environment = process.env) {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = readFileSync(sessionPath(environment), "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { version: 1, sessions: [] };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
|
|
89
|
+
// A marker whose id this build would not recognise is dropped rather than
|
|
90
|
+
// repaired. It can only have come from a copy of this command that spelled
|
|
91
|
+
// ids differently, and reusing one would put a token the server refuses
|
|
92
|
+
// into a commit message where nobody would ever look for the cause.
|
|
93
|
+
return { version: 1, sessions: sessions.filter((mark) => isSessionId(mark.sessionId)) };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { ...EMPTY, sessions: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function writeSessionMarks(file, environment) {
|
|
100
|
+
assertSafeStoreLocation(configHome(environment), environment);
|
|
101
|
+
writeStoreFile(SESSION_FILE, `${JSON.stringify(file, null, 2)}\n`, environment);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The session id for this repository right now, minting one when there is none
|
|
105
|
+
* current.
|
|
106
|
+
*
|
|
107
|
+
* Reusing a live one is the point. An agent that asks twice in one sitting must
|
|
108
|
+
* get the same answer, or its reads and its commit end up under two ids and the
|
|
109
|
+
* join it exists to make is broken by the act of asking for it.
|
|
110
|
+
*/
|
|
111
|
+
export function currentSession(input) {
|
|
112
|
+
const environment = input.environment ?? process.env;
|
|
113
|
+
const file = readSessionMarks(environment);
|
|
114
|
+
const existing = file.sessions.find((mark) => mark.repository === input.repository);
|
|
115
|
+
const age = existing === undefined ? undefined : Date.parse(input.now) - Date.parse(existing.startedAt);
|
|
116
|
+
const usable = existing !== undefined &&
|
|
117
|
+
input.forceNew !== true &&
|
|
118
|
+
age !== undefined &&
|
|
119
|
+
Number.isFinite(age) &&
|
|
120
|
+
age >= 0 &&
|
|
121
|
+
age < SESSION_LIFETIME_MS;
|
|
122
|
+
if (usable && existing !== undefined) {
|
|
123
|
+
return { sessionId: existing.sessionId, startedAt: existing.startedAt, minted: false };
|
|
124
|
+
}
|
|
125
|
+
const minted = {
|
|
126
|
+
repository: input.repository,
|
|
127
|
+
sessionId: mintSessionId(),
|
|
128
|
+
startedAt: input.now,
|
|
129
|
+
};
|
|
130
|
+
writeSessionMarks({
|
|
131
|
+
version: 1,
|
|
132
|
+
sessions: [...file.sessions.filter((mark) => mark.repository !== input.repository), minted],
|
|
133
|
+
}, environment);
|
|
134
|
+
return { sessionId: minted.sessionId, startedAt: minted.startedAt, minted: true };
|
|
135
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { DelegatedScope } from "./wire.js";
|
|
2
|
+
export declare class StoreError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
constructor(code: string, message: string);
|
|
5
|
+
}
|
|
6
|
+
export type PendingPairing = Readonly<{
|
|
7
|
+
controlPlane: string;
|
|
8
|
+
pairingId: string;
|
|
9
|
+
userCode: string;
|
|
10
|
+
verifier: string;
|
|
11
|
+
verificationUri: string;
|
|
12
|
+
expiresAt: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
}>;
|
|
15
|
+
export type StoredSession = Readonly<{
|
|
16
|
+
controlPlane: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
token: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
workspaceName: string;
|
|
21
|
+
workspaceSlug: string;
|
|
22
|
+
/**
|
|
23
|
+
* The membership that approved this pairing. It is what lets `discover` name
|
|
24
|
+
* an owner on every promise it files without asking a person to look an id up.
|
|
25
|
+
* Optional because a store written by a copy of this command that predates the
|
|
26
|
+
* field has none, and an entry without one makes `discover` say so rather than
|
|
27
|
+
* guess at an owner.
|
|
28
|
+
*/
|
|
29
|
+
membershipId?: string;
|
|
30
|
+
membershipDisplayName: string;
|
|
31
|
+
scopes: readonly DelegatedScope[];
|
|
32
|
+
expiresAt: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* One repository-bound agent credential, written when setup issues it.
|
|
36
|
+
*
|
|
37
|
+
* `balladeer mcp` refuses an entry whose `mcpUrl` origin differs from its
|
|
38
|
+
* `controlPlane` origin, so a server response can never redirect this bearer to
|
|
39
|
+
* a host the person never paired with.
|
|
40
|
+
*/
|
|
41
|
+
export type StoredAgent = Readonly<{
|
|
42
|
+
controlPlane: string;
|
|
43
|
+
workspaceId: string;
|
|
44
|
+
/**
|
|
45
|
+
* The repository this credential reaches, by id and by the owner/name a person
|
|
46
|
+
* reads. The name is what lets `balladeer mcp` pick the credential belonging
|
|
47
|
+
* to the directory it was started in rather than whichever one happens to be
|
|
48
|
+
* stored first. It is absent only in a store written by a copy of this command
|
|
49
|
+
* that predates the field, and an entry without it simply never matches a
|
|
50
|
+
* directory, which is a refusal rather than a wrong answer.
|
|
51
|
+
*/
|
|
52
|
+
repositoryId: string;
|
|
53
|
+
repository?: string;
|
|
54
|
+
connectionId: string;
|
|
55
|
+
mcpUrl: string;
|
|
56
|
+
token: string;
|
|
57
|
+
}>;
|
|
58
|
+
export type Credentials = {
|
|
59
|
+
version: 1;
|
|
60
|
+
pendingPairings: PendingPairing[];
|
|
61
|
+
sessions: StoredSession[];
|
|
62
|
+
agents: StoredAgent[];
|
|
63
|
+
};
|
|
64
|
+
export declare function configHome(environment?: NodeJS.ProcessEnv): string;
|
|
65
|
+
export declare function credentialsPath(environment?: NodeJS.ProcessEnv): string;
|
|
66
|
+
/**
|
|
67
|
+
* The credential must not land somewhere a commit would pick it up.
|
|
68
|
+
*
|
|
69
|
+
* An explicitly chosen location, or one under the current working directory, is
|
|
70
|
+
* refused outright the moment it sits in a work tree. The default
|
|
71
|
+
* `~/.config/balladeer` is only refused when git says the file would actually be
|
|
72
|
+
* tracked, because a great many developers keep their dotfiles in a repository
|
|
73
|
+
* and refusing all of them would brick setup for no gain.
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertSafeStoreLocation(directory: string, environment?: NodeJS.ProcessEnv, cwd?: string): void;
|
|
76
|
+
export declare function ensureStoreDirectory(environment?: NodeJS.ProcessEnv): string;
|
|
77
|
+
export declare function readCredentials(environment?: NodeJS.ProcessEnv): Credentials;
|
|
78
|
+
/**
|
|
79
|
+
* Atomic, and never through a path an attacker could have pre-planted: the
|
|
80
|
+
* temporary file is opened with `wx` (fail if it exists) under a random name,
|
|
81
|
+
* its mode is set on the open descriptor, and it is flushed before the rename.
|
|
82
|
+
*/
|
|
83
|
+
export declare function writeCredentials(credentials: Credentials, environment?: NodeJS.ProcessEnv): void;
|
|
84
|
+
/**
|
|
85
|
+
* One private file in the store directory, written the same careful way the
|
|
86
|
+
* credential is.
|
|
87
|
+
*
|
|
88
|
+
* Pulled out of `writeCredentials` rather than copied beside it: the session
|
|
89
|
+
* marker written next to the credential is not a secret, but it is written into
|
|
90
|
+
* the same directory by the same command, and a second, sloppier writer there
|
|
91
|
+
* would be the one an attacker pre-plants a symlink for.
|
|
92
|
+
*/
|
|
93
|
+
export declare function writeStoreFile(fileName: string, contents: string, environment?: NodeJS.ProcessEnv): void;
|
|
94
|
+
/** Origins, not URL strings, so `https://host/../` cannot alias a stored entry. */
|
|
95
|
+
export declare function normalizeControlPlane(value: string): string;
|
|
96
|
+
/** At most one pending pairing per control plane, newest wins. */
|
|
97
|
+
export declare function putPendingPairing(credentials: Credentials, pending: PendingPairing): Credentials;
|
|
98
|
+
export declare function findPendingPairing(credentials: Credentials, controlPlane: string): PendingPairing | undefined;
|
|
99
|
+
export declare function dropPendingPairing(credentials: Credentials, controlPlane: string): Credentials;
|
|
100
|
+
export declare function putSession(credentials: Credentials, session: StoredSession): Credentials;
|
|
101
|
+
export declare function findSession(credentials: Credentials, controlPlane: string): StoredSession | undefined;
|
|
102
|
+
export declare function dropSession(credentials: Credentials, controlPlane: string): Credentials;
|
|
103
|
+
/**
|
|
104
|
+
* One agent credential per repository per control plane, newest wins. Keyed on
|
|
105
|
+
* both, because a workspace holds many repositories and each one's connection
|
|
106
|
+
* reaches exactly that repository.
|
|
107
|
+
*/
|
|
108
|
+
export declare function putAgent(credentials: Credentials, agent: StoredAgent): Credentials;
|