borgmcp 0.9.56 → 0.9.58

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.
Files changed (60) hide show
  1. package/README.md +104 -176
  2. package/dist/assimilate-cmd.d.ts +2 -5
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +12 -0
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -9
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/auth-env.d.ts +52 -0
  10. package/dist/auth-env.d.ts.map +1 -0
  11. package/dist/auth-env.js +107 -0
  12. package/dist/auth-env.js.map +1 -0
  13. package/dist/auth.d.ts +33 -13
  14. package/dist/auth.d.ts.map +1 -1
  15. package/dist/auth.js +100 -4
  16. package/dist/auth.js.map +1 -1
  17. package/dist/claude.js +28 -6
  18. package/dist/claude.js.map +1 -1
  19. package/dist/cli-help.d.ts +16 -0
  20. package/dist/cli-help.d.ts.map +1 -0
  21. package/dist/cli-help.js +27 -0
  22. package/dist/cli-help.js.map +1 -0
  23. package/dist/cli-platform.js +1 -1
  24. package/dist/cli-platform.js.map +1 -1
  25. package/dist/codex-remote.d.ts +60 -12
  26. package/dist/codex-remote.d.ts.map +1 -1
  27. package/dist/codex-remote.js +173 -80
  28. package/dist/codex-remote.js.map +1 -1
  29. package/dist/config.d.ts +13 -10
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +105 -60
  32. package/dist/config.js.map +1 -1
  33. package/dist/device-auth.d.ts +75 -0
  34. package/dist/device-auth.d.ts.map +1 -0
  35. package/dist/device-auth.js +167 -0
  36. package/dist/device-auth.js.map +1 -0
  37. package/dist/inbox-monitor.d.ts.map +1 -1
  38. package/dist/inbox-monitor.js +15 -0
  39. package/dist/inbox-monitor.js.map +1 -1
  40. package/dist/index.js +5 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/setup.js +25 -7
  43. package/dist/setup.js.map +1 -1
  44. package/dist/subscription-retry.d.ts +40 -0
  45. package/dist/subscription-retry.d.ts.map +1 -0
  46. package/dist/subscription-retry.js +23 -0
  47. package/dist/subscription-retry.js.map +1 -0
  48. package/dist/templates.d.ts +1 -0
  49. package/dist/templates.d.ts.map +1 -1
  50. package/dist/templates.js +59 -11
  51. package/dist/templates.js.map +1 -1
  52. package/dist/token-crypto.d.ts +50 -0
  53. package/dist/token-crypto.d.ts.map +1 -0
  54. package/dist/token-crypto.js +91 -0
  55. package/dist/token-crypto.js.map +1 -0
  56. package/dist/token-store.d.ts +98 -0
  57. package/dist/token-store.d.ts.map +1 -0
  58. package/dist/token-store.js +136 -0
  59. package/dist/token-store.js.map +1 -0
  60. package/package.json +1 -9
package/dist/config.js CHANGED
@@ -1,79 +1,117 @@
1
1
  /**
2
- * Secure token storage using OS keychain.
2
+ * Secure token storage.
3
3
  *
4
- * Uses @napi-rs/keyring (NAPI-RS binding over the Rust `keyring-rs`
5
- * crate) for cross-platform credential storage:
6
- * - macOS: Keychain Services
7
- * - Windows: Credential Vault (Credential Manager)
8
- * - Linux: Secret Service (libsecret / gnome-keyring)
4
+ * The public API (storeIdToken / getIdToken / getRefreshToken / clearTokens /
5
+ * isAuthenticated) is unchanged; what changed in gh#557 is what sits beneath
6
+ * it. Three storage paths, in precedence order:
9
7
  *
10
- * Migrated from `keytar` in 0.7.2 (gh#22). Same encryption-at-rest
11
- * shape as keytar (delegates to the platform's native keychain
12
- * service); eliminates the maintenance-orphaned keytar + prebuild-
13
- * install dependency chain.
8
+ * 1. Caller-managed (read-only): if BORG_TOKEN / BORG_TOKEN_FILE supplies an
9
+ * id_token, it's served verbatim no keychain, no expiry check, no
10
+ * refresh_token. The caller owns the token's lifecycle (CI, containers,
11
+ * `borg --token-file`).
12
+ * 2. OS keychain (default): @napi-rs/keyring — real platform at-rest
13
+ * encryption (macOS Keychain / Windows Credential Vault / libsecret).
14
+ * 3. Encrypted file (fallback): ~/.borg/credentials, AES-256-GCM under a
15
+ * machine-derived key, 0600. Engages only when no keychain is available
16
+ * (headless Linux without Secret Service). Obfuscation-grade — see
17
+ * token-crypto.ts.
14
18
  *
15
- * `AsyncEntry` is used (over the sync `Entry` class) so the public
16
- * API surface every function below is `async` — stays identical
17
- * to the pre-migration shape. No call sites needed to change.
19
+ * The persistent backend (2 or 3) is selected once per process and memoized.
20
+ * BORG_TOKEN_STORE=keychain|file forces the choice and skips the probe.
18
21
  */
