@eir-labs/coltrane 0.8.1 → 0.9.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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The working tree a gig runs in, obtained AFTER the claim.
3
+ *
4
+ * WHY THIS EXISTS. `drain-loop.sh` cloned a repository BEFORE claiming, from a `REPO_URL` fixed in
5
+ * the box's environment at provisioning. That made a per-gig fact a per-box one — the same category
6
+ * error as the `worker_agent` the venue design already removed — and it meant every gig an
7
+ * organization ever dispatched had to work in the same repository.
8
+ *
9
+ * The obvious repair, letting a gig name its own repository through `input_data`, was refused on
10
+ * review and the reasons are worth keeping close to the code:
11
+ *
12
+ * - `input_data` is authored under a gate asking "may this agent RUN this standard". That is not
13
+ * "may it WRITE this repository", and nobody had asked the second question.
14
+ * - `git clone` accepts `ext::sh -c '…'` as a URL. A gig-supplied string is command execution.
15
+ *
16
+ * So the STORE names the repository, on the claim, from a governed column. Nothing the gig carries
17
+ * can influence it, because there is no field in which to carry it.
18
+ *
19
+ * AND THE CREDENTIAL IS FETCHED PER GIG, against a live lease. Not held at boot, not in the
20
+ * container's environment, not the same one twice. A drain between gigs holds no git credential at
21
+ * all — which is the property the per-gig store credential already has, extended to the half that
22
+ * previously sat in a Fly secret for the life of the machine.
23
+ */
24
+ /** What the mint endpoint answers with. `expires_at` is GitHub's hour, not our thirty-minute lease
25
+ * — carried through so a caller can see the difference rather than assume they match. */
26
+ export interface GitCredential {
27
+ token: string;
28
+ expires_at?: string;
29
+ }
30
+ export interface PreparedWorkspace {
31
+ /** Absolute path to the clone. The gig runs with this as cwd. */
32
+ dir: string;
33
+ /** Idempotent. Safe to call from a `finally` that may run after a partial failure. */
34
+ cleanup: () => void;
35
+ /**
36
+ * Hand the git credential back when the gig is done.
37
+ *
38
+ * A GitHub installation token is fixed at ONE HOUR and the lease that justified it is thirty
39
+ * minutes, so the git half outlives its own authority by at least 2x — and no revocation on our
40
+ * side can recall it. GitHub exposes exactly one way to end one early: DELETE
41
+ * /installation/token, authenticated WITH that token.
42
+ *
43
+ * Which means only a cooperative holder can do it. That is not a security control and must not be
44
+ * described as one: a compromised drain simply declines to call this. It is a hygiene measure for
45
+ * the ordinary case, and the ordinary case is every gig — a run that takes four minutes stops
46
+ * holding a live credential fifty-six minutes early.
47
+ *
48
+ * Best-effort by construction: a failure here must never fail a drained gig.
49
+ */
50
+ revoke: () => Promise<void>;
51
+ }
52
+ /**
53
+ * Trade the venue credential for a git credential scoped to ONE gig's repository.
54
+ *
55
+ * The endpoint decides nothing: it asks the store whether this instance currently holds a live
56
+ * lease on this gig, and mints only for the repository the store names. So a stolen drain key
57
+ * yields nothing here unless the thief is also, right now, doing that gig's work.
58
+ */
59
+ export declare function fetchGitCredential(endpoint: string, drainKey: string, instance: string, gigId: string): Promise<GitCredential>;
60
+ /**
61
+ * Shallow-clone `repoUrl` into a fresh temp directory.
62
+ *
63
+ * THE TOKEN NEVER TOUCHES DISK, and that is not incidental. Embedding it in the remote URL
64
+ * (`https://x-access-token:$TOKEN@github.com/…`) writes it verbatim into `.git/config` — inside the
65
+ * very tree the gig's seats then read and write, one `git remote -v` away from model context. A
66
+ * credential helper is consulted only when the server challenges, and reads the token from the
67
+ * helper's own environment at that moment.
68
+ *
69
+ * The helper is supplied through GIT_CONFIG_* rather than `-c`, because `git clone -c k=v` writes k
70
+ * into the NEW clone's .git/config — see the note at the call site. Scoped to `https://github.com`
71
+ * so a URL naming any other host is never offered the token.
72
+ */
73
+ export declare function cloneInto(repoUrl: string, token: string): PreparedWorkspace;
74
+ /**
75
+ * Everything above, for one claimed gig — or null when the claim names no repository.
76
+ *
77
+ * NULL IS A NORMAL ANSWER, not a degraded one. An organization that declares no `repo_url` runs
78
+ * gigs that do not touch a working tree, and refusing to run them because a repository is absent
79
+ * would repeat the boot-time refusal this whole change removes. A gig that DOES need a tree will
80
+ * fail on its own terms, naming what it could not find, which is a better error than any this
81
+ * layer could invent.
82
+ */
83
+ export declare function prepareWorkspace(opts: {
84
+ repoUrl: string | null | undefined;
85
+ gigId: string;
86
+ drainKey: string | undefined;
87
+ instance: string | undefined;
88
+ endpoint: string | undefined;
89
+ }): Promise<PreparedWorkspace | null>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The working tree a gig runs in, obtained AFTER the claim.
3
+ *
4
+ * WHY THIS EXISTS. `drain-loop.sh` cloned a repository BEFORE claiming, from a `REPO_URL` fixed in
5
+ * the box's environment at provisioning. That made a per-gig fact a per-box one — the same category
6
+ * error as the `worker_agent` the venue design already removed — and it meant every gig an
7
+ * organization ever dispatched had to work in the same repository.
8
+ *
9
+ * The obvious repair, letting a gig name its own repository through `input_data`, was refused on
10
+ * review and the reasons are worth keeping close to the code:
11
+ *
12
+ * - `input_data` is authored under a gate asking "may this agent RUN this standard". That is not
13
+ * "may it WRITE this repository", and nobody had asked the second question.
14
+ * - `git clone` accepts `ext::sh -c '…'` as a URL. A gig-supplied string is command execution.
15
+ *
16
+ * So the STORE names the repository, on the claim, from a governed column. Nothing the gig carries
17
+ * can influence it, because there is no field in which to carry it.
18
+ *
19
+ * AND THE CREDENTIAL IS FETCHED PER GIG, against a live lease. Not held at boot, not in the
20
+ * container's environment, not the same one twice. A drain between gigs holds no git credential at
21
+ * all — which is the property the per-gig store credential already has, extended to the half that
22
+ * previously sat in a Fly secret for the life of the machine.
23
+ */
24
+ import { execFileSync } from "node:child_process";
25
+ import { mkdtempSync, rmSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ /**
29
+ * Trade the venue credential for a git credential scoped to ONE gig's repository.
30
+ *
31
+ * The endpoint decides nothing: it asks the store whether this instance currently holds a live
32
+ * lease on this gig, and mints only for the repository the store names. So a stolen drain key
33
+ * yields nothing here unless the thief is also, right now, doing that gig's work.
34
+ */
35
+ export async function fetchGitCredential(endpoint, drainKey, instance, gigId) {
36
+ const res = await fetch(endpoint, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify({ drain_key: drainKey, instance, gig_id: gigId }),
40
+ });
41
+ const text = await res.text();
42
+ if (!res.ok) {
43
+ let detail = text;
44
+ try {
45
+ detail = JSON.parse(text).error ?? text;
46
+ }
47
+ catch { /* keep the raw body */ }
48
+ throw new Error(`git credential refused (${res.status}): ${detail}`);
49
+ }
50
+ const body = JSON.parse(text);
51
+ if (!body.token)
52
+ throw new Error("git credential endpoint returned no token");
53
+ return { token: body.token, ...(body.expires_at ? { expires_at: body.expires_at } : {}) };
54
+ }
55
+ /**
56
+ * Shallow-clone `repoUrl` into a fresh temp directory.
57
+ *
58
+ * THE TOKEN NEVER TOUCHES DISK, and that is not incidental. Embedding it in the remote URL
59
+ * (`https://x-access-token:$TOKEN@github.com/…`) writes it verbatim into `.git/config` — inside the
60
+ * very tree the gig's seats then read and write, one `git remote -v` away from model context. A
61
+ * credential helper is consulted only when the server challenges, and reads the token from the
62
+ * helper's own environment at that moment.
63
+ *
64
+ * The helper is supplied through GIT_CONFIG_* rather than `-c`, because `git clone -c k=v` writes k
65
+ * into the NEW clone's .git/config — see the note at the call site. Scoped to `https://github.com`
66
+ * so a URL naming any other host is never offered the token.
67
+ */
68
+ export function cloneInto(repoUrl, token) {
69
+ const dir = mkdtempSync(join(tmpdir(), "coltrane-gig-"));
70
+ const cleanup = () => {
71
+ try {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ catch { /* a temp dir that will not delete is not worth failing a drained gig over */ }
75
+ };
76
+ const revoke = async () => {
77
+ try {
78
+ await fetch("https://api.github.com/installation/token", {
79
+ method: "DELETE",
80
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
81
+ });
82
+ }
83
+ catch { /* best effort: the token expires on its own within the hour regardless */ }
84
+ };
85
+ try {
86
+ execFileSync("git", ["clone", "--quiet", "--depth", "1", repoUrl, dir], {
87
+ // CONFIG VIA ENVIRONMENT, NOT `-c`. `git clone -c k=v` PERSISTS k into the new clone's
88
+ // .git/config — so a helper passed that way survives into the very tree the gig's seats
89
+ // read. The token itself would not be there, but the helper would, and a later
90
+ // `git push` from a publish seat would consult it, find COLTRANE_GIT_TOKEN unset in that
91
+ // seat's environment, and send an empty password. Caught by a test asserting on the
92
+ // resulting .git/config rather than on the arguments passed.
93
+ //
94
+ // GIT_CONFIG_COUNT applies config to THIS process only and writes nothing to the clone.
95
+ //
96
+ // Scoped to https://github.com so a URL naming any other host is never offered the token.
97
+ // The store constrains repo_url to https and this layer cannot be reached with a
98
+ // gig-supplied string, but a credential helper that answers any host is one refactor away
99
+ // from exfiltration and costs nothing to scope now.
100
+ env: {
101
+ ...process.env,
102
+ COLTRANE_GIT_TOKEN: token,
103
+ GIT_CONFIG_COUNT: "1",
104
+ GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
105
+ GIT_CONFIG_VALUE_0: '!f() { echo username=x-access-token; echo "password=$COLTRANE_GIT_TOKEN"; }; f',
106
+ },
107
+ stdio: ["ignore", "ignore", "pipe"],
108
+ });
109
+ }
110
+ catch (e) {
111
+ cleanup();
112
+ // git writes the useful part to stderr; the message alone is usually just an exit status.
113
+ const stderr = e.stderr?.toString().trim();
114
+ throw new Error(`clone of ${repoUrl} failed${stderr ? `: ${stderr}` : ""}`);
115
+ }
116
+ return { dir, cleanup, revoke };
117
+ }
118
+ /**
119
+ * Everything above, for one claimed gig — or null when the claim names no repository.
120
+ *
121
+ * NULL IS A NORMAL ANSWER, not a degraded one. An organization that declares no `repo_url` runs
122
+ * gigs that do not touch a working tree, and refusing to run them because a repository is absent
123
+ * would repeat the boot-time refusal this whole change removes. A gig that DOES need a tree will
124
+ * fail on its own terms, naming what it could not find, which is a better error than any this
125
+ * layer could invent.
126
+ */
127
+ export async function prepareWorkspace(opts) {
128
+ if (!opts.repoUrl)
129
+ return null;
130
+ if (!opts.drainKey || !opts.instance) {
131
+ throw new Error(`the claim named ${opts.repoUrl} but this worker holds no venue credential, so it cannot ` +
132
+ `obtain a git credential for it — set COLTRANE_DRAIN_KEY and COLTRANE_INSTANCE`);
133
+ }
134
+ if (!opts.endpoint) {
135
+ throw new Error(`the claim named ${opts.repoUrl} but COLTRANE_GIT_CREDENTIALS_URL is unset, so there is ` +
136
+ `nowhere to obtain a credential scoped to this gig`);
137
+ }
138
+ const cred = await fetchGitCredential(opts.endpoint, opts.drainKey, opts.instance, opts.gigId);
139
+ return cloneInto(opts.repoUrl, cred.token);
140
+ }
141
+ //# sourceMappingURL=workspace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace.js","sourceRoot":"","sources":["../../src/workspace.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eir-labs/coltrane",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "A methodology engine — a typed substrate for defining agents, composing multi-phase standards, dispatching gigs, and sealing every output to a content-addressed ledger. Ships as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",