@layr-labs/benchmaxx-arena-mcp 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/dist/config.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Configuration for the benchmaxx-arena MCP server.
3
+ *
4
+ * The only knob is the base-URL origin. `BENCHMAXX_ARENA_URL` overrides it;
5
+ * only the origin (scheme, host, port) is used, any path is dropped. The
6
+ * default is production. A bearer key travels on this transport, so the scheme
7
+ * must be `https`, with the single exception of `http://localhost` and
8
+ * `http://127.0.0.1` for local development.
9
+ */
10
+ export const PRODUCTION_ORIGIN = 'https://harnessarena.xyz';
11
+ export const ARENA_URL_ENV = 'BENCHMAXX_ARENA_URL';
12
+ /** A loopback host may use plain http; every other host must use https. */
13
+ function isLoopbackHost(hostname) {
14
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
15
+ }
16
+ /**
17
+ * Normalize a raw URL string to its origin. Throws on a malformed URL or a
18
+ * scheme that would send a credential in cleartext to a non-loopback host.
19
+ */
20
+ export function normalizeOrigin(raw) {
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(raw);
24
+ }
25
+ catch {
26
+ throw new Error(`${ARENA_URL_ENV} is not a valid URL: ${raw}. Use a full origin such as https://harnessarena.xyz.`);
27
+ }
28
+ if (parsed.protocol === 'https:') {
29
+ return parsed.origin;
30
+ }
31
+ if (parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname)) {
32
+ return parsed.origin;
33
+ }
34
+ throw new Error(`${ARENA_URL_ENV} must use https (http is allowed only for localhost): ${raw}.`);
35
+ }
36
+ /**
37
+ * Resolve the configuration from an environment map. Defaults to production
38
+ * when the override is absent or empty.
39
+ */
40
+ export function loadConfig(env = process.env) {
41
+ const raw = env[ARENA_URL_ENV];
42
+ if (raw === undefined || raw.trim() === '') {
43
+ return { origin: PRODUCTION_ORIGIN };
44
+ }
45
+ return { origin: normalizeOrigin(raw.trim()) };
46
+ }
47
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,CAAC,MAAM,iBAAiB,GAAG,0BAA0B,CAAA;AAE3D,MAAM,CAAC,MAAM,aAAa,GAAG,qBAAqB,CAAA;AAElD,2EAA2E;AAC3E,SAAS,cAAc,CAAC,QAAgB;IACtC,OAAO,QAAQ,KAAK,WAAW,IAAI,QAAQ,KAAK,WAAW,IAAI,QAAQ,KAAK,OAAO,CAAA;AACrF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,IAAI,MAAW,CAAA;IACf,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAA;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CACb,GAAG,aAAa,wBAAwB,GAAG,uDAAuD,CACnG,CAAA;IACH,CAAC;IAED,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACjC,OAAO,MAAM,CAAC,MAAM,CAAA;IACtB,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QACnE,OAAO,MAAM,CAAC,MAAM,CAAA;IACtB,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,GAAG,aAAa,yDAAyD,GAAG,GAAG,CAAC,CAAA;AAClG,CAAC;AAOD;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC7D,MAAM,GAAG,GAAG,GAAG,CAAC,aAAa,CAAC,CAAA;IAC9B,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC3C,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAA;IACtC,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,eAAe,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,CAAA;AAChD,CAAC"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Durable, secure storage for the minted API key.
3
+ *
4
+ * The store writes `~/.benchmaxx/credentials.json`: versioned JSON keyed by the
5
+ * base-URL origin, so one file holds many deployments. The directory is `0700`
6
+ * and the file is `0600`. Those modes are enforced on every write, and a
7
+ * pre-existing loose mode is repaired on read, because a `mode` on an open of
8
+ * an already-existing file is a no-op — the file could predate this code with a
9
+ * looser mode. A symlink at either path is refused.
10
+ *
11
+ * The filesystem and the home directory are injected so the store is
12
+ * unit-testable without touching a real home directory. The token is never
13
+ * logged.
14
+ */
15
+ export declare const CREDENTIALS_VERSION = 1;
16
+ /** One stored credential: the non-secret identity plus the secret token. */
17
+ export interface StoredCredential {
18
+ token: string;
19
+ keyId: string;
20
+ keyPrefix: string;
21
+ username: string;
22
+ }
23
+ /** The on-disk shape: versioned, keyed by origin. */
24
+ export interface CredentialsFile {
25
+ version: number;
26
+ credentials: Record<string, StoredCredential>;
27
+ }
28
+ /** The subset of node:fs/promises the store needs, injected for testing. */
29
+ export interface FsLike {
30
+ mkdir(path: string, options: {
31
+ recursive: boolean;
32
+ mode: number;
33
+ }): Promise<unknown>;
34
+ chmod(path: string, mode: number): Promise<void>;
35
+ readFile(path: string, encoding: 'utf8'): Promise<string>;
36
+ writeFile(path: string, data: string, options: {
37
+ mode: number;
38
+ flag?: string;
39
+ }): Promise<void>;
40
+ rename(oldPath: string, newPath: string): Promise<void>;
41
+ rm(path: string, options: {
42
+ force: boolean;
43
+ }): Promise<void>;
44
+ lstat(path: string): Promise<{
45
+ isSymbolicLink(): boolean;
46
+ mode: number;
47
+ }>;
48
+ }
49
+ /** A malformed or unreadable credentials file that the user must fix. */
50
+ export declare class CredentialsFileError extends Error {
51
+ constructor(message: string);
52
+ }
53
+ export interface CredentialStoreOptions {
54
+ fs: FsLike;
55
+ homeDir: string;
56
+ /** Path join, injected so tests need no node:path. Defaults to posix join. */
57
+ join?: (...parts: string[]) => string;
58
+ /**
59
+ * Suffix for the temp file each write owns. Injected so a test can assert the
60
+ * exact temp path. Defaults to the pid plus random bytes.
61
+ */
62
+ uniqueSuffix?: () => string;
63
+ }
64
+ export declare class CredentialStore {
65
+ private readonly fs;
66
+ private readonly dir;
67
+ private readonly file;
68
+ private readonly join;
69
+ private readonly uniqueSuffix;
70
+ constructor(options: CredentialStoreOptions);
71
+ /** Read the credential for one origin, or undefined when not authenticated. */
72
+ get(origin: string): Promise<StoredCredential | undefined>;
73
+ /** Store the credential for one origin, replacing any prior value. */
74
+ set(origin: string, credential: StoredCredential): Promise<void>;
75
+ /** Delete the credential for one origin. Returns true when one was removed. */
76
+ delete(origin: string): Promise<boolean>;
77
+ /**
78
+ * Read and parse the file. A missing file is "not authenticated" (an empty
79
+ * store). A present-but-unreadable or invalid-JSON file is a hard error the
80
+ * user must fix or delete. Repairs a loose file mode and refuses a symlink.
81
+ */
82
+ private read;
83
+ /**
84
+ * Write atomically. Refuse a symlinked directory (following it would drop the
85
+ * 0600 credential at an attacker-chosen target and defeat the 0700
86
+ * containment). Create the 0700 dir, then write a temp file with the
87
+ * exclusive `wx` flag so an attacker-planted temp symlink fails the open
88
+ * rather than being followed. chmod it (the open mode is a no-op on an
89
+ * existing file), then rename it into place and re-chmod.
90
+ *
91
+ * The temp name is unique per write. A single fixed `credentials.json.tmp`
92
+ * made concurrent writers collide: two servers (or a retried login) sharing
93
+ * one home directory would have the second `wx` open fail on the first's temp,
94
+ * or — worse, once the stale temp was force-removed — race on the same path
95
+ * and interleave. A unique name means each writer owns its temp and the
96
+ * rename is the only contended step, which is atomic. The temp is removed if
97
+ * anything after the open fails, so a crashed write leaves no 0600 litter.
98
+ */
99
+ private write;
100
+ private assertNotSymlink;
101
+ /** Tighten a pre-existing group/world-accessible path back to the target mode. */
102
+ private repairMode;
103
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Durable, secure storage for the minted API key.
3
+ *
4
+ * The store writes `~/.benchmaxx/credentials.json`: versioned JSON keyed by the
5
+ * base-URL origin, so one file holds many deployments. The directory is `0700`
6
+ * and the file is `0600`. Those modes are enforced on every write, and a
7
+ * pre-existing loose mode is repaired on read, because a `mode` on an open of
8
+ * an already-existing file is a no-op — the file could predate this code with a
9
+ * looser mode. A symlink at either path is refused.
10
+ *
11
+ * The filesystem and the home directory are injected so the store is
12
+ * unit-testable without touching a real home directory. The token is never
13
+ * logged.
14
+ */
15
+ import { randomBytes } from 'node:crypto';
16
+ export const CREDENTIALS_VERSION = 1;
17
+ /** A malformed or unreadable credentials file that the user must fix. */
18
+ export class CredentialsFileError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'CredentialsFileError';
22
+ }
23
+ }
24
+ function isNotFound(error) {
25
+ return error?.code === 'ENOENT';
26
+ }
27
+ const defaultJoin = (...parts) => parts.join('/');
28
+ const defaultUniqueSuffix = () => `${process.pid}.${randomBytes(6).toString('hex')}`;
29
+ export class CredentialStore {
30
+ fs;
31
+ dir;
32
+ file;
33
+ join;
34
+ uniqueSuffix;
35
+ constructor(options) {
36
+ this.fs = options.fs;
37
+ this.join = options.join ?? defaultJoin;
38
+ this.uniqueSuffix = options.uniqueSuffix ?? defaultUniqueSuffix;
39
+ this.dir = this.join(options.homeDir, '.benchmaxx');
40
+ this.file = this.join(this.dir, 'credentials.json');
41
+ }
42
+ /** Read the credential for one origin, or undefined when not authenticated. */
43
+ async get(origin) {
44
+ const file = await this.read();
45
+ return file.credentials[origin];
46
+ }
47
+ /** Store the credential for one origin, replacing any prior value. */
48
+ async set(origin, credential) {
49
+ const file = await this.read();
50
+ file.credentials[origin] = credential;
51
+ await this.write(file);
52
+ }
53
+ /** Delete the credential for one origin. Returns true when one was removed. */
54
+ async delete(origin) {
55
+ const file = await this.read();
56
+ if (!(origin in file.credentials)) {
57
+ return false;
58
+ }
59
+ delete file.credentials[origin];
60
+ await this.write(file);
61
+ return true;
62
+ }
63
+ /**
64
+ * Read and parse the file. A missing file is "not authenticated" (an empty
65
+ * store). A present-but-unreadable or invalid-JSON file is a hard error the
66
+ * user must fix or delete. Repairs a loose file mode and refuses a symlink.
67
+ */
68
+ async read() {
69
+ let raw;
70
+ try {
71
+ await this.assertNotSymlink(this.dir);
72
+ await this.assertNotSymlink(this.file);
73
+ raw = await this.fs.readFile(this.file, 'utf8');
74
+ }
75
+ catch (error) {
76
+ if (isNotFound(error)) {
77
+ return { version: CREDENTIALS_VERSION, credentials: {} };
78
+ }
79
+ if (error instanceof CredentialsFileError) {
80
+ throw error;
81
+ }
82
+ throw new CredentialsFileError(`cannot read ${this.file}. Fix its permissions or delete it, then run the login tool again.`);
83
+ }
84
+ await this.repairMode(this.dir, 0o700);
85
+ await this.repairMode(this.file, 0o600);
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ throw new CredentialsFileError(`${this.file} is not valid JSON. Delete it, then run the login tool again.`);
92
+ }
93
+ if (typeof parsed !== 'object' ||
94
+ parsed === null ||
95
+ typeof parsed.credentials !== 'object' ||
96
+ parsed.credentials === null) {
97
+ throw new CredentialsFileError(`${this.file} has an unexpected shape. Delete it, then run the login tool again.`);
98
+ }
99
+ const file = parsed;
100
+ return { version: file.version ?? CREDENTIALS_VERSION, credentials: file.credentials };
101
+ }
102
+ /**
103
+ * Write atomically. Refuse a symlinked directory (following it would drop the
104
+ * 0600 credential at an attacker-chosen target and defeat the 0700
105
+ * containment). Create the 0700 dir, then write a temp file with the
106
+ * exclusive `wx` flag so an attacker-planted temp symlink fails the open
107
+ * rather than being followed. chmod it (the open mode is a no-op on an
108
+ * existing file), then rename it into place and re-chmod.
109
+ *
110
+ * The temp name is unique per write. A single fixed `credentials.json.tmp`
111
+ * made concurrent writers collide: two servers (or a retried login) sharing
112
+ * one home directory would have the second `wx` open fail on the first's temp,
113
+ * or — worse, once the stale temp was force-removed — race on the same path
114
+ * and interleave. A unique name means each writer owns its temp and the
115
+ * rename is the only contended step, which is atomic. The temp is removed if
116
+ * anything after the open fails, so a crashed write leaves no 0600 litter.
117
+ */
118
+ async write(file) {
119
+ await this.assertNotSymlink(this.dir);
120
+ await this.fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
121
+ await this.fs.chmod(this.dir, 0o700);
122
+ const tmp = `${this.file}.${this.uniqueSuffix()}.tmp`;
123
+ const data = JSON.stringify({ version: CREDENTIALS_VERSION, credentials: file.credentials }, null, 2);
124
+ await this.fs.writeFile(tmp, data, { mode: 0o600, flag: 'wx' });
125
+ try {
126
+ await this.fs.chmod(tmp, 0o600);
127
+ await this.fs.rename(tmp, this.file);
128
+ }
129
+ catch (error) {
130
+ // The temp still holds the token; do not leave it behind on failure.
131
+ await this.fs.rm(tmp, { force: true }).catch(() => { });
132
+ throw error;
133
+ }
134
+ await this.fs.chmod(this.file, 0o600);
135
+ }
136
+ async assertNotSymlink(path) {
137
+ let stat;
138
+ try {
139
+ stat = await this.fs.lstat(path);
140
+ }
141
+ catch (error) {
142
+ if (isNotFound(error)) {
143
+ return;
144
+ }
145
+ throw error;
146
+ }
147
+ if (stat.isSymbolicLink()) {
148
+ throw new CredentialsFileError(`${path} is a symlink, which is refused for a credential file. Delete it, then run the login tool again.`);
149
+ }
150
+ }
151
+ /** Tighten a pre-existing group/world-accessible path back to the target mode. */
152
+ async repairMode(path, targetMode) {
153
+ try {
154
+ const stat = await this.fs.lstat(path);
155
+ const looseBits = stat.mode & 0o077;
156
+ if (looseBits !== 0) {
157
+ await this.fs.chmod(path, targetMode);
158
+ }
159
+ }
160
+ catch (error) {
161
+ if (isNotFound(error)) {
162
+ return;
163
+ }
164
+ throw error;
165
+ }
166
+ }
167
+ }
168
+ //# sourceMappingURL=credentialStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentialStore.js","sourceRoot":"","sources":["../src/credentialStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAA;AA2BpC,yEAAyE;AACzE,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAA;IACpC,CAAC;CACF;AAED,SAAS,UAAU,CAAC,KAAc;IAChC,OAAQ,KAA+B,EAAE,IAAI,KAAK,QAAQ,CAAA;AAC5D,CAAC;AAcD,MAAM,WAAW,GAAG,CAAC,GAAG,KAAe,EAAU,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAEnE,MAAM,mBAAmB,GAAG,GAAW,EAAE,CAAC,GAAG,OAAO,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AAE5F,MAAM,OAAO,eAAe;IACT,EAAE,CAAQ;IACV,GAAG,CAAQ;IACX,IAAI,CAAQ;IACZ,IAAI,CAAgC;IACpC,YAAY,CAAc;IAE3C,YAAY,OAA+B;QACzC,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,EAAE,CAAA;QACpB,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,CAAA;QACvC,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,mBAAmB,CAAA;QAC/D,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,YAAY,CAAC,CAAA;QACnD,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAA;IACrD,CAAC;IAED,+EAA+E;IAC/E,KAAK,CAAC,GAAG,CAAC,MAAc;QACtB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAA;QAC9B,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;IACjC,CAAC;IAED,sEAAsE;IACtE,KAAK,CAAC,GAAG,CAAC,MAAc,EAAE,UAA4B;QACpD,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAA;QAC9B,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,UAAU,CAAA;QACrC,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACxB,CAAC;IAED,+EAA+E;IAC/E,KAAK,CAAC,MAAM,CAAC,MAAc;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAA;QAC9B,IAAI,CAAC,CAAC,MAAM,IAAI,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;YAClC,OAAO,KAAK,CAAA;QACd,CAAC;QACD,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;QAC/B,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACtB,OAAO,IAAI,CAAA;IACb,CAAC;IAED;;;;OAIG;IACK,KAAK,CAAC,IAAI;QAChB,IAAI,GAAW,CAAA;QACf,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;YACrC,MAAM,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACtC,GAAG,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;QACjD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;gBACtB,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,EAAE,EAAE,CAAA;YAC1D,CAAC;YACD,IAAI,KAAK,YAAY,oBAAoB,EAAE,CAAC;gBAC1C,MAAM,KAAK,CAAA;YACb,CAAC;YACD,MAAM,IAAI,oBAAoB,CAC5B,eAAe,IAAI,CAAC,IAAI,oEAAoE,CAC7F,CAAA;QACH,CAAC;QAED,MAAM,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;QACtC,MAAM,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;QAEvC,IAAI,MAAe,CAAA;QACnB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC1B,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,oBAAoB,CAC5B,GAAG,IAAI,CAAC,IAAI,+DAA+D,CAC5E,CAAA;QACH,CAAC;QAED,IACE,OAAO,MAAM,KAAK,QAAQ;YAC1B,MAAM,KAAK,IAAI;YACf,OAAQ,MAA0B,CAAC,WAAW,KAAK,QAAQ;YAC1D,MAA0B,CAAC,WAAW,KAAK,IAAI,EAChD,CAAC;YACD,MAAM,IAAI,oBAAoB,CAC5B,GAAG,IAAI,CAAC,IAAI,qEAAqE,CAClF,CAAA;QACH,CAAC;QAED,MAAM,IAAI,GAAG,MAAyB,CAAA;QACtC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,mBAAmB,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAA;IACxF,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACK,KAAK,CAAC,KAAK,CAAC,IAAqB;QACvC,MAAM,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACrC,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;QAC/D,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;QAEpC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,YAAY,EAAE,MAAM,CAAA;QACrD,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CACzB,EAAE,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,EAC/D,IAAI,EACJ,CAAC,CACF,CAAA;QACD,MAAM,IAAI,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;QAC/D,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YAC/B,MAAM,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;QACtC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,qEAAqE;YACrE,MAAM,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;YACtD,MAAM,KAAK,CAAA;QACb,CAAC;QACD,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IACvC,CAAC;IAEO,KAAK,CAAC,gBAAgB,CAAC,IAAY;QACzC,IAAI,IAAmC,CAAA;QACvC,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QAClC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;gBACtB,OAAM;YACR,CAAC;YACD,MAAM,KAAK,CAAA;QACb,CAAC;QACD,IAAI,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC;YAC1B,MAAM,IAAI,oBAAoB,CAC5B,GAAG,IAAI,kGAAkG,CAC1G,CAAA;QACH,CAAC;IACH,CAAC;IAED,kFAAkF;IAC1E,KAAK,CAAC,UAAU,CAAC,IAAY,EAAE,UAAkB;QACvD,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YACtC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,GAAG,KAAK,CAAA;YACnC,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;gBACpB,MAAM,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;YACvC,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;gBACtB,OAAM;YACR,CAAC;YACD,MAAM,KAAK,CAAA;QACb,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * GitHub device-flow poll loop.
3
+ *
4
+ * The primitives (`nextPollDelay`, `devicePollDeadline`, `assertBeforeDevicePoll`)
5
+ * are ported from frontend/src/workbench/device.ts so the two clients honor the
6
+ * same PENDING/SLOW_DOWN/expiry rules. `runDevicePoll` drives them with an
7
+ * injected clock, sleep, and poll function, so the loop is unit-testable with no
8
+ * wall-clock and no network.
9
+ */
10
+ export declare const DEVICE_STATUS: {
11
+ readonly UNSPECIFIED: "DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED";
12
+ readonly PENDING: "DEVICE_AUTHORIZATION_STATUS_PENDING";
13
+ readonly SLOW_DOWN: "DEVICE_AUTHORIZATION_STATUS_SLOW_DOWN";
14
+ readonly EXPIRED: "DEVICE_AUTHORIZATION_STATUS_EXPIRED";
15
+ readonly DENIED: "DEVICE_AUTHORIZATION_STATUS_DENIED";
16
+ readonly SUCCESS: "DEVICE_AUTHORIZATION_STATUS_SUCCESS";
17
+ };
18
+ /** Minted credentials returned on a successful poll. */
19
+ export interface DeviceCredentials {
20
+ token: string;
21
+ id: string;
22
+ prefix: string;
23
+ }
24
+ /**
25
+ * One poll outcome, the fields of PollDeviceResponse this loop reads.
26
+ *
27
+ * `credentials` is a message field and the gateway sets EmitUnpopulated, so a
28
+ * pending poll carries an explicit `null` rather than omitting the key. The
29
+ * SUCCESS branch's falsy check covers both.
30
+ */
31
+ export interface DeviceOutcome {
32
+ status?: string;
33
+ pending?: boolean;
34
+ retryAfterSeconds?: number;
35
+ errorCode?: string;
36
+ credentials?: DeviceCredentials | null;
37
+ username?: string;
38
+ }
39
+ /**
40
+ * The next poll delay in seconds for a non-terminal outcome, or undefined when
41
+ * the outcome is terminal. Honors the server's retry hint but never goes below
42
+ * the previous delay (GitHub's cumulative slow_down behavior).
43
+ */
44
+ export declare function nextPollDelay(previousSeconds: number, outcome: DeviceOutcome): number | undefined;
45
+ /** Absolute deadline (ms epoch) after which polling must stop. */
46
+ export declare function devicePollDeadline(expiresInSeconds: number, now: number): number;
47
+ /** Throw when the deadline has passed. */
48
+ export declare function assertBeforeDevicePoll(deadline: number, now: number): void;
49
+ /** Raised when the caller cancels the login. */
50
+ export declare class DeviceLoginCancelled extends Error {
51
+ constructor();
52
+ }
53
+ export interface DevicePollDeps {
54
+ /** Sleep for a number of milliseconds. */
55
+ sleep: (ms: number) => Promise<void>;
56
+ /** Current time in ms epoch. */
57
+ now: () => number;
58
+ /** Perform one poll of the device-token endpoint. */
59
+ poll: (pollIntervalSeconds: number) => Promise<DeviceOutcome>;
60
+ /**
61
+ * Cancellation from the caller. Checked at both loop boundaries, so an
62
+ * abandoned login stops polling instead of running to the device deadline.
63
+ */
64
+ signal?: AbortSignal;
65
+ /**
66
+ * Called before each wait. Used to emit progress so an MCP host sees liveness
67
+ * and does not time the tool out while the human is approving.
68
+ */
69
+ onWait?: () => Promise<void> | void;
70
+ }
71
+ export interface DevicePollParams {
72
+ /** Initial poll interval from DeviceAuthorizationResponse.interval. */
73
+ intervalSeconds: number;
74
+ /** Device authorization lifetime from DeviceAuthorizationResponse.expiresIn. */
75
+ expiresInSeconds: number;
76
+ }
77
+ /** The identity produced by a successful device flow. */
78
+ export interface DeviceLoginResult {
79
+ credentials: DeviceCredentials;
80
+ username: string;
81
+ }
82
+ /**
83
+ * Poll until the device authorization is approved, denied, or expired. Returns
84
+ * the credentials on SUCCESS. Throws a clear, actionable error on DENIED,
85
+ * EXPIRED, or the deadline.
86
+ */
87
+ export declare function runDevicePoll(deps: DevicePollDeps, params: DevicePollParams): Promise<DeviceLoginResult>;
package/dist/device.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * GitHub device-flow poll loop.
3
+ *
4
+ * The primitives (`nextPollDelay`, `devicePollDeadline`, `assertBeforeDevicePoll`)
5
+ * are ported from frontend/src/workbench/device.ts so the two clients honor the
6
+ * same PENDING/SLOW_DOWN/expiry rules. `runDevicePoll` drives them with an
7
+ * injected clock, sleep, and poll function, so the loop is unit-testable with no
8
+ * wall-clock and no network.
9
+ */
10
+ export const DEVICE_STATUS = {
11
+ UNSPECIFIED: 'DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED',
12
+ PENDING: 'DEVICE_AUTHORIZATION_STATUS_PENDING',
13
+ SLOW_DOWN: 'DEVICE_AUTHORIZATION_STATUS_SLOW_DOWN',
14
+ EXPIRED: 'DEVICE_AUTHORIZATION_STATUS_EXPIRED',
15
+ DENIED: 'DEVICE_AUTHORIZATION_STATUS_DENIED',
16
+ SUCCESS: 'DEVICE_AUTHORIZATION_STATUS_SUCCESS',
17
+ };
18
+ /**
19
+ * The next poll delay in seconds for a non-terminal outcome, or undefined when
20
+ * the outcome is terminal. Honors the server's retry hint but never goes below
21
+ * the previous delay (GitHub's cumulative slow_down behavior).
22
+ */
23
+ export function nextPollDelay(previousSeconds, outcome) {
24
+ if (outcome.status === DEVICE_STATUS.PENDING || outcome.status === DEVICE_STATUS.SLOW_DOWN) {
25
+ return Math.max(previousSeconds, outcome.retryAfterSeconds ?? previousSeconds);
26
+ }
27
+ return undefined;
28
+ }
29
+ /** Absolute deadline (ms epoch) after which polling must stop. */
30
+ export function devicePollDeadline(expiresInSeconds, now) {
31
+ if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
32
+ throw new Error('device authorization expiry is invalid');
33
+ }
34
+ return now + expiresInSeconds * 1000;
35
+ }
36
+ /** Throw when the deadline has passed. */
37
+ export function assertBeforeDevicePoll(deadline, now) {
38
+ if (now >= deadline) {
39
+ throw new Error('device authorization expired before you approved it — run the login tool again.');
40
+ }
41
+ }
42
+ /** Raised when the caller cancels the login. */
43
+ export class DeviceLoginCancelled extends Error {
44
+ constructor() {
45
+ super('the login was cancelled.');
46
+ this.name = 'DeviceLoginCancelled';
47
+ }
48
+ }
49
+ function assertNotCancelled(signal) {
50
+ if (signal?.aborted) {
51
+ throw new DeviceLoginCancelled();
52
+ }
53
+ }
54
+ /**
55
+ * Poll until the device authorization is approved, denied, or expired. Returns
56
+ * the credentials on SUCCESS. Throws a clear, actionable error on DENIED,
57
+ * EXPIRED, or the deadline.
58
+ */
59
+ export async function runDevicePoll(deps, params) {
60
+ const deadline = devicePollDeadline(params.expiresInSeconds, deps.now());
61
+ let delaySeconds = Math.max(1, params.intervalSeconds);
62
+ for (;;) {
63
+ assertNotCancelled(deps.signal);
64
+ assertBeforeDevicePoll(deadline, deps.now());
65
+ await deps.onWait?.();
66
+ await deps.sleep(delaySeconds * 1000);
67
+ // Recheck after the wait: cancellation almost always lands during it.
68
+ assertNotCancelled(deps.signal);
69
+ assertBeforeDevicePoll(deadline, deps.now());
70
+ const outcome = await deps.poll(delaySeconds);
71
+ if (outcome.status === DEVICE_STATUS.SUCCESS) {
72
+ if (!outcome.credentials || !outcome.username) {
73
+ throw new Error('the arena approved the login but returned no credentials — try again.');
74
+ }
75
+ return { credentials: outcome.credentials, username: outcome.username };
76
+ }
77
+ if (outcome.status === DEVICE_STATUS.DENIED) {
78
+ throw new Error('the login request was denied on GitHub — run the login tool again to retry.');
79
+ }
80
+ if (outcome.status === DEVICE_STATUS.EXPIRED) {
81
+ throw new Error('device authorization expired before you approved it — run the login tool again.');
82
+ }
83
+ const next = nextPollDelay(delaySeconds, outcome);
84
+ if (next === undefined) {
85
+ // Unknown or unspecified status: treat as an unrecoverable failure rather
86
+ // than looping forever.
87
+ throw new Error('the login could not complete — run the login tool again.');
88
+ }
89
+ delaySeconds = next;
90
+ }
91
+ }
92
+ //# sourceMappingURL=device.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.js","sourceRoot":"","sources":["../src/device.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Error types and the mapping from an API failure to one actionable message.
3
+ *
4
+ * Two failure kinds reach a tool:
5
+ * - `ApiError`: a non-2xx HTTP response. It carries the status and the
6
+ * server's `rpcStatus.message` (mirrors frontend/src/api/http.ts).
7
+ * - `NetworkError`: the fetch itself rejected (DNS, connection refused, TLS).
8
+ *
9
+ * `toActionableMessage` turns either into a single message per issue #166 §5.4.
10
+ * It never includes a stack trace, a raw response body, or the bearer token.
11
+ */
12
+ /** A failed HTTP call. `message` is the server's message; `status` is the code. */
13
+ export declare class ApiError extends Error {
14
+ readonly status: number;
15
+ constructor(status: number, message: string);
16
+ }
17
+ /** The fetch rejected before any HTTP status was seen. */
18
+ export declare class NetworkError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ /**
22
+ * Raised when the caller cancels a login.
23
+ *
24
+ * This lives here rather than in the flow that throws it, so both the client and
25
+ * the loopback listener raise the one type `toActionableMessage` recognizes. A
26
+ * plain `Error` would fall through to the generic copy and lose the friendly
27
+ * message.
28
+ */
29
+ export declare class LoginCancelled extends Error {
30
+ constructor();
31
+ }
32
+ /** Raised when the loopback listener's deadline passes before a code arrives. */
33
+ export declare class LoginTimedOut extends Error {
34
+ constructor();
35
+ }
36
+ /**
37
+ * Raised when POST /v1/auth/cli/redeem rejects the code or the verifier.
38
+ *
39
+ * A redeem 401 is NOT a revoked stored key — no stored key is involved — so it
40
+ * must not reach the generic 401 mapping, which would tell the user to log in
41
+ * again with no hint that their code is simply spent.
42
+ */
43
+ export declare class CliRedeemFailed extends Error {
44
+ constructor();
45
+ }
46
+ /**
47
+ * Map any thrown failure to one actionable message. Leaks no internals: an
48
+ * unknown error becomes a generic message, never a raw string or stack.
49
+ */
50
+ export declare function toActionableMessage(error: unknown): string;