heedvane 0.0.0-stage → 0.2.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 +62 -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 +29 -0
- package/dist/cli.js +666 -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 +41 -0
- package/dist/dispatchers/posix.js +208 -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 +303 -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 +78 -0
- package/dist/heed-file.js +316 -0
- package/dist/installer.d.ts +47 -0
- package/dist/installer.js +174 -0
- package/dist/listener/background.d.ts +8 -0
- package/dist/listener/background.js +36 -0
- package/dist/listener/endpoint.d.ts +26 -0
- package/dist/listener/endpoint.js +125 -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 +32 -0
- package/dist/render.js +132 -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 +35 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# heedvane
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect the coding agents on your machine to your Heedvane organization: guardrails, approved-skill
|
|
4
|
+
checks and team memory for Claude Code, Codex, Cursor, Gemini CLI, Hermes, OpenCode and more.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx heedvane
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The installer shows the agents it found on your machine. Tick the ones you want, choose what Heedvane
|
|
11
|
+
adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
|
|
12
|
+
browser. It needs Node.js 22 or newer.
|
|
13
|
+
|
|
14
|
+
Your dashboard's Agents page has the same command with your server and organization filled in:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx heedvane connect --server https://app.heedvane.com --org <organization id>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it changes
|
|
21
|
+
|
|
22
|
+
- Writes `~/.heed/heed.yaml`, your control file, with your answers. Run `npx heedvane` again to change
|
|
23
|
+
them.
|
|
24
|
+
- Adds Heedvane's hooks, MCP server entry and skill to each agent you ticked. A config file it edits is
|
|
25
|
+
backed up first.
|
|
26
|
+
- Starts Heedvane's local listener, which answers the hooks. After a restart the hooks start it again by
|
|
27
|
+
themselves, at most once a minute. Set `HEED_LISTENER_AUTOSTART=0` to turn that off.
|
|
28
|
+
|
|
29
|
+
Run `npx heedvane connect --dry-run` to see every file it would touch without writing anything.
|
|
30
|
+
|
|
31
|
+
## Software factories, CI and containers
|
|
32
|
+
|
|
33
|
+
Agents that run unattended (a factory looping on a server, a CI job, a container on AWS) use the same
|
|
34
|
+
package without the installer:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# Once, when you build the image: install the hooks for the agent the factory runs
|
|
38
|
+
npx -y heedvane connect --server https://app.heedvane.com --org <organization id> --agent claude --yes
|
|
39
|
+
|
|
40
|
+
# When the container starts: sign in with an organization key from your secret store
|
|
41
|
+
printf '%s' "$HEEDVANE_ORG_KEY" | npx heedvane auth org
|
|
42
|
+
|
|
43
|
+
# Every agent run: Heedvane answers the hooks for exactly this run
|
|
44
|
+
npx heedvane run -- claude -p "fix the failing tests"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`run` keeps the agent's terminal output and exit code, so your pipeline reads them as before. Heedvane's
|
|
48
|
+
own lines go to stderr and `~/.heed/listener.log`.
|
|
49
|
+
|
|
50
|
+
- Claude Code runs the hooks in print mode (`claude -p`), but not with `--bare`.
|
|
51
|
+
- Codex runs hooks only after they are trusted.
|
|
52
|
+
- Pi and Flue agents cannot be connected yet.
|
|
53
|
+
|
|
54
|
+
## Everyday commands
|
|
55
|
+
|
|
56
|
+
| Command | What it does |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `npx heedvane` | Opens the installer: change agents and features any time. |
|
|
59
|
+
| `npx heedvane doctor` | Checks the install and prints a fix for each problem. |
|
|
60
|
+
| `npx heedvane listener start` | Runs the listener in the foreground. |
|
|
61
|
+
| `npx heedvane run -- <command>` | Runs one agent with Heedvane, for CI and containers. |
|
|
62
|
+
| `npx heedvane uninstall` | Removes only the entries Heedvane added. `heed.yaml` stays. |
|
|
63
|
+
| `npx heedvane --help` | Lists every command and flag. |
|
package/dist/apply.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transactional config write recipe (REA's pattern, ported):
|
|
3
|
+
*
|
|
4
|
+
* 1. Refuse to follow symlinks not owned by the current uid (lstat; if the
|
|
5
|
+
* path is a symlink, realpath and require a regular file owned by the
|
|
6
|
+
* current uid — else { status: "failed", reason: "unsafe-path" }).
|
|
7
|
+
* 2. Missing file -> "create".
|
|
8
|
+
* 3. Malformed existing config -> "invalid" with a remediation string;
|
|
9
|
+
* the file is NEVER overwritten.
|
|
10
|
+
* 4. Semantic compare (sorted-keys deep-equal, key order ignored) ->
|
|
11
|
+
* "unchanged" when the desired entry is already current.
|
|
12
|
+
* 5. Backup via copyFile with COPYFILE_EXCL to <path>.heedvane.backup
|
|
13
|
+
* (EEXIST means a backup already exists — proceed, never clobber).
|
|
14
|
+
* 6. Atomic write via write-file-atomic, mode 0600.
|
|
15
|
+
* 7. Read back, re-parse, semantic verify — on mismatch RESTORE the
|
|
16
|
+
* original bytes and return { status: "failed", reason: "readback" }.
|
|
17
|
+
* The backup is kept either way.
|
|
18
|
+
*/
|
|
19
|
+
import { serializeDoc, type ConfigFormat } from "./config-doc.js";
|
|
20
|
+
/** Injectable serializer seam (tests use a broken serializer to force readback failure). */
|
|
21
|
+
export type Serializer = typeof serializeDoc;
|
|
22
|
+
export interface ApplyOptions {
|
|
23
|
+
serialize?: Serializer;
|
|
24
|
+
}
|
|
25
|
+
export type Inspection = {
|
|
26
|
+
status: "create";
|
|
27
|
+
} | {
|
|
28
|
+
status: "update";
|
|
29
|
+
} | {
|
|
30
|
+
status: "unchanged";
|
|
31
|
+
} | {
|
|
32
|
+
status: "invalid";
|
|
33
|
+
remediation: string;
|
|
34
|
+
} | {
|
|
35
|
+
status: "failed";
|
|
36
|
+
reason: "unsafe-path";
|
|
37
|
+
detail: string;
|
|
38
|
+
};
|
|
39
|
+
export type ApplyResult = {
|
|
40
|
+
status: "create";
|
|
41
|
+
backupPath?: string;
|
|
42
|
+
} | {
|
|
43
|
+
status: "update";
|
|
44
|
+
backupPath: string;
|
|
45
|
+
} | {
|
|
46
|
+
status: "unchanged";
|
|
47
|
+
} | {
|
|
48
|
+
status: "invalid";
|
|
49
|
+
remediation: string;
|
|
50
|
+
} | {
|
|
51
|
+
status: "failed";
|
|
52
|
+
reason: "unsafe-path" | "readback" | "write";
|
|
53
|
+
backupPath?: string;
|
|
54
|
+
};
|
|
55
|
+
export interface ConfigTarget {
|
|
56
|
+
path: string;
|
|
57
|
+
format: ConfigFormat;
|
|
58
|
+
}
|
|
59
|
+
/** Which entries (name -> desired value) to inspect or write, inside which container; one file write covers them all. */
|
|
60
|
+
export interface EntrySpec {
|
|
61
|
+
serversKey: string;
|
|
62
|
+
entries: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
/** Write `entries` into `containerKey`, or (uninstall) remove the `remove` entry. */
|
|
65
|
+
export type ApplyEntryRequest = {
|
|
66
|
+
containerKey: string;
|
|
67
|
+
entries: Record<string, unknown>;
|
|
68
|
+
} | {
|
|
69
|
+
containerKey: string;
|
|
70
|
+
remove: string;
|
|
71
|
+
};
|
|
72
|
+
/** Compat surface (A's ApplyResult): flat shape carrying the path. */
|
|
73
|
+
export type ApplyStatus = "create" | "update" | "unchanged" | "invalid" | "failed";
|
|
74
|
+
export interface ApplyTarget {
|
|
75
|
+
path: string;
|
|
76
|
+
format: ConfigFormat;
|
|
77
|
+
}
|
|
78
|
+
export interface ApplyEntryResult {
|
|
79
|
+
status: ApplyStatus;
|
|
80
|
+
path: string;
|
|
81
|
+
backupPath?: string;
|
|
82
|
+
reason?: string;
|
|
83
|
+
remediation?: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Compat entry point (A's API) on top of {@link applyClientConfig} /
|
|
87
|
+
* {@link transactionalWrite}: same recipe, but the result carries `path` and
|
|
88
|
+
* `remove: true` removes an entry (uninstall). Malformed configs and
|
|
89
|
+
* unowned symlinks report `invalid` with a remediation, never a write.
|
|
90
|
+
*/
|
|
91
|
+
export declare function applyEntry(target: ApplyTarget, request: ApplyEntryRequest): Promise<ApplyEntryResult>;
|
|
92
|
+
/**
|
|
93
|
+
* Set (or, with `undefined`, remove) one top-level key of a JSON settings file through the same
|
|
94
|
+
* transactional recipe; `unchanged` when it already holds that value, or is absent and stays so.
|
|
95
|
+
*/
|
|
96
|
+
export declare function applyTopLevelKey(target: ApplyTarget, key: string, value: unknown): Promise<ApplyEntryResult>;
|
|
97
|
+
/** Read-only inspection: what would applying `desired` to this config do? Never writes. */
|
|
98
|
+
export declare function inspectClient(target: ConfigTarget, spec: EntrySpec): Promise<Inspection>;
|
|
99
|
+
/**
|
|
100
|
+
* Apply `desired` to the config transactionally. See the module doc for the
|
|
101
|
+
* recipe. Returns "unchanged" without touching the file when the entry is
|
|
102
|
+
* already semantically current; never overwrites malformed configs.
|
|
103
|
+
*/
|
|
104
|
+
export declare function applyClientConfig(target: ConfigTarget, spec: EntrySpec, options?: ApplyOptions): Promise<ApplyResult>;
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transactional config write recipe (REA's pattern, ported):
|
|
3
|
+
*
|
|
4
|
+
* 1. Refuse to follow symlinks not owned by the current uid (lstat; if the
|
|
5
|
+
* path is a symlink, realpath and require a regular file owned by the
|
|
6
|
+
* current uid — else { status: "failed", reason: "unsafe-path" }).
|
|
7
|
+
* 2. Missing file -> "create".
|
|
8
|
+
* 3. Malformed existing config -> "invalid" with a remediation string;
|
|
9
|
+
* the file is NEVER overwritten.
|
|
10
|
+
* 4. Semantic compare (sorted-keys deep-equal, key order ignored) ->
|
|
11
|
+
* "unchanged" when the desired entry is already current.
|
|
12
|
+
* 5. Backup via copyFile with COPYFILE_EXCL to <path>.heedvane.backup
|
|
13
|
+
* (EEXIST means a backup already exists — proceed, never clobber).
|
|
14
|
+
* 6. Atomic write via write-file-atomic, mode 0600.
|
|
15
|
+
* 7. Read back, re-parse, semantic verify — on mismatch RESTORE the
|
|
16
|
+
* original bytes and return { status: "failed", reason: "readback" }.
|
|
17
|
+
* The backup is kept either way.
|
|
18
|
+
*/
|
|
19
|
+
import { constants as fsConstants } from "node:fs";
|
|
20
|
+
import { copyFile, lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
21
|
+
import { dirname } from "node:path";
|
|
22
|
+
import writeFileAtomicCb from "write-file-atomic";
|
|
23
|
+
import { getServers, parseDoc, removeServer, serializeDoc, withServer, withTopLevelKey, valuesEqual, TOP_LEVEL, entryValue, } from "./config-doc.js";
|
|
24
|
+
import { errorMessage, isRecord } from "./guards.js";
|
|
25
|
+
function writeAtomic(path, data, mode) {
|
|
26
|
+
return writeFileAtomicCb(path, data, { mode });
|
|
27
|
+
}
|
|
28
|
+
/** The path any backup for `path` will live at. */
|
|
29
|
+
function backupPathFor(path) {
|
|
30
|
+
return `${path}.heedvane.backup`;
|
|
31
|
+
}
|
|
32
|
+
/** Apply every entry of the spec to the doc (mutated in place, like withServer). */
|
|
33
|
+
function withEntries(doc, format, spec) {
|
|
34
|
+
for (const [name, desired] of Object.entries(spec.entries))
|
|
35
|
+
withServer(doc, format, { serversKey: spec.serversKey, name, desired });
|
|
36
|
+
return doc;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compat entry point (A's API) on top of {@link applyClientConfig} /
|
|
40
|
+
* {@link transactionalWrite}: same recipe, but the result carries `path` and
|
|
41
|
+
* `remove: true` removes an entry (uninstall). Malformed configs and
|
|
42
|
+
* unowned symlinks report `invalid` with a remediation, never a write.
|
|
43
|
+
*/
|
|
44
|
+
export async function applyEntry(target, request) {
|
|
45
|
+
if ("remove" in request)
|
|
46
|
+
return removeEntry(target, request.containerKey, request.remove);
|
|
47
|
+
if (request.containerKey === TOP_LEVEL)
|
|
48
|
+
return applyTopLevelKeys(target, request.entries);
|
|
49
|
+
const res = await applyClientConfig(target, { serversKey: request.containerKey, entries: request.entries });
|
|
50
|
+
if (res.status === "invalid")
|
|
51
|
+
return { status: "invalid", path: target.path, remediation: res.remediation };
|
|
52
|
+
if (res.status === "failed")
|
|
53
|
+
return { status: "failed", path: target.path, reason: res.reason, backupPath: res.backupPath };
|
|
54
|
+
// Only a backup this write actually took is reported.
|
|
55
|
+
return { status: res.status, path: target.path, backupPath: "backupPath" in res ? res.backupPath : undefined };
|
|
56
|
+
}
|
|
57
|
+
/** The `remove: true` half of applyEntry: remove one entry through the transactional recipe. */
|
|
58
|
+
async function removeEntry(target, containerKey, entryName) {
|
|
59
|
+
let doc;
|
|
60
|
+
try {
|
|
61
|
+
const originalBytes = await readFile(target.path);
|
|
62
|
+
doc = parseDoc(originalBytes.toString("utf8"), target.format);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err?.code === "ENOENT")
|
|
66
|
+
return { status: "unchanged", path: target.path };
|
|
67
|
+
return {
|
|
68
|
+
status: "invalid",
|
|
69
|
+
path: target.path,
|
|
70
|
+
reason: `existing config does not parse: ${errorMessage(err)}`,
|
|
71
|
+
remediation: `fix or move aside ${target.path} (we never overwrite malformed configs), then re-run`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!markerVerifiedPresent(doc, containerKey, entryName)) {
|
|
75
|
+
return { status: "unchanged", path: target.path };
|
|
76
|
+
}
|
|
77
|
+
const next = removeServer(doc, target.format, { serversKey: containerKey, name: entryName });
|
|
78
|
+
const res = await transactionalWrite(target.path, target.format, () => serializeDoc(next, target.format, undefined));
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
return { status: "failed", path: target.path, backupPath: res.backupPath, reason: res.reason };
|
|
81
|
+
}
|
|
82
|
+
return { status: "update", path: target.path, backupPath: res.backupPath };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Set (or, with `undefined`, remove) one top-level key of a JSON settings file through the same
|
|
86
|
+
* transactional recipe; `unchanged` when it already holds that value, or is absent and stays so.
|
|
87
|
+
*/
|
|
88
|
+
export async function applyTopLevelKey(target, key, value) {
|
|
89
|
+
let doc;
|
|
90
|
+
try {
|
|
91
|
+
doc = parseDoc((await readFile(target.path)).toString("utf8"), target.format);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err?.code !== "ENOENT") {
|
|
95
|
+
return { status: "invalid", path: target.path, reason: `existing config does not parse: ${errorMessage(err)}`, remediation: INVALID_REMEDIATION(target.path) };
|
|
96
|
+
}
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
return { status: "unchanged", path: target.path };
|
|
99
|
+
doc = parseDoc("{}", target.format);
|
|
100
|
+
}
|
|
101
|
+
// A higher schema version already in the file is kept (entryValue); any other value is replaced.
|
|
102
|
+
const kept = entryValue(TOP_LEVEL, doc.value[key], value);
|
|
103
|
+
if (JSON.stringify(doc.value[key]) === JSON.stringify(kept))
|
|
104
|
+
return { status: "unchanged", path: target.path };
|
|
105
|
+
const next = withTopLevelKey(doc, { key, value: kept });
|
|
106
|
+
const res = await transactionalWrite(target.path, target.format, () => serializeDoc(next, target.format, undefined));
|
|
107
|
+
if (!res.ok)
|
|
108
|
+
return { status: "failed", path: target.path, backupPath: res.backupPath, reason: res.reason };
|
|
109
|
+
return { status: "update", path: target.path, backupPath: res.backupPath };
|
|
110
|
+
}
|
|
111
|
+
/** Every top-level key of `entries`, one transactional write each; the first failure stops the rest. */
|
|
112
|
+
async function applyTopLevelKeys(target, entries) {
|
|
113
|
+
let worst = { status: "unchanged", path: target.path };
|
|
114
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
115
|
+
const res = await applyTopLevelKey(target, key, value);
|
|
116
|
+
if (res.status === "failed" || res.status === "invalid")
|
|
117
|
+
return res;
|
|
118
|
+
if (res.status !== "unchanged")
|
|
119
|
+
worst = res;
|
|
120
|
+
}
|
|
121
|
+
return worst;
|
|
122
|
+
}
|
|
123
|
+
/** Was `entryName` in an object servers table? Present-but-not-an-object → false (nothing marker-verified). */
|
|
124
|
+
function markerVerifiedPresent(doc, containerKey, entryName) {
|
|
125
|
+
try {
|
|
126
|
+
const servers = getServers(doc, doc.format, containerKey);
|
|
127
|
+
return servers !== undefined && entryName in servers;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false; // container present but not an object — nothing marker-verified to remove
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const INVALID_REMEDIATION = (path) => `repair ${path} before running setup — heedvane will not overwrite it`;
|
|
134
|
+
/** Was any of the spec's entries already present in `doc.value[serversKey]`? (Pre-mutation.) */
|
|
135
|
+
function entryExistedIn(doc, spec) {
|
|
136
|
+
const container = doc.value[spec.serversKey];
|
|
137
|
+
return isRecord(container) && Object.keys(spec.entries).some((name) => name in container);
|
|
138
|
+
}
|
|
139
|
+
/** The recipe's step 1: refuse symlinks / non-regular files we do not own. */
|
|
140
|
+
async function checkPathSafety(filePath) {
|
|
141
|
+
let lst;
|
|
142
|
+
try {
|
|
143
|
+
lst = await lstat(filePath);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e.code === "ENOENT")
|
|
147
|
+
return { ok: true }; // missing file, handled as "create"
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
if (lst.isSymbolicLink()) {
|
|
151
|
+
let target;
|
|
152
|
+
try {
|
|
153
|
+
target = await realpath(filePath);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { ok: false, detail: `${filePath} is a symlink but its target does not resolve` };
|
|
157
|
+
}
|
|
158
|
+
let st;
|
|
159
|
+
try {
|
|
160
|
+
st = await stat(target);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { ok: false, detail: `symlink ${filePath} -> ${target} does not resolve to an accessible file` };
|
|
164
|
+
}
|
|
165
|
+
if (!st.isFile()) {
|
|
166
|
+
return { ok: false, detail: `symlink ${filePath} -> ${target} does not resolve to a regular file` };
|
|
167
|
+
}
|
|
168
|
+
if (process.platform !== "win32" && typeof process.getuid === "function" && st.uid !== process.getuid()) {
|
|
169
|
+
return { ok: false, detail: `symlink ${filePath} -> ${target} is owned by uid ${st.uid}, not the current uid ${process.getuid()}` };
|
|
170
|
+
}
|
|
171
|
+
return { ok: true };
|
|
172
|
+
}
|
|
173
|
+
if (!lst.isFile()) {
|
|
174
|
+
return { ok: false, detail: `${filePath} exists and is not a regular file` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
async function loadExisting(target) {
|
|
179
|
+
let originalBytes;
|
|
180
|
+
try {
|
|
181
|
+
originalBytes = await readFile(target.path);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
if (e.code === "ENOENT")
|
|
185
|
+
return null;
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
const originalText = originalBytes.toString("utf8");
|
|
189
|
+
const originalMode = (await stat(target.path)).mode & 0o777;
|
|
190
|
+
const doc = parseDoc(originalText, target.format); // throws ConfigParseError / ServersTableError-adjacent
|
|
191
|
+
return { originalBytes, originalText, originalMode, doc };
|
|
192
|
+
}
|
|
193
|
+
/** COPYFILE_EXCL backup: an existing backup is kept, never clobbered. */
|
|
194
|
+
async function copyBackupExcl(from, to) {
|
|
195
|
+
try {
|
|
196
|
+
await copyFile(from, to, fsConstants.COPYFILE_EXCL);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (e.code === "EEXIST")
|
|
200
|
+
return; // a backup already exists — keep it
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Best-effort restore of the original bytes; a failed restore is swallowed by design (the write failure is reported instead). */
|
|
205
|
+
async function restoreOriginal(filePath, original) {
|
|
206
|
+
try {
|
|
207
|
+
await writeAtomic(filePath, original.bytes, original.mode);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return; // best-effort restore on a failed write
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Read back, re-parse and semantic-verify the write (recipe step 7). */
|
|
214
|
+
async function verifyWrittenText(filePath, format, nextText) {
|
|
215
|
+
try {
|
|
216
|
+
const writtenText = await readFile(filePath, "utf8");
|
|
217
|
+
const writtenDoc = parseDoc(writtenText, format);
|
|
218
|
+
const expectedDoc = parseDoc(nextText, format);
|
|
219
|
+
if (!valuesEqual(writtenDoc.value, expectedDoc.value))
|
|
220
|
+
return { ok: false };
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return { ok: false }; // unreadable or unparseable after the write — restore below
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Read-only inspection: what would applying `desired` to this config do? Never writes. */
|
|
228
|
+
export async function inspectClient(target, spec) {
|
|
229
|
+
const safety = await checkPathSafety(target.path);
|
|
230
|
+
if (!safety.ok)
|
|
231
|
+
return { status: "failed", reason: "unsafe-path", detail: safety.detail };
|
|
232
|
+
let loaded;
|
|
233
|
+
try {
|
|
234
|
+
loaded = await loadExisting(target);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return { status: "invalid", remediation: INVALID_REMEDIATION(target.path) };
|
|
238
|
+
}
|
|
239
|
+
if (loaded === null)
|
|
240
|
+
return { status: "create" };
|
|
241
|
+
// Snapshot BEFORE withServer: it mutates the doc in place.
|
|
242
|
+
const before = structuredClone(loaded.doc.value);
|
|
243
|
+
const hadEntry = entryExistedIn(loaded.doc, spec);
|
|
244
|
+
let next;
|
|
245
|
+
try {
|
|
246
|
+
next = withEntries(loaded.doc, target.format, spec);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return { status: "invalid", remediation: INVALID_REMEDIATION(target.path) };
|
|
250
|
+
}
|
|
251
|
+
if (valuesEqual(before, next.value))
|
|
252
|
+
return { status: "unchanged" };
|
|
253
|
+
// Status is entry-level: adding our entry to an existing config is a
|
|
254
|
+
// "create" of that entry; replacing an existing one is an "update".
|
|
255
|
+
return hadEntry ? { status: "update" } : { status: "create" };
|
|
256
|
+
}
|
|
257
|
+
/** Atomic write (step 6) then read-back verify (step 7); on failure restore the original bytes. */
|
|
258
|
+
async function writeAndVerify(target, nextText, original) {
|
|
259
|
+
const mode = original?.mode ?? 0o600;
|
|
260
|
+
try {
|
|
261
|
+
// A new config may sit in a folder the agent has not made yet (an agent forced with --agent).
|
|
262
|
+
if (original === null)
|
|
263
|
+
await mkdir(dirname(target.path), { recursive: true, mode: 0o700 });
|
|
264
|
+
await writeAtomic(target.path, nextText, mode);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
if (original !== null) {
|
|
268
|
+
await restoreOriginal(target.path, original);
|
|
269
|
+
}
|
|
270
|
+
return { ok: false, reason: "write" };
|
|
271
|
+
}
|
|
272
|
+
if ((await verifyWrittenText(target.path, target.format, nextText)).ok)
|
|
273
|
+
return { ok: true };
|
|
274
|
+
if (original !== null) {
|
|
275
|
+
await writeAtomic(target.path, original.bytes, original.mode);
|
|
276
|
+
}
|
|
277
|
+
return { ok: false, reason: "readback" };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Apply `desired` to the config transactionally. See the module doc for the
|
|
281
|
+
* recipe. Returns "unchanged" without touching the file when the entry is
|
|
282
|
+
* already semantically current; never overwrites malformed configs.
|
|
283
|
+
*/
|
|
284
|
+
export async function applyClientConfig(target, spec, options = {}) {
|
|
285
|
+
const serialize = options.serialize ?? serializeDoc;
|
|
286
|
+
const safety = await checkPathSafety(target.path);
|
|
287
|
+
if (!safety.ok)
|
|
288
|
+
return { status: "failed", reason: "unsafe-path" };
|
|
289
|
+
let loaded = null;
|
|
290
|
+
try {
|
|
291
|
+
loaded = await loadExisting(target);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return { status: "invalid", remediation: INVALID_REMEDIATION(target.path) };
|
|
295
|
+
}
|
|
296
|
+
const prepared = prepareNext(target, spec, { loaded, serialize });
|
|
297
|
+
if (prepared.outcome === "invalid")
|
|
298
|
+
return { status: "invalid", remediation: INVALID_REMEDIATION(target.path) };
|
|
299
|
+
if (prepared.outcome === "unchanged")
|
|
300
|
+
return { status: "unchanged" };
|
|
301
|
+
// Backup (step 5): COPYFILE_EXCL — an existing backup is never clobbered.
|
|
302
|
+
const backupPath = await backupExisting(target.path, loaded);
|
|
303
|
+
const original = loaded === null ? null : { bytes: loaded.originalBytes, mode: loaded.originalMode };
|
|
304
|
+
const outcome = await writeAndVerify(target, prepared.nextText, original);
|
|
305
|
+
if (!outcome.ok) {
|
|
306
|
+
if (loaded === null)
|
|
307
|
+
return { status: "failed", reason: outcome.reason };
|
|
308
|
+
return { status: "failed", reason: outcome.reason, backupPath };
|
|
309
|
+
}
|
|
310
|
+
if (loaded === null)
|
|
311
|
+
return { status: "create" };
|
|
312
|
+
return prepared.entryWasPresent
|
|
313
|
+
? { status: "update", backupPath: backupPath }
|
|
314
|
+
: { status: "create", backupPath };
|
|
315
|
+
}
|
|
316
|
+
/** Serialize the mutated doc, or report unchanged / invalid without touching the file. */
|
|
317
|
+
function prepareNext(target, spec, ctx) {
|
|
318
|
+
const { loaded, serialize } = ctx;
|
|
319
|
+
if (loaded === null) {
|
|
320
|
+
// Missing file: serialize a fresh doc carrying only our entry.
|
|
321
|
+
const fresh = { format: target.format, value: {} };
|
|
322
|
+
const nextText = serialize(withEntries(fresh, target.format, spec), target.format, undefined);
|
|
323
|
+
return { outcome: "write", nextText, entryWasPresent: false };
|
|
324
|
+
}
|
|
325
|
+
// Snapshot BEFORE withServer: it mutates the doc in place.
|
|
326
|
+
const before = structuredClone(loaded.doc.value);
|
|
327
|
+
const entryWasPresent = entryExistedIn(loaded.doc, spec);
|
|
328
|
+
let next;
|
|
329
|
+
try {
|
|
330
|
+
next = withEntries(loaded.doc, target.format, spec);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return { outcome: "invalid" };
|
|
334
|
+
}
|
|
335
|
+
if (valuesEqual(before, next.value))
|
|
336
|
+
return { outcome: "unchanged" };
|
|
337
|
+
return { outcome: "write", nextText: serialize(next, target.format, loaded.originalText), entryWasPresent };
|
|
338
|
+
}
|
|
339
|
+
/** Copy the current file to its backup path (no-op, undefined, when creating a fresh file). */
|
|
340
|
+
async function backupExisting(path, loaded) {
|
|
341
|
+
if (loaded === null)
|
|
342
|
+
return undefined;
|
|
343
|
+
const backupPath = backupPathFor(path);
|
|
344
|
+
await copyBackupExcl(path, backupPath);
|
|
345
|
+
return backupPath;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Shared transactional write for callers that already computed the new text
|
|
349
|
+
* (uninstall uses this). Same recipe as applyClientConfig from the backup
|
|
350
|
+
* step onwards: COPYFILE_EXCL backup, atomic 0600 write, read-back verify,
|
|
351
|
+
* restore original bytes on any mismatch.
|
|
352
|
+
*/
|
|
353
|
+
async function transactionalWrite(filePath, format, serialize) {
|
|
354
|
+
const safety = await checkPathSafety(filePath);
|
|
355
|
+
if (!safety.ok)
|
|
356
|
+
return { ok: false, reason: "unsafe-path" };
|
|
357
|
+
const originalState = await readOriginalState(filePath);
|
|
358
|
+
const originalBytes = originalState.bytes;
|
|
359
|
+
const nextText = serialize();
|
|
360
|
+
let backupPath;
|
|
361
|
+
if (originalBytes !== null) {
|
|
362
|
+
backupPath = backupPathFor(filePath);
|
|
363
|
+
await copyBackupExcl(filePath, backupPath);
|
|
364
|
+
}
|
|
365
|
+
const original = originalBytes === null ? null : { bytes: originalBytes, mode: originalState.mode };
|
|
366
|
+
const outcome = await writeAndVerify({ path: filePath, format }, nextText, original);
|
|
367
|
+
if (!outcome.ok) {
|
|
368
|
+
if (originalBytes === null)
|
|
369
|
+
return { ok: false, reason: outcome.reason };
|
|
370
|
+
return { ok: false, reason: outcome.reason, backupPath };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, backupPath };
|
|
373
|
+
}
|
|
374
|
+
/** The recipe's input snapshot: the file's bytes and mode, or null bytes when it is missing. */
|
|
375
|
+
async function readOriginalState(filePath) {
|
|
376
|
+
let bytes = null;
|
|
377
|
+
let mode = 0o600;
|
|
378
|
+
try {
|
|
379
|
+
bytes = await readFile(filePath);
|
|
380
|
+
mode = (await stat(filePath)).mode & 0o777;
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
if (e.code === "ENOENT")
|
|
384
|
+
return { bytes, mode }; // missing file — take the create path
|
|
385
|
+
throw e;
|
|
386
|
+
}
|
|
387
|
+
return { bytes, mode };
|
|
388
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one door from this machine to the Heedvane API. Every caller is fail-open by contract
|
|
3
|
+
* (docs/product/agent-integration/steering.md: a hook never blocks work on Heedvane's availability),
|
|
4
|
+
* so a network failure or timeout answers `null` and the caller lets the agent continue.
|
|
5
|
+
*/
|
|
6
|
+
export interface Backend {
|
|
7
|
+
url: string;
|
|
8
|
+
/**
|
|
9
|
+
* The one credential this machine uses: the signed-in developer's current access token
|
|
10
|
+
* (`npx heedvane login`), else the organization API key (`npx heedvane auth org`, CI and headless
|
|
11
|
+
* machines). Never both, so a key of another organization can never stand in for a refused login.
|
|
12
|
+
*/
|
|
13
|
+
credential: () => Promise<Credential>;
|
|
14
|
+
/**
|
|
15
|
+
* Whose sign-in is current, without reading its token: the Heedvane URL and the organization (login)
|
|
16
|
+
* or key it belongs to; null without any. It names the sign-in even when its token has expired, so
|
|
17
|
+
* queued data and caches stay with the organization they came from and are never sent or applied
|
|
18
|
+
* under another.
|
|
19
|
+
*/
|
|
20
|
+
identity: () => Promise<string | null>;
|
|
21
|
+
}
|
|
22
|
+
/** One snapshot of the current sign-in: the bearer and whose it is always come from the same read. */
|
|
23
|
+
export interface Credential {
|
|
24
|
+
bearer: string | null;
|
|
25
|
+
identity: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface BackendRequest {
|
|
28
|
+
method: "GET" | "POST";
|
|
29
|
+
path: string;
|
|
30
|
+
body?: unknown;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
}
|
|
33
|
+
export interface BackendAnswer {
|
|
34
|
+
status: number;
|
|
35
|
+
json: unknown;
|
|
36
|
+
}
|
|
37
|
+
/** One request to Heedvane: its status and decoded body, or null when it never answered in time (or there is no credential). */
|
|
38
|
+
export declare function callHeedvane(backend: Backend, request: BackendRequest): Promise<BackendAnswer | null>;
|
|
39
|
+
/** {@link callHeedvane} with a bearer already read, so the caller can vouch whose it is. */
|
|
40
|
+
export declare function callHeedvaneAs(url: string, bearer: string | null, request: BackendRequest): Promise<BackendAnswer | null>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** One request to Heedvane: its status and decoded body, or null when it never answered in time (or there is no credential). */
|
|
2
|
+
export async function callHeedvane(backend, request) {
|
|
3
|
+
return callHeedvaneAs(backend.url, (await backend.credential()).bearer, request);
|
|
4
|
+
}
|
|
5
|
+
/** {@link callHeedvane} with a bearer already read, so the caller can vouch whose it is. */
|
|
6
|
+
export async function callHeedvaneAs(url, bearer, request) {
|
|
7
|
+
return bearer === null || bearer === "" ? null : send(url, request, bearer);
|
|
8
|
+
}
|
|
9
|
+
async function send(url, request, bearer) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetch(`${url}${request.path}`, {
|
|
13
|
+
method: request.method,
|
|
14
|
+
headers: { authorization: `Bearer ${bearer}`, ...(request.body === undefined ? {} : { "content-type": "application/json" }) },
|
|
15
|
+
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
16
|
+
signal: AbortSignal.timeout(request.timeoutMs),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null; // unreachable or timed out: fail open
|
|
21
|
+
}
|
|
22
|
+
const text = await response.text().catch(() => "");
|
|
23
|
+
return { status: response.status, json: parseJson(text) };
|
|
24
|
+
}
|
|
25
|
+
function parseJson(text) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null; // a non-JSON body (proxy error page): the caller sees no usable answer
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Backend } from "./client.js";
|
|
2
|
+
import { type LoginTokenOptions } from "./login.js";
|
|
3
|
+
export type CredentialKind = "org";
|
|
4
|
+
export type Credentials = Partial<Record<CredentialKind, string>>;
|
|
5
|
+
export declare function credentialsPath(root: string): string;
|
|
6
|
+
/** Store one key beside the others, 0600. */
|
|
7
|
+
export declare function saveCredential(root: string, kind: CredentialKind, key: string): Promise<void>;
|
|
8
|
+
/** Heedvane at `url` when this machine has a sign-in or a key now, else null. The credential is read again on every call. */
|
|
9
|
+
export declare function backendAt(root: string, url: string, options?: LoginTokenOptions): Promise<Backend | null>;
|
|
10
|
+
/**
|
|
11
|
+
* The current sign-in's identity: the saved login's organization (else its client) at `url`, else the
|
|
12
|
+
* saved key's fingerprint; null with neither, or with a login or key file that cannot be read. Read
|
|
13
|
+
* now, so a login to another organization changes it at once, also for a listener started with a key.
|
|
14
|
+
*/
|
|
15
|
+
export declare function signInIdentity(root: string, url: string): Promise<string | null>;
|