relayrun 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.
@@ -0,0 +1,108 @@
1
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * MAKING AN EXPLICITLY SUPPLIED KEY ACTUALLY BE THE ONE THAT'S USED.
6
+ *
7
+ * This exists because the obvious implementation is silently wrong. Passing
8
+ * `env: { ANTHROPIC_API_KEY: key }` to the SDK looks like it supplies the key,
9
+ * typechecks, and produces a successful run — but on any machine where the
10
+ * operator has logged into Claude Code, the subprocess ignores the variable and
11
+ * authenticates with their stored OAuth token instead. Verified by pointing
12
+ * ANTHROPIC_BASE_URL at a local server and reading the headers: every request
13
+ * carried `Authorization: Bearer sk-ant-oat...`, the operator's own credential,
14
+ * with the supplied key nowhere.
15
+ *
16
+ * `apiKeyHelper` is the mechanism that does work. It's a script the CLI runs to
17
+ * obtain a credential, and its output is sent as `x-api-key`, taking precedence
18
+ * over the stored OAuth token.
19
+ *
20
+ * The script deliberately contains no secret. It echoes an environment
21
+ * variable, and the key travels in the process environment exactly as before.
22
+ */
23
+ /** The env var the helper reads. Must match what the runner sets. */
24
+ export const SESSION_KEY_ENV = "RELAY_SESSION_API_KEY";
25
+ const SCRIPT = `#!/bin/sh\nprintf %s "$${SESSION_KEY_ENV}"\n`;
26
+ let helperPath = null;
27
+ /**
28
+ * Path to the helper script, created once per process on first use. Returns
29
+ * null if it can't be created (read-only tmp, a platform without /bin/sh).
30
+ */
31
+ export function apiKeyHelperPath() {
32
+ if (helperPath)
33
+ return helperPath;
34
+ try {
35
+ // 0700 on both the directory and the script: it's executed by a subprocess
36
+ // of this process and nothing else needs to read it.
37
+ const dir = mkdtempSync(path.join(tmpdir(), "relay-key-"));
38
+ chmodSync(dir, 0o700);
39
+ const file = path.join(dir, "relay-api-key");
40
+ writeFileSync(file, SCRIPT, { mode: 0o700 });
41
+ chmodSync(file, 0o700);
42
+ process.once("exit", () => {
43
+ try {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ catch {
47
+ // Best effort — it holds no secret, and tmp is cleared on reboot.
48
+ }
49
+ });
50
+ helperPath = file;
51
+ return helperPath;
52
+ }
53
+ catch (err) {
54
+ console.error("could not create the API key helper:", err instanceof Error ? err.message : err);
55
+ return null;
56
+ }
57
+ }
58
+ const VERIFY_TIMEOUT_MS = 10_000;
59
+ /**
60
+ * Checks a key at startup rather than on first use.
61
+ *
62
+ * Without this, a mistyped or revoked key is accepted silently and the operator
63
+ * finds out only when an instruction arrives: the agent retries the 401 with
64
+ * backoff for over three minutes, then reports the failure as its answer.
65
+ * Measured — 191 seconds from instruction to "Failed to authenticate". One
66
+ * request up front turns that into an immediate, accurate "no".
67
+ *
68
+ * `/v1/models` is the cheapest authenticated endpoint there is.
69
+ */
70
+ export async function verifyApiKey(key) {
71
+ const base = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com";
72
+ try {
73
+ const res = await fetch(`${base.replace(/\/$/, "")}/v1/models?limit=1`, {
74
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
75
+ signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
76
+ });
77
+ if (res.ok)
78
+ return { ok: true };
79
+ if (res.status === 401 || res.status === 403) {
80
+ return { ok: false, error: "Anthropic rejected that key" };
81
+ }
82
+ if (res.status === 429) {
83
+ // The key is real — it's the account that's over its limit. Accepting it
84
+ // is right: the rate limit may well have cleared by the first run.
85
+ return { ok: true };
86
+ }
87
+ return {
88
+ ok: false,
89
+ error: `could not verify that key (Anthropic returned ${res.status})`,
90
+ };
91
+ }
92
+ catch {
93
+ // Offline, DNS failure, or the timeout above. Refuse rather than accept
94
+ // optimistically: if this machine can't reach Anthropic now, the agent
95
+ // can't either, and a key accepted here would fail confusingly later.
96
+ return { ok: false, error: "could not reach Anthropic to check that key" };
97
+ }
98
+ }
99
+ const KEY_SHAPE = /^[A-Za-z0-9_-]+$/;
100
+ const MAX_KEY_LENGTH = 300;
101
+ /** Shape-only check, so an obviously malformed key fails before a network call. */
102
+ export function looksLikeApiKey(key) {
103
+ return (key.startsWith("sk-ant-") && key.length <= MAX_KEY_LENGTH && KEY_SHAPE.test(key));
104
+ }
105
+ /** The only part of a key that may leave this machine. */
106
+ export function keyHint(key) {
107
+ return key.slice(-4);
108
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "relayrun",
3
+ "version": "0.1.0",
4
+ "description": "Run a Relay session against a repository on your own machine — everyone with the link watches the agent work, one person drives.",
5
+ "keywords": [
6
+ "claude",
7
+ "agent",
8
+ "collaboration",
9
+ "pair-programming",
10
+ "cli"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "bin": {
15
+ "relayrun": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20.9.0"
23
+ },
24
+ "dependencies": {
25
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
26
+ "ws": "8.21.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "26.1.2",
30
+ "@types/ws": "8.18.1",
31
+ "tsx": "4.23.1",
32
+ "typescript": "7.0.2",
33
+ "@relay/shared": "0.0.0"
34
+ },
35
+ "scripts": {
36
+ "dev": "tsx src/cli.ts --mock --server http://localhost:4000 --web http://localhost:3000",
37
+ "build": "tsc -p tsconfig.json",
38
+ "start": "node dist/cli.js",
39
+ "typecheck": "tsc --noEmit"
40
+ }
41
+ }