19
- import { AsyncEntry } from '@napi-rs/keyring';
20
- const SERVICE_NAME = 'borg-mcp';
22
+ import os from 'os';
23
+ import path from 'path';
24
+ import { promises as fsp } from 'fs';
25
+ import { isKeyringAvailable } from './auth-env.js';
26
+ import { deriveMachineKey } from './token-crypto.js';
27
+ import { makeKeychainBackend, makeEncryptedFileBackend, selectTokenBackend, readCallerManagedIdToken, } from './token-store.js';
21
28
  const ID_TOKEN_ACCOUNT = 'google-id-token';
22
29
  const REFRESH_TOKEN_ACCOUNT = 'google-refresh-token';
23
30
  const TOKEN_EXPIRY_ACCOUNT = 'token-expiry';
24
- /**
25
- * Build an AsyncEntry bound to the borg-mcp service + given account.
26
- * One per call site rather than module-level singletons so the
27
- * underlying Rust handle isn't held longer than necessary.
28
- */
29
- function entry(account) {
30
- return new AsyncEntry(SERVICE_NAME, account);
31
+ /** Where the encrypted-file fallback lives when no keychain is available. */
32
+ function credentialsPath() {
33
+ return path.join(os.homedir(), '.borg', 'credentials');
31
34
  }
32
- /**
33
- * `@napi-rs/keyring`'s `deletePassword` returns a `NoEntry` error
34
- * when the entry doesn't exist. `keytar.deletePassword` returned
35
- * `false` silently in the same case. We preserve the silent-on-
36
- * missing semantic so `clearTokens()` stays idempotent across
37
- * repeat calls.
38
- *
39
- * Other errors (platform unavailable, ambiguous credential, etc.)
40
- * propagate — fail-loud is correct for those classes.
41
- */
42
- async function deleteIfExists(account) {
43
- try {
44
- await entry(account).deletePassword();
45
- }
46
- catch (err) {
47
- const msg = String(err?.message ?? '');
48
- if (/no entry|not found/i.test(msg))
49
- return;
50
- throw err;
35
+ /** Production fs adapter for the encrypted-file backend. */
36
+ const nodeFs = {
37
+ readFile: (filePath) => fsp.readFile(filePath, 'utf8'),
38
+ writeFile: async (filePath, data, mode) => {
39
+ // `mode` on writeFile only applies when the file is CREATED; chmod after
40
+ // guarantees 0600 even when rewriting an existing credentials file.
41
+ await fsp.writeFile(filePath, data, { mode });
42
+ await fsp.chmod(filePath, mode);
43
+ },
44
+ mkdir: async (dir, mode) => {
45
+ await fsp.mkdir(dir, { recursive: true, mode });
46
+ },
47
+ };
48
+ /** Map the user-facing BORG_TOKEN_STORE value to a forced backend, if valid. */
49
+ function parseForcedStore(value) {
50
+ const v = value?.trim().toLowerCase();
51
+ if (v === 'keychain')
52
+ return 'keychain';
53
+ if (v === 'file' || v === 'encrypted-file')
54
+ return 'file';
55
+ return undefined;
56
+ }
57
+ // Memoized persistent-backend selection (one keychain probe per process).
58
+ let backendPromise = null;
59
+ function getBackend() {
60
+ if (!backendPromise) {
61
+ backendPromise = selectTokenBackend({
62
+ keyringAvailable: () => isKeyringAvailable(),
63
+ makeKeychain: () => makeKeychainBackend(),
64
+ makeFile: () => makeEncryptedFileBackend({
65
+ filePath: credentialsPath(),
66
+ key: deriveMachineKey({
67
+ hostname: os.hostname(),
68
+ username: os.userInfo().username,
69
+ platform: process.platform,
70
+ }),
71
+ fs: nodeFs,
72
+ }),
73
+ forced: parseForcedStore(process.env.BORG_TOKEN_STORE),
74
+ });
51
75
  }
76
+ return backendPromise;
77
+ }
78
+ /** Caller-managed id_token (BORG_TOKEN / BORG_TOKEN_FILE), or null. */
79
+ function callerManagedIdToken() {
80
+ return readCallerManagedIdToken({
81
+ env: process.env,
82
+ readFile: (filePath) => fsp.readFile(filePath, 'utf8'),
83
+ });
52
84
  }
53
85
  /**
54
- * Store Google OAuth ID token securely in the OS keychain.
86
+ * Store Google OAuth ID token securely in the selected backend.
55
87
  */
56
88
  export async function storeIdToken(idToken, expiresAt) {
57
- await entry(ID_TOKEN_ACCOUNT).setPassword(idToken);
58
- await entry(TOKEN_EXPIRY_ACCOUNT).setPassword(expiresAt.toString());
89
+ const backend = await getBackend();
90
+ await backend.set(ID_TOKEN_ACCOUNT, idToken);
91
+ await backend.set(TOKEN_EXPIRY_ACCOUNT, expiresAt.toString());
59
92
  }
60
93
  /**
61
- * Store Google OAuth refresh token securely in the OS keychain.
94
+ * Store Google OAuth refresh token securely in the selected backend.
62
95
  */
63
96
  export async function storeRefreshToken(refreshToken) {
64
- await entry(REFRESH_TOKEN_ACCOUNT).setPassword(refreshToken);
97
+ const backend = await getBackend();
98
+ await backend.set(REFRESH_TOKEN_ACCOUNT, refreshToken);
65
99
  }
66
100
  /**
67
- * Retrieve the Google OAuth ID token from the OS keychain.
68
- * Returns null if not stored or expired (5-minute buffer).
101
+ * Retrieve the Google OAuth ID token.
69
102
  *
70
- * `getPassword` on a missing entry returns null/undefined depending
71
- * on platform binding; normalize with `?? null` so the caller
72
- * contract (`string | null`) is platform-stable.
103
+ * A caller-managed token (BORG_TOKEN / BORG_TOKEN_FILE) takes precedence and
104
+ * is returned verbatim the caller owns its freshness, so the expiry buffer
105
+ * does not apply. Otherwise reads the persistent backend and returns null if
106
+ * not stored or within the 5-minute expiry buffer.
73
107
  */
74
108
  export async function getIdToken() {
75
- const token = (await entry(ID_TOKEN_ACCOUNT).getPassword()) ?? null;
76
- const expiryStr = (await entry(TOKEN_EXPIRY_ACCOUNT).getPassword()) ?? null;
109
+ const callerManaged = await callerManagedIdToken();
110
+ if (callerManaged)
111
+ return callerManaged;
112
+ const backend = await getBackend();
113
+ const token = await backend.get(ID_TOKEN_ACCOUNT);
114
+ const expiryStr = await backend.get(TOKEN_EXPIRY_ACCOUNT);
77
115
  if (!token || !expiryStr) {
78
116
  return null;
79
117
  }
@@ -86,19 +124,26 @@ export async function getIdToken() {
86
124
  return token;
87
125
  }
88
126
  /**
89
- * Retrieve the Google OAuth refresh token from the OS keychain.
127
+ * Retrieve the Google OAuth refresh token. There is no refresh_token in
128
+ * caller-managed mode (the externally-supplied id_token has no refresh
129
+ * counterpart), so this returns null whenever a caller-managed token is set.
90
130
  */
91
131
  export async function getRefreshToken() {
92
- return (await entry(REFRESH_TOKEN_ACCOUNT).getPassword()) ?? null;
132
+ if (await callerManagedIdToken())
133
+ return null;
134
+ const backend = await getBackend();
135
+ return backend.get(REFRESH_TOKEN_ACCOUNT);
93
136
  }
94
137
  /**
95
- * Clear all stored tokens from the OS keychain. Idempotent — calling
96
- * on an already-empty keychain is a no-op (NoEntry errors absorbed).
138
+ * Clear all stored tokens from the selected backend. Idempotent — clearing
139
+ * an already-empty store is a no-op. Does not touch caller-managed env vars
140
+ * (those are the caller's to manage).
97
141
  */
98
142
  export async function clearTokens() {
99
- await deleteIfExists(ID_TOKEN_ACCOUNT);
100
- await deleteIfExists(REFRESH_TOKEN_ACCOUNT);
101
- await deleteIfExists(TOKEN_EXPIRY_ACCOUNT);
143
+ const backend = await getBackend();
144
+ await backend.delete(ID_TOKEN_ACCOUNT);
145
+ await backend.delete(REFRESH_TOKEN_ACCOUNT);
146
+ await backend.delete(TOKEN_EXPIRY_ACCOUNT);
102
147
  }
103
148
  /**
104
149
  * Check if user has valid authentication.
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAG9C,MAAM,YAAY,GAAG,UAAU,CAAC;AAChC,MAAM,gBAAgB,GAAG,iBAAiB,CAAC;AAC3C,MAAM,qBAAqB,GAAG,sBAAsB,CAAC;AACrD,MAAM,oBAAoB,GAAG,cAAc,CAAC;AAE5C;;;;GAIG;AACH,SAAS,KAAK,CAAC,OAAe;IAC5B,OAAO,IAAI,UAAU,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;AAC/C,CAAC;AAED;;;;;;;;;GASG;AACH,KAAK,UAAU,cAAc,CAAC,OAAe;IAC3C,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,OAAO,CAAC,CAAC,cAAc,EAAE,CAAC;IACxC,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,EAAE,OAAO,IAAI,EAAE,CAAC,CAAC;QACvC,IAAI,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO;QAC5C,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,OAAe,EAAE,SAAiB;IACnE,MAAM,KAAK,CAAC,gBAAgB,CAAC,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IACnD,MAAM,KAAK,CAAC,oBAAoB,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;AACtE,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,YAAoB;IAC1D,MAAM,KAAK,CAAC,qBAAqB,CAAC,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,KAAK,GAAG,CAAC,MAAM,KAAK,CAAC,gBAAgB,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,IAAI,CAAC;IACpE,MAAM,SAAS,GAAG,CAAC,MAAM,KAAK,CAAC,oBAAoB,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,IAAI,CAAC;IAE5E,IAAI,CAAC,KAAK,IAAI,CAAC,SAAS,EAAE,CAAC;QACzB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,SAAS,GAAG,QAAQ,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;IAC1C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,oDAAoD;IACpD,IAAI,SAAS,GAAG,GAAG,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,EAAE,CAAC;QACpC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,OAAO,CAAC,MAAM,KAAK,CAAC,qBAAqB,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,IAAI,CAAC;AACpE,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,MAAM,cAAc,CAAC,gBAAgB,CAAC,CAAC;IACvC,MAAM,cAAc,CAAC,qBAAqB,CAAC,CAAC;IAC5C,MAAM,cAAc,CAAC,oBAAoB,CAAC,CAAC;AAC7C,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,MAAM,KAAK,GAAG,MAAM,UAAU,EAAE,CAAC;IACjC,OAAO,KAAK,KAAK,IAAI,CAAC;AACxB,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * gh#557 — Google OAuth 2.0 Device Authorization Grant (RFC 8628).
3
+ *
4
+ * The no-browser counterpart to the loopback flow in auth.ts. Instead of
5
+ * opening a browser and listening on localhost, the device flow:
6
+ * 1. asks Google for a device_code + a short human-typable user_code,
7
+ * 2. prints a verification URL + the user_code for the human to open on
8
+ * ANY device (their phone, a laptop with a browser), and
9
+ * 3. polls Google's token endpoint until the human authorizes (or the
10
+ * code expires / is denied).
11
+ *
12
+ * This module is decoupled from the live Google client: `fetch`, `sleep`,
13
+ * and `now` are injected, and the client_id / client_secret / endpoints
14
+ * come from the caller. The live device flow needs a Google OAuth client
15
+ * of type "TVs & Limited Input devices" (a separate GOOGLE_DEVICE_CLIENT_ID
16
+ * — Desktop/loopback clients reject /device/code with invalid_client); the
17
+ * wiring layer supplies those credentials. Everything here is unit-tested
18
+ * against a mocked Google.
19
+ */
20
+ /**
21
+ * Failure of the device-grant flow. `code` is Google's OAuth error code
22
+ * where one exists (`access_denied`, `expired_token`, `invalid_client`,
23
+ * `slow_down`, `authorization_pending`) or a synthetic code for
24
+ * transport/shape failures (`device_code_request_failed`,
25
+ * `device_token_request_failed`, `malformed_token_response`).
26
+ *
27
+ * Token material is never placed in the message — only Google's error
28
+ * code + description, mirroring RefreshTokenInvalidError's discipline.
29
+ */
30
+ export declare class DeviceAuthError extends Error {
31
+ readonly code: string;
32
+ constructor(code: string, message?: string);
33
+ }
34
+ export interface DeviceAuthConfig {
35
+ clientId: string;
36
+ /** Limited-Input clients are issued a secret; included in the token poll. */
37
+ clientSecret?: string;
38
+ scopes: string[];
39
+ /** Overridable for tests; defaults to Google's production endpoints. */
40
+ deviceCodeUrl?: string;
41
+ tokenUrl?: string;
42
+ }
43
+ export interface DeviceAuthDeps {
44
+ fetch: typeof fetch;
45
+ sleep: (ms: number) => Promise<void>;
46
+ /** Monotonic-ish clock for the local expiry deadline; defaults to Date.now. */
47
+ now?: () => number;
48
+ }
49
+ export interface DeviceCodeResponse {
50
+ device_code: string;
51
+ user_code: string;
52
+ /** Google returns `verification_url` (not the RFC's `verification_uri`). */
53
+ verification_url: string;
54
+ expires_in: number;
55
+ interval: number;
56
+ }
57
+ export interface DeviceTokenResult {
58
+ id_token: string;
59
+ refresh_token?: string;
60
+ expires_in: number;
61
+ }
62
+ /**
63
+ * Step 1 — request a device_code + user_code from Google.
64
+ */
65
+ export declare function requestDeviceCode(config: DeviceAuthConfig, deps: DeviceAuthDeps): Promise<DeviceCodeResponse>;
66
+ /**
67
+ * Step 2 — poll Google's token endpoint until the user authorizes the
68
+ * device_code, honoring the RFC 8628 poll semantics.
69
+ *
70
+ * Sleeps `interval` BEFORE each poll (never hammers immediately). A local
71
+ * deadline derived from `expires_in` bounds the loop so a code the user
72
+ * abandons can't poll forever even if Google never returns expired_token.
73
+ */
74
+ export declare function pollForDeviceToken(deviceCode: DeviceCodeResponse, config: DeviceAuthConfig, deps: DeviceAuthDeps): Promise<DeviceTokenResult>;
75
+ //# sourceMappingURL=device-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-auth.d.ts","sourceRoot":"","sources":["../src/device-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAQH;;;;;;;;;GASG;AACH,qBAAa,eAAgB,SAAQ,KAAK;aACZ,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;CAI3D;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,kBAAkB,CAAC,CAiD7B;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,kBAAkB,EAC9B,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,iBAAiB,CAAC,CA4E5B"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * gh#557 — Google OAuth 2.0 Device Authorization Grant (RFC 8628).
3
+ *
4
+ * The no-browser counterpart to the loopback flow in auth.ts. Instead of
5
+ * opening a browser and listening on localhost, the device flow:
6
+ * 1. asks Google for a device_code + a short human-typable user_code,
7
+ * 2. prints a verification URL + the user_code for the human to open on
8
+ * ANY device (their phone, a laptop with a browser), and
9
+ * 3. polls Google's token endpoint until the human authorizes (or the
10
+ * code expires / is denied).
11
+ *
12
+ * This module is decoupled from the live Google client: `fetch`, `sleep`,
13
+ * and `now` are injected, and the client_id / client_secret / endpoints
14
+ * come from the caller. The live device flow needs a Google OAuth client
15
+ * of type "TVs & Limited Input devices" (a separate GOOGLE_DEVICE_CLIENT_ID
16
+ * — Desktop/loopback clients reject /device/code with invalid_client); the
17
+ * wiring layer supplies those credentials. Everything here is unit-tested
18
+ * against a mocked Google.
19
+ */
20
+ const GOOGLE_DEVICE_CODE_URL = 'https://oauth2.googleapis.com/device/code';
21
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
22
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
23
+ const DEFAULT_INTERVAL_SECONDS = 5;
24
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
25
+ /**
26
+ * Failure of the device-grant flow. `code` is Google's OAuth error code
27
+ * where one exists (`access_denied`, `expired_token`, `invalid_client`,
28
+ * `slow_down`, `authorization_pending`) or a synthetic code for
29
+ * transport/shape failures (`device_code_request_failed`,
30
+ * `device_token_request_failed`, `malformed_token_response`).
31
+ *
32
+ * Token material is never placed in the message — only Google's error
33
+ * code + description, mirroring RefreshTokenInvalidError's discipline.
34
+ */
35
+ export class DeviceAuthError extends Error {
36
+ code;
37
+ constructor(code, message) {
38
+ super(message ?? code);
39
+ this.code = code;
40
+ this.name = 'DeviceAuthError';
41
+ }
42
+ }
43
+ /**
44
+ * Step 1 — request a device_code + user_code from Google.
45
+ */
46
+ export async function requestDeviceCode(config, deps) {
47
+ const url = config.deviceCodeUrl ?? GOOGLE_DEVICE_CODE_URL;
48
+ let response;
49
+ try {
50
+ response = await deps.fetch(url, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53
+ body: new URLSearchParams({
54
+ client_id: config.clientId,
55
+ scope: config.scopes.join(' '),
56
+ }),
57
+ });
58
+ }
59
+ catch (err) {
60
+ throw new DeviceAuthError('device_code_request_failed', `Could not reach Google device endpoint: ${err?.message ?? 'unknown'}`);
61
+ }
62
+ if (!response.ok) {
63
+ const code = await readErrorCode(response);
64
+ throw new DeviceAuthError(code ?? 'device_code_request_failed', `Device-code request failed (HTTP ${response.status}${code ? `, ${code}` : ''})`);
65
+ }
66
+ let data;
67
+ try {
68
+ data = (await response.json());
69
+ }
70
+ catch {
71
+ throw new DeviceAuthError('malformed_token_response', 'Device-code response was not JSON');
72
+ }
73
+ if (!data.device_code || !data.user_code || !data.verification_url) {
74
+ throw new DeviceAuthError('malformed_token_response', 'Device-code response missing device_code/user_code/verification_url');
75
+ }
76
+ return {
77
+ device_code: data.device_code,
78
+ user_code: data.user_code,
79
+ verification_url: data.verification_url,
80
+ expires_in: typeof data.expires_in === 'number' ? data.expires_in : 1800,
81
+ interval: typeof data.interval === 'number' ? data.interval : DEFAULT_INTERVAL_SECONDS,
82
+ };
83
+ }
84
+ /**
85
+ * Step 2 — poll Google's token endpoint until the user authorizes the
86
+ * device_code, honoring the RFC 8628 poll semantics.
87
+ *
88
+ * Sleeps `interval` BEFORE each poll (never hammers immediately). A local
89
+ * deadline derived from `expires_in` bounds the loop so a code the user
90
+ * abandons can't poll forever even if Google never returns expired_token.
91
+ */
92
+ export async function pollForDeviceToken(deviceCode, config, deps) {
93
+ const tokenUrl = config.tokenUrl ?? GOOGLE_TOKEN_URL;
94
+ const now = deps.now ?? Date.now;
95
+ const deadline = now() + deviceCode.expires_in * 1000;
96
+ let intervalSeconds = deviceCode.interval > 0 ? deviceCode.interval : DEFAULT_INTERVAL_SECONDS;
97
+ // eslint-disable-next-line no-constant-condition
98
+ while (true) {
99
+ if (now() >= deadline) {
100
+ throw new DeviceAuthError('expired_token', 'Device code expired before the authorization was completed');
101
+ }
102
+ await deps.sleep(intervalSeconds * 1000);
103
+ let response;
104
+ try {
105
+ response = await deps.fetch(tokenUrl, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: new URLSearchParams({
109
+ client_id: config.clientId,
110
+ ...(config.clientSecret ? { client_secret: config.clientSecret } : {}),
111
+ device_code: deviceCode.device_code,
112
+ grant_type: DEVICE_GRANT_TYPE,
113
+ }),
114
+ });
115
+ }
116
+ catch (err) {
117
+ throw new DeviceAuthError('device_token_request_failed', `Could not reach Google token endpoint: ${err?.message ?? 'unknown'}`);
118
+ }
119
+ if (response.ok) {
120
+ let data;
121
+ try {
122
+ data = (await response.json());
123
+ }
124
+ catch {
125
+ throw new DeviceAuthError('malformed_token_response', 'Token response was not JSON');
126
+ }
127
+ if (!data.id_token || typeof data.expires_in !== 'number') {
128
+ throw new DeviceAuthError('malformed_token_response', 'Token response missing id_token or expires_in');
129
+ }
130
+ return {
131
+ id_token: data.id_token,
132
+ refresh_token: data.refresh_token,
133
+ expires_in: data.expires_in,
134
+ };
135
+ }
136
+ const code = await readErrorCode(response);
137
+ switch (code) {
138
+ case 'authorization_pending':
139
+ // The user hasn't finished yet — keep polling at the same interval.
140
+ continue;
141
+ case 'slow_down':
142
+ // Google asks us to back off; RFC 8628 §3.5 → bump the interval.
143
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
144
+ continue;
145
+ case 'access_denied':
146
+ throw new DeviceAuthError('access_denied', 'Authorization was denied by the user');
147
+ case 'expired_token':
148
+ throw new DeviceAuthError('expired_token', 'Device code expired before authorization');
149
+ default:
150
+ throw new DeviceAuthError(code ?? 'device_token_request_failed', `Device token poll failed (HTTP ${response.status}${code ? `, ${code}` : ''})`);
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Extract Google's OAuth `error` code from a non-2xx response body without
156
+ * throwing. Returns null when the body isn't JSON or has no error field.
157
+ */
158
+ async function readErrorCode(response) {
159
+ try {
160
+ const body = (await response.json());
161
+ return typeof body?.error === 'string' ? body.error : null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ //# sourceMappingURL=device-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-auth.js","sourceRoot":"","sources":["../src/device-auth.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"inbox-monitor.d.ts","sourceRoot":"","sources":["../src/inbox-monitor.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAUH;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMhE;AA8CD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAM/E"}
1
+ {"version":3,"file":"inbox-monitor.d.ts","sourceRoot":"","sources":["../src/inbox-monitor.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAUH;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMhE;AA6DD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAM/E"}
@@ -71,6 +71,7 @@ function main() {
71
71
  process.exit(1);
72
72
  }
73
73
  const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
74
+ let shuttingDown = false;
74
75
  rl.on('line', (line) => {
75
76
  const pretty = formatEventLine(line);
76
77
  if (pretty !== null) {
@@ -82,10 +83,24 @@ function main() {
82
83
  process.exit(1);
83
84
  });
84
85
  tail.on('exit', (code, signal) => {
86
+ if (shuttingDown)
87
+ process.exit(0);
85
88
  if (signal)
86
89
  process.exit(0);
87
90
  process.exit(code ?? 0);
88
91
  });
92
+ const shutdown = (signal) => {
93
+ if (shuttingDown)
94
+ return;
95
+ shuttingDown = true;
96
+ rl.close();
97
+ if (!tail.killed && !tail.kill(signal)) {
98
+ process.exit(0);
99
+ }
100
+ setTimeout(() => process.exit(0), 1000).unref();
101
+ };
102
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
103
+ process.once('SIGINT', () => shutdown('SIGINT'));
89
104
  }
90
105
  /**
91
106
  * Is this module being invoked as the bin entry point?
@@ -1 +1 @@
1
- {"version":3,"file":"inbox-monitor.js","sourceRoot":"","sources":["../src/inbox-monitor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"inbox-monitor.js","sourceRoot":"","sources":["../src/inbox-monitor.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -374,7 +374,7 @@ async function main() {
374
374
  cube_directive: { type: 'string', description: 'New cube directive markdown (optional).' },
375
375
  message_taxonomy: {
376
376
  type: 'array',
377
- description: 'New message-class taxonomy (optional). REPLACES the whole taxonomy; the worker re-validates the full array (non-overlapping prefixes, unique class names, directed classes need default_to). Pass [] to clear. To change ONE class without resending the whole array, use borg:patch-taxonomy-class instead. In default_to, pass @human-seat to route to drones in the cube human-seat role(s); literal role names/slugs/labels still work.',
377
+ description: 'New message-class taxonomy (optional). REPLACES the whole taxonomy; the worker re-validates the full array (non-overlapping prefixes, unique class names, directed classes need default_to). Pass [] to clear. To change ONE class without resending the whole array, use borg:patch-taxonomy-class instead. In default_to, pass @human-seat to route to drones in the cube human-seat role(s); literal role names/slugs/labels still work. Optional lifecycle tags mark dispatch/completion classes for stuck-dispatch detection.',
378
378
  items: {
379
379
  type: 'object',
380
380
  properties: {
@@ -382,6 +382,7 @@ async function main() {
382
382
  prefixes: { type: 'array', items: { type: 'string' }, description: 'Message prefixes routed by this class.' },
383
383
  routing: { type: 'string', enum: ['broadcast', 'directed'], description: 'Routing mode.' },
384
384
  default_to: { type: 'array', items: { type: 'string' }, description: 'Default recipients (role name/slug/label, or @human-seat) for a directed class.' },
385
+ lifecycle: { type: 'string', enum: ['dispatch', 'completion'], description: 'Optional lifecycle marker for stuck-dispatch detection.' },
385
386
  },
386
387
  },
387
388
  },
@@ -391,7 +392,7 @@ async function main() {
391
392
  },
392
393
  {
393
394
  name: 'borg:patch-taxonomy-class',
394
- description: "Surgically patch ONE message-class within a cube's message_taxonomy, leaving other classes unchanged. Use this instead of borg:update-cube when adding/changing a single class so you don't resend (and risk clobbering) the whole taxonomy. action=add appends a new class; action=replace overwrites the class with the same name (case-insensitive); action=remove drops a class. The whole resulting taxonomy is re-validated (non-overlapping prefixes, unique class names, directed classes need default_to) — a single-class patch that breaks a cross-class rule against an untouched class is rejected. In default_to, pass @human-seat to route to drones in the cube human-seat role(s); literal role names/slugs/labels still work.",
395
+ description: "Surgically patch ONE message-class within a cube's message_taxonomy, leaving other classes unchanged. Use this instead of borg:update-cube when adding/changing a single class so you don't resend (and risk clobbering) the whole taxonomy. action=add appends a new class; action=replace overwrites the class with the same name (case-insensitive); action=remove drops a class. The whole resulting taxonomy is re-validated (non-overlapping prefixes, unique class names, directed classes need default_to) — a single-class patch that breaks a cross-class rule against an untouched class is rejected. In default_to, pass @human-seat to route to drones in the cube human-seat role(s); literal role names/slugs/labels still work. Optional lifecycle tags mark dispatch/completion classes for stuck-dispatch detection.",
395
396
  inputSchema: {
396
397
  type: 'object',
397
398
  properties: {
@@ -399,12 +400,13 @@ async function main() {
399
400
  action: { type: 'string', enum: ['add', 'replace', 'remove'], description: 'add / replace / remove a single class.' },
400
401
  class_def: {
401
402
  type: 'object',
402
- description: 'The class definition (for add/replace). Shape: { class, prefixes?, routing: "broadcast"|"directed", default_to? }.',
403
+ description: 'The class definition (for add/replace). Shape: { class, prefixes?, routing: "broadcast"|"directed", default_to?, lifecycle? }.',
403
404
  properties: {
404
405
  class: { type: 'string', description: 'Unique class name.' },
405
406
  prefixes: { type: 'array', items: { type: 'string' }, description: 'Message prefixes routed by this class.' },
406
407
  routing: { type: 'string', enum: ['broadcast', 'directed'], description: 'Routing mode.' },
407
408
  default_to: { type: 'array', items: { type: 'string' }, description: 'Default recipients (required for directed classes): role name/slug/label, or @human-seat.' },
409
+ lifecycle: { type: 'string', enum: ['dispatch', 'completion'], description: 'Optional lifecycle marker for stuck-dispatch detection.' },
408
410
  },
409
411
  required: ['class', 'routing'],
410
412
  },