heedvane 0.0.0-stage → 0.1.0
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 +27 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +32 -0
- package/dist/dispatchers/posix.js +191 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +316 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +67 -0
- package/dist/heed-file.js +295 -0
- package/dist/listener/endpoint.d.ts +21 -0
- package/dist/listener/endpoint.js +112 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +131 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +34 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this machine calls Heedvane. A developer signs in with `npx heedvane login` (browser OAuth,
|
|
3
|
+
* src/backend/login.ts); CI and headless machines use an organization API key (`hv_…`) saved with
|
|
4
|
+
* `npx heedvane auth org` in `<root>/credentials.json`, mode 0600, kept apart from heed.yaml (a file
|
|
5
|
+
* people share). A machine uses one of them: the login when there is one, else the key.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import writeFileAtomic from "write-file-atomic";
|
|
10
|
+
import { isRecord, sha256Hex } from "../guards.js";
|
|
11
|
+
import { loginTokenFor, readLogin } from "./login.js";
|
|
12
|
+
export function credentialsPath(root) {
|
|
13
|
+
return join(root, "credentials.json");
|
|
14
|
+
}
|
|
15
|
+
/** The stored key; empty when none was saved yet. */
|
|
16
|
+
async function readCredentials(root) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = await readFile(credentialsPath(root), "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT")
|
|
23
|
+
return {};
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
const value = JSON.parse(text);
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
throw new Error(`${credentialsPath(root)} is not a JSON object`);
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const kind of ["org"]) {
|
|
31
|
+
if (typeof value[kind] === "string" && value[kind])
|
|
32
|
+
out[kind] = value[kind];
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Store one key beside the others, 0600. */
|
|
37
|
+
export async function saveCredential(root, kind, key) {
|
|
38
|
+
const next = { ...(await readCredentials(root)), [kind]: key };
|
|
39
|
+
await writeFileAtomic(credentialsPath(root), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
/** Heedvane at `url` when this machine has a sign-in or a key now, else null. The credential is read again on every call. */
|
|
42
|
+
export async function backendAt(root, url, options = {}) {
|
|
43
|
+
if ((await signInIdentity(root, url)) === null)
|
|
44
|
+
return null;
|
|
45
|
+
return {
|
|
46
|
+
url,
|
|
47
|
+
identity: () => signInIdentity(root, url),
|
|
48
|
+
credential: () => credentialAt(root, url, options),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The current sign-in's identity: the saved login's organization (else its client) at `url`, else the
|
|
53
|
+
* saved key's fingerprint; null with neither, or with a login or key file that cannot be read. Read
|
|
54
|
+
* now, so a login to another organization changes it at once, also for a listener started with a key.
|
|
55
|
+
*/
|
|
56
|
+
export async function signInIdentity(root, url) {
|
|
57
|
+
return (await currentSignIn(root, url)).identity;
|
|
58
|
+
}
|
|
59
|
+
/** One read of the current sign-in (the login, else the key) and the identity that names it. */
|
|
60
|
+
async function currentSignIn(root, url) {
|
|
61
|
+
const login = await readLogin(root).catch(() => undefined);
|
|
62
|
+
if (login === undefined)
|
|
63
|
+
return { identity: null }; // unreadable: no sign-in to name, nothing is sent
|
|
64
|
+
if (login !== null) {
|
|
65
|
+
return login.url === url ? { identity: `${url}|login|${login.organizationId ?? `client:${login.clientId}`}`, login } : { identity: null };
|
|
66
|
+
}
|
|
67
|
+
const { org } = await readCredentials(root).catch(() => ({}));
|
|
68
|
+
return org === undefined ? { identity: null } : { identity: `${url}|key|${sha256Hex(org).slice(0, 16)}`, key: org };
|
|
69
|
+
}
|
|
70
|
+
/** The bearer and the identity of one and the same read of the sign-in. */
|
|
71
|
+
async function credentialAt(root, url, options) {
|
|
72
|
+
const { identity, login, key } = await currentSignIn(root, url);
|
|
73
|
+
if (key !== undefined)
|
|
74
|
+
return { identity, bearer: key };
|
|
75
|
+
return { identity, bearer: login === undefined ? null : await loginTokenFor(root, login, options) };
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface LoginTokens {
|
|
2
|
+
url: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
accessToken: string;
|
|
5
|
+
refreshToken: string;
|
|
6
|
+
/** Epoch milliseconds. */
|
|
7
|
+
expiresAt: number;
|
|
8
|
+
/** The organization chosen on the consent page (the token's organization claim), when present. */
|
|
9
|
+
organizationId?: string;
|
|
10
|
+
}
|
|
11
|
+
/** How a login reaches the person: where to send the browser, and progress lines. */
|
|
12
|
+
export interface LoginUi {
|
|
13
|
+
openBrowser: (url: string) => void;
|
|
14
|
+
out: (line: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function readLogin(root: string): Promise<LoginTokens | null>;
|
|
17
|
+
/** The lock directory that serializes writes of `login.json` across processes. */
|
|
18
|
+
export declare function loginLockPath(root: string): string;
|
|
19
|
+
/** Sign in through the browser and save the tokens; resolves with the saved login. */
|
|
20
|
+
export declare function login(root: string, url: string, ui: LoginUi): Promise<LoginTokens>;
|
|
21
|
+
export interface LoginTokenOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Wait for a refresh (commands). The listener does not: a hook never waits on it, so an expiring
|
|
24
|
+
* token is used while it lasts, and an expired one means no call until the background refresh lands.
|
|
25
|
+
*/
|
|
26
|
+
wait?: boolean;
|
|
27
|
+
/** Where a failed refresh is said, once per backoff. */
|
|
28
|
+
log?: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
/** A current access token for `url`, refreshed before it expires; null without a usable login. */
|
|
31
|
+
export declare function loginToken(root: string, url: string, options?: LoginTokenOptions): Promise<string | null>;
|
|
32
|
+
/** {@link loginToken} for a login the caller has already read, so its identity and token are one snapshot. */
|
|
33
|
+
export declare function loginTokenFor(root: string, saved: LoginTokens, options?: LoginTokenOptions): Promise<string | null>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx heedvane login`: the developer signs in to Heedvane in the browser through the same OAuth server
|
|
3
|
+
* the Heedvane MCP uses (authorization code with PKCE S256, a dynamically registered public client and
|
|
4
|
+
* a loopback redirect, RFC 8252), and picks the organization on Heedvane's consent page. The tokens are
|
|
5
|
+
* kept in `<root>/login.json`, mode 0600, and refreshed before they expire. Refresh tokens rotate, so
|
|
6
|
+
* every refresh saves the new one, and one listener refreshes at a time.
|
|
7
|
+
*
|
|
8
|
+
* The access token is a JWT for the resource `<heedvane.url>/api/mcp`, bound to one organization. The
|
|
9
|
+
* `/api/agent` routes accept it once Heedvane verifies MCP grants there; until then they answer 401,
|
|
10
|
+
* which `login` and the listener report. While signed in, a saved organization key is not used (src/backend/credentials.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { mkdir, readFile, rmdir, stat } from "node:fs/promises";
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
17
|
+
import writeFileAtomic from "write-file-atomic";
|
|
18
|
+
import { errorMessage, isRecord } from "../guards.js";
|
|
19
|
+
const ORGANIZATION_CLAIM = "https://heedvane.com/mcp/organization";
|
|
20
|
+
/** A token this close to expiry is refreshed first. */
|
|
21
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
22
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
24
|
+
function loginPath(root) {
|
|
25
|
+
return join(root, "login.json");
|
|
26
|
+
}
|
|
27
|
+
export async function readLogin(root) {
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = await readFile(loginPath(root), "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err.code === "ENOENT")
|
|
34
|
+
return null;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
const value = JSON.parse(text);
|
|
38
|
+
if (!isRecord(value) || typeof value["accessToken"] !== "string" || typeof value["refreshToken"] !== "string") {
|
|
39
|
+
throw new Error(`${loginPath(root)} is not a saved login: run npx heedvane login again`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
async function saveLogin(root, tokens) {
|
|
44
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
45
|
+
await writeFileAtomic(loginPath(root), `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
/** The lock directory that serializes writes of `login.json` across processes. */
|
|
48
|
+
export function loginLockPath(root) {
|
|
49
|
+
return join(root, "login.lock");
|
|
50
|
+
}
|
|
51
|
+
const LOGIN_LOCK_STALE_MS = 10_000;
|
|
52
|
+
const LOGIN_LOCK_WAIT_MS = 5_000;
|
|
53
|
+
const LOGIN_LOCK_POLL_MS = 25;
|
|
54
|
+
/**
|
|
55
|
+
* Run `work` holding the login lock (a directory, created atomically). A login in one process and a
|
|
56
|
+
* refresh in another both write `login.json`; the refresh compares what it read against what is there
|
|
57
|
+
* and writes, and the login writes, so neither may land between the other's compare and write. A lock
|
|
58
|
+
* older than 10 s was left by a crashed process and is taken over. shortcut: two processes reclaiming
|
|
59
|
+
* the same stale lock in the same instant can both proceed, which needs a crash plus that coincidence;
|
|
60
|
+
* upgrade to a lock library if it is ever seen.
|
|
61
|
+
*/
|
|
62
|
+
async function withLoginLock(root, work) {
|
|
63
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
|
+
const lock = loginLockPath(root);
|
|
65
|
+
const giveUpAt = Date.now() + LOGIN_LOCK_WAIT_MS;
|
|
66
|
+
for (;;) {
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(lock);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err.code !== "EEXIST")
|
|
73
|
+
throw err;
|
|
74
|
+
const held = await stat(lock).catch(() => null);
|
|
75
|
+
if (held !== null && Date.now() - held.mtimeMs > LOGIN_LOCK_STALE_MS)
|
|
76
|
+
await rmdir(lock).catch(() => undefined);
|
|
77
|
+
else if (Date.now() > giveUpAt)
|
|
78
|
+
throw new Error(`${lock} is held by another heedvane-hook process; try again, or remove it if no heedvane-hook command is running`, { cause: err });
|
|
79
|
+
else
|
|
80
|
+
await sleep(LOGIN_LOCK_POLL_MS);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return await work();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await rmdir(lock).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Sign in through the browser and save the tokens; resolves with the saved login. */
|
|
91
|
+
export async function login(root, url, ui) {
|
|
92
|
+
const server = await authorizationServer(url);
|
|
93
|
+
const callback = await loopbackCallback();
|
|
94
|
+
try {
|
|
95
|
+
const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
|
|
96
|
+
const clientId = await registerClient(server.registration, redirectUri);
|
|
97
|
+
const verifier = base64Url(randomBytes(32));
|
|
98
|
+
const state = base64Url(randomBytes(16));
|
|
99
|
+
const authorize = new URL(server.authorization);
|
|
100
|
+
authorize.search = new URLSearchParams({
|
|
101
|
+
response_type: "code",
|
|
102
|
+
client_id: clientId,
|
|
103
|
+
redirect_uri: redirectUri,
|
|
104
|
+
scope: "offline_access",
|
|
105
|
+
code_challenge: base64Url(createHash("sha256").update(verifier).digest()),
|
|
106
|
+
code_challenge_method: "S256",
|
|
107
|
+
state,
|
|
108
|
+
resource: resourceOf(url),
|
|
109
|
+
}).toString();
|
|
110
|
+
ui.out(` Sign in to Heedvane in your browser. If it does not open, visit:\n ${authorize.toString()}`);
|
|
111
|
+
ui.openBrowser(authorize.toString());
|
|
112
|
+
const code = await callback.code(state);
|
|
113
|
+
const answer = await tokenRequest(server.token, { grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, resource: resourceOf(url) });
|
|
114
|
+
const tokens = { ...answer, url, clientId };
|
|
115
|
+
await withLoginLock(root, () => saveLogin(root, tokens));
|
|
116
|
+
return tokens;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
callback.server.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// One refresh at a time per process: a second refresh with the same (rotated, now revoked) refresh
|
|
123
|
+
// token would fail and could end the grant.
|
|
124
|
+
const refreshing = new Map();
|
|
125
|
+
/** Per heed root, until when a failed refresh is not retried. */
|
|
126
|
+
const refreshFailedUntil = new Map();
|
|
127
|
+
const REFRESH_BACKOFF_MS = 5 * 60_000;
|
|
128
|
+
/** A current access token for `url`, refreshed before it expires; null without a usable login. */
|
|
129
|
+
export async function loginToken(root, url, options = {}) {
|
|
130
|
+
const saved = await readLogin(root).catch(() => null);
|
|
131
|
+
return saved === null || saved.url !== url ? null : loginTokenFor(root, saved, options);
|
|
132
|
+
}
|
|
133
|
+
/** {@link loginToken} for a login the caller has already read, so its identity and token are one snapshot. */
|
|
134
|
+
export async function loginTokenFor(root, saved, options = {}) {
|
|
135
|
+
if (saved.expiresAt - REFRESH_MARGIN_MS > Date.now())
|
|
136
|
+
return saved.accessToken;
|
|
137
|
+
const refreshed = refreshOnce(root, saved, options.log);
|
|
138
|
+
if (options.wait !== false)
|
|
139
|
+
return refreshed;
|
|
140
|
+
return saved.expiresAt > Date.now() ? saved.accessToken : null;
|
|
141
|
+
}
|
|
142
|
+
function refreshOnce(root, saved, log) {
|
|
143
|
+
if ((refreshFailedUntil.get(root) ?? 0) > Date.now())
|
|
144
|
+
return Promise.resolve(null);
|
|
145
|
+
let pending = refreshing.get(root);
|
|
146
|
+
if (pending === undefined) {
|
|
147
|
+
pending = refresh(root, saved, log).finally(() => refreshing.delete(root));
|
|
148
|
+
refreshing.set(root, pending);
|
|
149
|
+
}
|
|
150
|
+
return pending;
|
|
151
|
+
}
|
|
152
|
+
async function refresh(root, saved, log) {
|
|
153
|
+
try {
|
|
154
|
+
const server = await authorizationServer(saved.url);
|
|
155
|
+
const answer = await tokenRequest(server.token, { grant_type: "refresh_token", refresh_token: saved.refreshToken, client_id: saved.clientId, resource: resourceOf(saved.url) });
|
|
156
|
+
// A login to another organization while this refresh ran must not be reverted to the old one: the
|
|
157
|
+
// compare and the write happen together under the lock a login's write also takes.
|
|
158
|
+
const tokens = await withLoginLock(root, async () => {
|
|
159
|
+
const current = await readLogin(root).catch(() => null);
|
|
160
|
+
if (current === null || current.refreshToken !== saved.refreshToken)
|
|
161
|
+
return null;
|
|
162
|
+
const refreshed = { ...saved, ...answer };
|
|
163
|
+
await saveLogin(root, refreshed);
|
|
164
|
+
return refreshed;
|
|
165
|
+
});
|
|
166
|
+
if (tokens === null)
|
|
167
|
+
return null;
|
|
168
|
+
refreshFailedUntil.delete(root);
|
|
169
|
+
return tokens.accessToken;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
// Expired or revoked, or Heedvane unreachable: no call carries the login until a later refresh or login.
|
|
173
|
+
refreshFailedUntil.set(root, Date.now() + REFRESH_BACKOFF_MS);
|
|
174
|
+
log?.(`[heedvane] could not refresh the sign-in (${errorMessage(err)}); retried in 5 minutes. If it keeps failing, run npx heedvane login`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function resourceOf(url) {
|
|
179
|
+
return `${url}/api/mcp`;
|
|
180
|
+
}
|
|
181
|
+
async function authorizationServer(url) {
|
|
182
|
+
const metadata = await getJson(`${url}/.well-known/oauth-authorization-server`);
|
|
183
|
+
const field = (name) => {
|
|
184
|
+
const value = metadata[name];
|
|
185
|
+
if (typeof value !== "string")
|
|
186
|
+
throw new Error(`Heedvane's OAuth metadata has no ${name}`);
|
|
187
|
+
return value;
|
|
188
|
+
};
|
|
189
|
+
return { authorization: field("authorization_endpoint"), token: field("token_endpoint"), registration: field("registration_endpoint") };
|
|
190
|
+
}
|
|
191
|
+
async function registerClient(registration, redirectUri) {
|
|
192
|
+
const answer = await postJson(registration, {
|
|
193
|
+
client_name: "heedvane-hooks",
|
|
194
|
+
redirect_uris: [redirectUri],
|
|
195
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
196
|
+
response_types: ["code"],
|
|
197
|
+
token_endpoint_auth_method: "none",
|
|
198
|
+
});
|
|
199
|
+
if (typeof answer["client_id"] !== "string")
|
|
200
|
+
throw new Error("Heedvane did not register the login client");
|
|
201
|
+
return answer["client_id"];
|
|
202
|
+
}
|
|
203
|
+
async function tokenRequest(token, form) {
|
|
204
|
+
const response = await fetch(token, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
205
|
+
const json = await response.json().catch(() => null);
|
|
206
|
+
if (!response.ok || !isRecord(json) || typeof json["access_token"] !== "string" || typeof json["refresh_token"] !== "string") {
|
|
207
|
+
throw new Error(`Heedvane refused the sign-in (${response.status}): ${JSON.stringify(json)}`);
|
|
208
|
+
}
|
|
209
|
+
const expiresIn = typeof json["expires_in"] === "number" ? json["expires_in"] : 3600;
|
|
210
|
+
const organizationId = organizationClaim(json["access_token"]);
|
|
211
|
+
return { accessToken: json["access_token"], refreshToken: json["refresh_token"], expiresAt: Date.now() + expiresIn * 1000, ...(organizationId === undefined ? {} : { organizationId }) };
|
|
212
|
+
}
|
|
213
|
+
/** The organization claim of a JWT access token, read without verifying it (Heedvane verifies). */
|
|
214
|
+
function organizationClaim(jwt) {
|
|
215
|
+
try {
|
|
216
|
+
const claims = JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
217
|
+
return isRecord(claims) && typeof claims[ORGANIZATION_CLAIM] === "string" ? claims[ORGANIZATION_CLAIM] : undefined;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined; // not a JWT: the token still works, the organization is just not shown
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function getJson(url) {
|
|
224
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
225
|
+
const json = await response.json();
|
|
226
|
+
if (!response.ok || !isRecord(json))
|
|
227
|
+
throw new Error(`${url} answered ${response.status}`);
|
|
228
|
+
return json;
|
|
229
|
+
}
|
|
230
|
+
async function postJson(url, body) {
|
|
231
|
+
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
232
|
+
const json = await response.json().catch(() => null);
|
|
233
|
+
if (!response.ok || !isRecord(json))
|
|
234
|
+
throw new Error(`${url} answered ${response.status}: ${JSON.stringify(json)}`);
|
|
235
|
+
return json;
|
|
236
|
+
}
|
|
237
|
+
function base64Url(bytes) {
|
|
238
|
+
return bytes.toString("base64url");
|
|
239
|
+
}
|
|
240
|
+
/** A one-shot loopback server for the browser's redirect back with the authorization code. */
|
|
241
|
+
async function loopbackCallback() {
|
|
242
|
+
let deliver = () => { };
|
|
243
|
+
const arrived = new Promise((resolve) => (deliver = resolve));
|
|
244
|
+
const server = createServer((req, res) => {
|
|
245
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
246
|
+
if (url.pathname !== "/callback")
|
|
247
|
+
return void res.writeHead(404).end();
|
|
248
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Signed in to Heedvane. You can close this tab.");
|
|
249
|
+
deliver(url);
|
|
250
|
+
});
|
|
251
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
252
|
+
const address = server.address();
|
|
253
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
254
|
+
const code = async (state) => {
|
|
255
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("no sign-in within five minutes")), LOGIN_TIMEOUT_MS).unref());
|
|
256
|
+
const url = await Promise.race([arrived, timeout]);
|
|
257
|
+
if (url.searchParams.get("state") !== state)
|
|
258
|
+
throw new Error("the sign-in answer did not match this login (state)");
|
|
259
|
+
const value = url.searchParams.get("code");
|
|
260
|
+
if (value === null)
|
|
261
|
+
throw new Error(`sign-in was not completed: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "no code"}`);
|
|
262
|
+
return value;
|
|
263
|
+
};
|
|
264
|
+
return { server, port, code };
|
|
265
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The origin URL for `cwd` without credentials, or "" when it is not a git checkout (cached per cwd). */
|
|
2
|
+
export declare function repoRemote(cwd: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* The absolute top-level folder of the checkout a session works in (`git rev-parse --show-toplevel`),
|
|
5
|
+
* or "" outside one; resolved once per session and working folder, so session-less events (all filed
|
|
6
|
+
* under one placeholder session) never share another folder's root.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sessionRepoRoot(session: string, cwd: string): Promise<string>;
|
|
9
|
+
export declare function withoutCredentials(remote: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The repository's full path from an origin URL (`owner/name`, or `group/sub/name` for nested groups),
|
|
12
|
+
* the form the steering endpoint's `repository` takes; "" when it has none. Reads https, `ssh://` and
|
|
13
|
+
* the scp-like `user@host:path` form, like ingestion's `normalizedRepositoryOrigin`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function repositoryName(remote: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which repository an event came from, as its git `origin` URL, so Heedvane can map a developer's
|
|
3
|
+
* checkout to a connected repository. Credentials in an https URL (`https://user:token@host/…`) never
|
|
4
|
+
* leave the machine.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
/** The origin URL for `cwd` without credentials, or "" when it is not a git checkout (cached per cwd). */
|
|
9
|
+
export function repoRemote(cwd) {
|
|
10
|
+
let remote = cache.get(cwd);
|
|
11
|
+
if (remote === undefined) {
|
|
12
|
+
remote = readOrigin(cwd).then(withoutCredentials);
|
|
13
|
+
cache.set(cwd, remote);
|
|
14
|
+
}
|
|
15
|
+
return remote;
|
|
16
|
+
}
|
|
17
|
+
function readOrigin(cwd) {
|
|
18
|
+
return git(cwd, ["remote", "get-url", "origin"]);
|
|
19
|
+
}
|
|
20
|
+
const roots = new Map();
|
|
21
|
+
/**
|
|
22
|
+
* The absolute top-level folder of the checkout a session works in (`git rev-parse --show-toplevel`),
|
|
23
|
+
* or "" outside one; resolved once per session and working folder, so session-less events (all filed
|
|
24
|
+
* under one placeholder session) never share another folder's root.
|
|
25
|
+
*/
|
|
26
|
+
export function sessionRepoRoot(session, cwd) {
|
|
27
|
+
const key = JSON.stringify([session, cwd]);
|
|
28
|
+
let root = roots.get(key);
|
|
29
|
+
if (root === undefined) {
|
|
30
|
+
root = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
31
|
+
roots.set(key, root);
|
|
32
|
+
}
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
function git(cwd, args) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
execFile("git", ["-C", cwd, ...args], { timeout: 1000 }, (err, stdout) => {
|
|
38
|
+
resolve(err ? "" : stdout.trim()); // not a repository, no origin, or no git
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function withoutCredentials(remote) {
|
|
43
|
+
if (!URL.canParse(remote) || !remote.includes("://"))
|
|
44
|
+
return remote; // scp-style git@host:owner/name
|
|
45
|
+
const url = new URL(remote);
|
|
46
|
+
url.username = "";
|
|
47
|
+
url.password = "";
|
|
48
|
+
return url.toString();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The repository's full path from an origin URL (`owner/name`, or `group/sub/name` for nested groups),
|
|
52
|
+
* the form the steering endpoint's `repository` takes; "" when it has none. Reads https, `ssh://` and
|
|
53
|
+
* the scp-like `user@host:path` form, like ingestion's `normalizedRepositoryOrigin`.
|
|
54
|
+
*/
|
|
55
|
+
export function repositoryName(remote) {
|
|
56
|
+
const trimmed = remote.trim();
|
|
57
|
+
const colon = trimmed.indexOf(":");
|
|
58
|
+
const url = URL.parse(trimmed.includes("://") || colon <= 0 ? trimmed : `ssh://${trimmed.slice(0, colon)}/${trimmed.slice(colon + 1)}`);
|
|
59
|
+
const path = url?.pathname.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/^\/+/, "") ?? "";
|
|
60
|
+
return path.includes("/") ? path : "";
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The file that records whose sign-in the spool, instructions and caches on this machine belong to. */
|
|
2
|
+
export declare function signedInAsPath(root: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Make the leftovers match the current sign-in: when the recorded sign-in is not the current one (or none
|
|
5
|
+
* was recorded), set them aside and record the current one. Returns the current identity; with no sign-in
|
|
6
|
+
* nothing moves, so the leftovers wait for whoever signs in next.
|
|
7
|
+
*/
|
|
8
|
+
export declare function reconcileSignIn(root: string, url: string): Promise<string | null>;
|
|
9
|
+
/** Run `change` (a command that may sign in as someone else), then reconcile the leftovers with the sign-in it left. */
|
|
10
|
+
export declare function withSignInChange<T>(root: string, url: string, change: () => Promise<T>): Promise<T>;
|
|
11
|
+
/** The pre-rendered instructions and the cached policy of an earlier sign-in; the next sync writes the current one's. */
|
|
12
|
+
export declare function discardSteeringCaches(root: string): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a change of sign-in leaves behind. Everything this machine holds for Heedvane belongs to the
|
|
3
|
+
* sign-in that produced it, and a hook event carries no sign-in of its own, so when `login` or
|
|
4
|
+
* `auth org` leaves a different sign-in than it found, what the earlier one left is set aside, never
|
|
5
|
+
* sent or injected under the new one:
|
|
6
|
+
*
|
|
7
|
+
* - the dispatcher's spooled events (`spool/pane-*.jsonl`), which the listener would replay under
|
|
8
|
+
* whatever sign-in is current: moved to `spool/other-sign-in/`;
|
|
9
|
+
* - the pre-rendered organization instructions the dispatcher prints when the listener is down
|
|
10
|
+
* (`steering/instructions-*.json`), and the cached steering policy: removed (the next sync writes
|
|
11
|
+
* the new organization's).
|
|
12
|
+
*
|
|
13
|
+
* The sign-in the leftovers belong to is recorded in `signed-in-as`, so the comparison is against what was
|
|
14
|
+
* left, not against what one command saw: the listener makes it at its start, before it replays the spool,
|
|
15
|
+
* and so does every command that changes the sign-in, including a plain deletion of `login.json`.
|
|
16
|
+
*
|
|
17
|
+
* The outbox and the caches that carry their sign-in stay with the identity they were stamped with, and
|
|
18
|
+
* apply only under exactly that identity (src/listener/forward.ts, src/skills/approved.ts,
|
|
19
|
+
* src/steering/rules.ts).
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, readdir, readFile, rename, unlink } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import writeFileAtomic from "write-file-atomic";
|
|
24
|
+
import { signInIdentity } from "./credentials.js";
|
|
25
|
+
import { isPaneSpoolFile, spoolDir } from "../listener/spool.js";
|
|
26
|
+
import { steeringPolicyPath } from "../steering/rules.js";
|
|
27
|
+
/** The file that records whose sign-in the spool, instructions and caches on this machine belong to. */
|
|
28
|
+
export function signedInAsPath(root) {
|
|
29
|
+
return join(root, "signed-in-as");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Make the leftovers match the current sign-in: when the recorded sign-in is not the current one (or none
|
|
33
|
+
* was recorded), set them aside and record the current one. Returns the current identity; with no sign-in
|
|
34
|
+
* nothing moves, so the leftovers wait for whoever signs in next.
|
|
35
|
+
*/
|
|
36
|
+
export async function reconcileSignIn(root, url) {
|
|
37
|
+
const current = await signInIdentity(root, url);
|
|
38
|
+
if (current === null)
|
|
39
|
+
return null;
|
|
40
|
+
const recorded = await readFile(signedInAsPath(root), "utf8").then((text) => text.trim(), (err) => {
|
|
41
|
+
if (err.code === "ENOENT")
|
|
42
|
+
return null;
|
|
43
|
+
throw err;
|
|
44
|
+
});
|
|
45
|
+
if (recorded !== current) {
|
|
46
|
+
await setAsideSignInLeftovers(root);
|
|
47
|
+
await writeFileAtomic(signedInAsPath(root), `${current}\n`, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
/** Run `change` (a command that may sign in as someone else), then reconcile the leftovers with the sign-in it left. */
|
|
52
|
+
export async function withSignInChange(root, url, change) {
|
|
53
|
+
const result = await change();
|
|
54
|
+
await reconcileSignIn(root, url);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
async function setAsideSignInLeftovers(root) {
|
|
58
|
+
const spool = spoolDir(root);
|
|
59
|
+
const names = (await readdir(spool).catch(() => [])).filter(isPaneSpoolFile);
|
|
60
|
+
if (names.length > 0)
|
|
61
|
+
await mkdir(join(spool, "other-sign-in"), { recursive: true, mode: 0o700 });
|
|
62
|
+
for (const name of names)
|
|
63
|
+
await rename(join(spool, name), join(spool, "other-sign-in", `${Date.now()}-${name}`));
|
|
64
|
+
await discardSteeringCaches(root);
|
|
65
|
+
}
|
|
66
|
+
/** The pre-rendered instructions and the cached policy of an earlier sign-in; the next sync writes the current one's. */
|
|
67
|
+
export async function discardSteeringCaches(root) {
|
|
68
|
+
const steering = join(root, "steering");
|
|
69
|
+
for (const name of (await readdir(steering).catch(() => [])).filter((entry) => entry.startsWith("instructions-")))
|
|
70
|
+
await unlink(join(steering, name)).catch(() => undefined);
|
|
71
|
+
await unlink(steeringPolicyPath(root)).catch(() => undefined);
|
|
72
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Delete `path` if it exists; ignore every failure (best-effort cleanup). */
|
|
2
|
+
export declare function unlinkOptional(path: string): Promise<void>;
|
|
3
|
+
/** Write `content` to `path` with `mode`, ignoring every failure (best-effort restore). */
|
|
4
|
+
export declare function writeFileOptional(path: string, content: string | Buffer, mode: number): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort filesystem cleanup used on failure/cleanup paths: the operation's
|
|
3
|
+
* failure is swallowed by design because the caller has already decided what to
|
|
4
|
+
* report (or there is nothing left to report on). Each helper is a bare
|
|
5
|
+
* try/catch so the intent is visible and greppable in one place.
|
|
6
|
+
*/
|
|
7
|
+
import { unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
/** Delete `path` if it exists; ignore every failure (best-effort cleanup). */
|
|
9
|
+
export async function unlinkOptional(path) {
|
|
10
|
+
try {
|
|
11
|
+
await unlink(path);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return; // best-effort: absent or unremovable is the same outcome here
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Write `content` to `path` with `mode`, ignoring every failure (best-effort restore). */
|
|
18
|
+
export async function writeFileOptional(path, content, mode) {
|
|
19
|
+
try {
|
|
20
|
+
await writeFile(path, content, { mode });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return; // best-effort: the caller reports the primary failure
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface MainOptions {
|
|
3
|
+
/** Env override (tests inject HEED_HOME fixtures). Defaults to process.env. */
|
|
4
|
+
env?: Record<string, string | undefined>;
|
|
5
|
+
/** Output sink; defaults to console.log. */
|
|
6
|
+
write?: (line: string) => void;
|
|
7
|
+
/** Stdin as text; defaults to the process stdin (tests inject a key for `auth`). */
|
|
8
|
+
readStdin?: () => Promise<string>;
|
|
9
|
+
/** Opens the sign-in page; defaults to the system browser (tests follow the URL themselves). */
|
|
10
|
+
openBrowser?: (url: string) => void;
|
|
11
|
+
/** The working folder `memory` reads the repository from; defaults to the process's. */
|
|
12
|
+
cwd?: string;
|
|
13
|
+
/** May init/setup start a browser sign-in? Defaults to whether a person is at the terminal. */
|
|
14
|
+
interactive?: boolean;
|
|
15
|
+
}
|
|
16
|
+
declare function main(argv: string[], opts?: MainOptions): Promise<number>;
|
|
17
|
+
/** All of stdin as text (where `auth` reads its key). */
|
|
18
|
+
/** Open `url` in the system browser; the URL is printed too, so a failure here only costs a click. */
|
|
19
|
+
/**
|
|
20
|
+
* The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
|
|
21
|
+
* cmd would cut the sign-in URL at its first `&`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function browserOpener(platform: NodeJS.Platform, url: string): [string, string[]];
|
|
24
|
+
export { main };
|