@rockhopper-co/mcp-server 0.6.0 → 0.8.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/CHANGELOG.md +156 -1
- package/README.md +57 -10
- package/dist/api-client.d.ts +67 -5
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +143 -19
- package/dist/api-client.js.map +1 -1
- package/dist/auth/device-grant-client.d.ts +80 -0
- package/dist/auth/device-grant-client.d.ts.map +1 -0
- package/dist/auth/device-grant-client.js +140 -0
- package/dist/auth/device-grant-client.js.map +1 -0
- package/dist/auth/resolve-auth.d.ts +52 -0
- package/dist/auth/resolve-auth.d.ts.map +1 -0
- package/dist/auth/resolve-auth.js +100 -0
- package/dist/auth/resolve-auth.js.map +1 -0
- package/dist/auth/token-store.d.ts +51 -0
- package/dist/auth/token-store.d.ts.map +1 -0
- package/dist/auth/token-store.js +98 -0
- package/dist/auth/token-store.js.map +1 -0
- package/dist/cli.js +68 -10
- package/dist/cli.js.map +1 -1
- package/dist/correlation.d.ts +9 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +26 -0
- package/dist/correlation.js.map +1 -0
- package/dist/logger.d.ts +42 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +159 -0
- package/dist/logger.js.map +1 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +20 -9
- package/dist/prompts/index.js.map +1 -1
- package/dist/resources/changes.d.ts.map +1 -1
- package/dist/resources/changes.js +8 -2
- package/dist/resources/changes.js.map +1 -1
- package/dist/resources/orchestration-guide.md +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +38 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +113 -20
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/write-files.js +4 -4
- package/dist/tools/write-files.js.map +1 -1
- package/dist/tools/write-reviews.d.ts.map +1 -1
- package/dist/tools/write-reviews.js +3 -1
- package/dist/tools/write-reviews.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/zod-schemas.d.ts +63 -0
- package/dist/zod-schemas.d.ts.map +1 -0
- package/dist/zod-schemas.js +62 -0
- package/dist/zod-schemas.js.map +1 -0
- package/package.json +13 -6
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — RFC 8628 device-grant client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the backend's `POST /auth/device/{code,token}` endpoints
|
|
5
|
+
* (introduced in ENG-1384 PR 1, `Rockhopper-Co/backend#473`). Used by
|
|
6
|
+
* the mcp-server CLI when no PAT env var is set and no OAuth bundle
|
|
7
|
+
* is stored in the OS keychain.
|
|
8
|
+
*
|
|
9
|
+
* Surfaces a single entrypoint, `runDeviceGrantFlow`, that:
|
|
10
|
+
*
|
|
11
|
+
* 1. Calls `/auth/device/code` to get a (deviceCode, userCode) pair.
|
|
12
|
+
* 2. Emits the user-facing `userCode` + verification URI to stderr
|
|
13
|
+
* (LLM clients pick this up via stdout's stderr passthrough).
|
|
14
|
+
* 3. Polls `/auth/device/token` at the server-specified interval,
|
|
15
|
+
* respecting RFC 8628 § 3.5 `slow_down` (bumps interval +5s) and
|
|
16
|
+
* `authorization_pending` (continues polling).
|
|
17
|
+
* 4. Resolves with the access-token bundle on success, rejects on
|
|
18
|
+
* `access_denied` / `expired_token` / network failure.
|
|
19
|
+
*
|
|
20
|
+
* No external HTTP dependency — uses globalThis.fetch (Node 18+).
|
|
21
|
+
* `fetchImpl` + `sleep` + `onUserCode` are injectable for tests.
|
|
22
|
+
*/
|
|
23
|
+
export interface DeviceCodeResponse {
|
|
24
|
+
deviceCode: string;
|
|
25
|
+
userCode: string;
|
|
26
|
+
verificationUri: string;
|
|
27
|
+
verificationUriComplete: string;
|
|
28
|
+
expiresIn: number;
|
|
29
|
+
interval: number;
|
|
30
|
+
}
|
|
31
|
+
export interface DeviceTokenSuccess {
|
|
32
|
+
accessToken: string;
|
|
33
|
+
tokenType: 'Bearer';
|
|
34
|
+
expiresIn: number;
|
|
35
|
+
refreshToken?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface DeviceGrantFlowOptions {
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
/** Override for tests. Defaults to globalThis.fetch. */
|
|
41
|
+
fetchImpl?: typeof fetch;
|
|
42
|
+
/** Override for tests. Defaults to setTimeout-based sleep. */
|
|
43
|
+
sleep?: (ms: number) => Promise<void>;
|
|
44
|
+
/** Called once with the user code + verification URI. Defaults to stderr. */
|
|
45
|
+
onUserCode?: (info: {
|
|
46
|
+
userCode: string;
|
|
47
|
+
verificationUri: string;
|
|
48
|
+
verificationUriComplete: string;
|
|
49
|
+
}) => void;
|
|
50
|
+
}
|
|
51
|
+
export declare class DeviceGrantError extends Error {
|
|
52
|
+
readonly code: 'access_denied' | 'expired_token' | 'network_error' | 'unknown';
|
|
53
|
+
constructor(code: 'access_denied' | 'expired_token' | 'network_error' | 'unknown', message: string);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Issue a fresh device code + user code pair.
|
|
57
|
+
*/
|
|
58
|
+
export declare function requestDeviceCode(opts: Pick<DeviceGrantFlowOptions, 'baseUrl' | 'clientId' | 'fetchImpl'>): Promise<DeviceCodeResponse>;
|
|
59
|
+
/**
|
|
60
|
+
* Internal: one polling round-trip. Returns the token bundle on
|
|
61
|
+
* success, or a sentinel describing why the server isn't ready yet.
|
|
62
|
+
*/
|
|
63
|
+
export declare function pollOnce(opts: Pick<DeviceGrantFlowOptions, 'baseUrl' | 'clientId' | 'fetchImpl'>, deviceCode: string): Promise<{
|
|
64
|
+
kind: 'success';
|
|
65
|
+
bundle: DeviceTokenSuccess;
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'pending';
|
|
68
|
+
} | {
|
|
69
|
+
kind: 'slow_down';
|
|
70
|
+
}>;
|
|
71
|
+
/**
|
|
72
|
+
* Full flow — request code, print to stderr, poll until success or
|
|
73
|
+
* fatal error. Returns the access-token bundle on success.
|
|
74
|
+
*
|
|
75
|
+
* `interval` (seconds, server-supplied) becomes the poll cadence.
|
|
76
|
+
* `slow_down` responses bump the interval by RFC's recommended +5s.
|
|
77
|
+
* Total wall time is bounded by the server-supplied `expiresIn`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function runDeviceGrantFlow(opts: DeviceGrantFlowOptions): Promise<DeviceTokenSuccess>;
|
|
80
|
+
//# sourceMappingURL=device-grant-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-grant-client.d.ts","sourceRoot":"","sources":["../../src/auth/device-grant-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,EAAE,MAAM,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,QAAQ,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,eAAe,EAAE,MAAM,CAAC;QACxB,uBAAuB,EAAE,MAAM,CAAC;KACjC,KAAK,IAAI,CAAC;CACZ;AAED,qBAAa,gBAAiB,SAAQ,KAAK;aAEvB,IAAI,EAChB,eAAe,GACf,eAAe,GACf,eAAe,GACf,SAAS;gBAJG,IAAI,EAChB,eAAe,GACf,eAAe,GACf,eAAe,GACf,SAAS,EACb,OAAO,EAAE,MAAM;CAKlB;AAkBD;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,IAAI,CAAC,sBAAsB,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC,GACvE,OAAO,CAAC,kBAAkB,CAAC,CAuB7B;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,IAAI,CAAC,sBAAsB,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC,EACxE,UAAU,EAAE,MAAM,GACjB,OAAO,CACN;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,kBAAkB,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CACxB,CAwDA;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAgC7B"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — RFC 8628 device-grant client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the backend's `POST /auth/device/{code,token}` endpoints
|
|
5
|
+
* (introduced in ENG-1384 PR 1, `Rockhopper-Co/backend#473`). Used by
|
|
6
|
+
* the mcp-server CLI when no PAT env var is set and no OAuth bundle
|
|
7
|
+
* is stored in the OS keychain.
|
|
8
|
+
*
|
|
9
|
+
* Surfaces a single entrypoint, `runDeviceGrantFlow`, that:
|
|
10
|
+
*
|
|
11
|
+
* 1. Calls `/auth/device/code` to get a (deviceCode, userCode) pair.
|
|
12
|
+
* 2. Emits the user-facing `userCode` + verification URI to stderr
|
|
13
|
+
* (LLM clients pick this up via stdout's stderr passthrough).
|
|
14
|
+
* 3. Polls `/auth/device/token` at the server-specified interval,
|
|
15
|
+
* respecting RFC 8628 § 3.5 `slow_down` (bumps interval +5s) and
|
|
16
|
+
* `authorization_pending` (continues polling).
|
|
17
|
+
* 4. Resolves with the access-token bundle on success, rejects on
|
|
18
|
+
* `access_denied` / `expired_token` / network failure.
|
|
19
|
+
*
|
|
20
|
+
* No external HTTP dependency — uses globalThis.fetch (Node 18+).
|
|
21
|
+
* `fetchImpl` + `sleep` + `onUserCode` are injectable for tests.
|
|
22
|
+
*/
|
|
23
|
+
export class DeviceGrantError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = 'DeviceGrantError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/* v8 ignore next 2 -- trivial setTimeout wrapper; tests inject a sleep stub via `opts.sleep` */
|
|
32
|
+
const DEFAULT_SLEEP = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
const DEFAULT_ON_USER_CODE = (info) => {
|
|
34
|
+
process.stderr.write('\nRockhopper — sign in to authorize this MCP client.\n' +
|
|
35
|
+
`Open: ${info.verificationUriComplete}\n` +
|
|
36
|
+
`(or visit ${info.verificationUri} and enter code: ${info.userCode})\n\n`);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Issue a fresh device code + user code pair.
|
|
40
|
+
*/
|
|
41
|
+
export async function requestDeviceCode(opts) {
|
|
42
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetchImpl(`${opts.baseUrl}/auth/device/code`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ clientId: opts.clientId }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
throw new DeviceGrantError('network_error', `Failed to reach ${opts.baseUrl}/auth/device/code: ${e instanceof Error ? e.message : String(e)}`);
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new DeviceGrantError('unknown', `Device-code request failed with HTTP ${res.status}`);
|
|
56
|
+
}
|
|
57
|
+
return (await res.json());
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Internal: one polling round-trip. Returns the token bundle on
|
|
61
|
+
* success, or a sentinel describing why the server isn't ready yet.
|
|
62
|
+
*/
|
|
63
|
+
export async function pollOnce(opts, deviceCode) {
|
|
64
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await fetchImpl(`${opts.baseUrl}/auth/device/token`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ deviceCode, clientId: opts.clientId }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
throw new DeviceGrantError('network_error', `Failed to reach ${opts.baseUrl}/auth/device/token: ${e instanceof Error ? e.message : String(e)}`);
|
|
75
|
+
}
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
return {
|
|
78
|
+
kind: 'success',
|
|
79
|
+
bundle: (await res.json()),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// RFC 8628 error mapping. Body shape is `{ error, error_description }`
|
|
83
|
+
// per the backend (Rockhopper's exception filter unwraps it — see
|
|
84
|
+
// memory `project_backend_badrequest_unwraps_payload`).
|
|
85
|
+
let body = {};
|
|
86
|
+
try {
|
|
87
|
+
// TS 6.0 types `Response.json()` as `Promise<unknown>`; the body shape is
|
|
88
|
+
// validated structurally by the `switch (body.error)` below.
|
|
89
|
+
body = (await res.json());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Non-JSON error body — fall through to 'unknown'.
|
|
93
|
+
}
|
|
94
|
+
switch (body.error) {
|
|
95
|
+
case 'authorization_pending':
|
|
96
|
+
return { kind: 'pending' };
|
|
97
|
+
case 'slow_down':
|
|
98
|
+
return { kind: 'slow_down' };
|
|
99
|
+
case 'access_denied':
|
|
100
|
+
throw new DeviceGrantError('access_denied', body.error_description ?? 'Device code denied.');
|
|
101
|
+
case 'expired_token':
|
|
102
|
+
throw new DeviceGrantError('expired_token', body.error_description ?? 'Device code expired before approval.');
|
|
103
|
+
default:
|
|
104
|
+
throw new DeviceGrantError('unknown', `Unexpected device-grant error (HTTP ${res.status}): ${body.error ?? 'no error code'}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Full flow — request code, print to stderr, poll until success or
|
|
109
|
+
* fatal error. Returns the access-token bundle on success.
|
|
110
|
+
*
|
|
111
|
+
* `interval` (seconds, server-supplied) becomes the poll cadence.
|
|
112
|
+
* `slow_down` responses bump the interval by RFC's recommended +5s.
|
|
113
|
+
* Total wall time is bounded by the server-supplied `expiresIn`.
|
|
114
|
+
*/
|
|
115
|
+
export async function runDeviceGrantFlow(opts) {
|
|
116
|
+
const sleep = opts.sleep ?? DEFAULT_SLEEP;
|
|
117
|
+
const onUserCode = opts.onUserCode ?? DEFAULT_ON_USER_CODE;
|
|
118
|
+
const code = await requestDeviceCode(opts);
|
|
119
|
+
onUserCode({
|
|
120
|
+
userCode: code.userCode,
|
|
121
|
+
verificationUri: code.verificationUri,
|
|
122
|
+
verificationUriComplete: code.verificationUriComplete,
|
|
123
|
+
});
|
|
124
|
+
let intervalMs = code.interval * 1000;
|
|
125
|
+
const deadline = Date.now() + code.expiresIn * 1000;
|
|
126
|
+
while (Date.now() < deadline) {
|
|
127
|
+
await sleep(intervalMs);
|
|
128
|
+
const result = await pollOnce(opts, code.deviceCode);
|
|
129
|
+
if (result.kind === 'success') {
|
|
130
|
+
return result.bundle;
|
|
131
|
+
}
|
|
132
|
+
if (result.kind === 'slow_down') {
|
|
133
|
+
// RFC 8628 § 3.5 — increase interval by 5s and retry.
|
|
134
|
+
intervalMs += 5_000;
|
|
135
|
+
}
|
|
136
|
+
// 'pending' just continues at the current interval.
|
|
137
|
+
}
|
|
138
|
+
throw new DeviceGrantError('expired_token', `Device code expired (waited ${code.expiresIn}s without approval).`);
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=device-grant-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-grant-client.js","sourceRoot":"","sources":["../../src/auth/device-grant-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — auth resolution for the mcp-server CLI.
|
|
3
|
+
*
|
|
4
|
+
* Decides where the bearer token comes from on each launch, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. `ROCKHOPPER_TOKEN` env var — Personal Access Token. Headless /
|
|
7
|
+
* CI / scripted scenarios. Same path as pre-OAuth releases.
|
|
8
|
+
* 2. Stored OAuth bundle in the OS keychain (token-store). The user
|
|
9
|
+
* previously completed the device-grant flow; if the access
|
|
10
|
+
* token is still valid, use it.
|
|
11
|
+
* 3. Device-grant flow — issues a fresh code, prints the user code
|
|
12
|
+
* + verification URI to stderr, polls for approval, persists the
|
|
13
|
+
* resulting bundle to the keychain for next time.
|
|
14
|
+
*
|
|
15
|
+
* Returns `{ accessToken, source }` where `source` is one of
|
|
16
|
+
* `'pat' | 'stored-oauth' | 'device-grant'` for logging / debugging.
|
|
17
|
+
*
|
|
18
|
+
* Heavy dependencies (token store, device-grant flow, env reads) are
|
|
19
|
+
* injectable for tests.
|
|
20
|
+
*/
|
|
21
|
+
import { runDeviceGrantFlow } from './device-grant-client.js';
|
|
22
|
+
import { isExpired as defaultIsExpired, type OAuthTokenBundle } from './token-store.js';
|
|
23
|
+
export type AuthSource = 'pat' | 'stored-oauth' | 'device-grant';
|
|
24
|
+
export interface ResolvedAuth {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
source: AuthSource;
|
|
27
|
+
}
|
|
28
|
+
export interface ResolveAuthOptions {
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
/** Defaults to `'mcp-stdio'`. */
|
|
31
|
+
clientId?: string;
|
|
32
|
+
/** Typically `process.env.ROCKHOPPER_TOKEN`. */
|
|
33
|
+
patFromEnv?: string;
|
|
34
|
+
/** Override for tests. */
|
|
35
|
+
tokenStoreGet?: () => Promise<OAuthTokenBundle | null>;
|
|
36
|
+
/** Override for tests. */
|
|
37
|
+
tokenStoreSet?: (bundle: OAuthTokenBundle) => Promise<void>;
|
|
38
|
+
/** Override for tests. */
|
|
39
|
+
tokenStoreClear?: () => Promise<void>;
|
|
40
|
+
/** Override for tests. */
|
|
41
|
+
isExpiredFn?: typeof defaultIsExpired;
|
|
42
|
+
/** Override for tests. Defaults to `runDeviceGrantFlow`. */
|
|
43
|
+
deviceGrantFlow?: typeof runDeviceGrantFlow;
|
|
44
|
+
/** Optional stderr logger; defaults to `process.stderr.write` line-suffixed. */
|
|
45
|
+
log?: (msg: string) => void;
|
|
46
|
+
}
|
|
47
|
+
export declare class AuthResolutionError extends Error {
|
|
48
|
+
readonly code: 'pat_malformed' | 'device_grant_failed' | 'token_store_failure';
|
|
49
|
+
constructor(code: 'pat_malformed' | 'device_grant_failed' | 'token_store_failure', message: string);
|
|
50
|
+
}
|
|
51
|
+
export declare function resolveAuth(opts: ResolveAuthOptions): Promise<ResolvedAuth>;
|
|
52
|
+
//# sourceMappingURL=resolve-auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-auth.d.ts","sourceRoot":"","sources":["../../src/auth/resolve-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAEL,kBAAkB,EAEnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAGL,SAAS,IAAI,gBAAgB,EAE7B,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAC;AAI1B,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACvD,0BAA0B;IAC1B,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,0BAA0B;IAC1B,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,0BAA0B;IAC1B,WAAW,CAAC,EAAE,OAAO,gBAAgB,CAAC;IACtC,4DAA4D;IAC5D,eAAe,CAAC,EAAE,OAAO,kBAAkB,CAAC;IAC5C,gFAAgF;IAChF,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,IAAI,EAChB,eAAe,GACf,qBAAqB,GACrB,qBAAqB;gBAHT,IAAI,EAChB,eAAe,GACf,qBAAqB,GACrB,qBAAqB,EACzB,OAAO,EAAE,MAAM;CAKlB;AAKD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAgFvB"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — auth resolution for the mcp-server CLI.
|
|
3
|
+
*
|
|
4
|
+
* Decides where the bearer token comes from on each launch, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. `ROCKHOPPER_TOKEN` env var — Personal Access Token. Headless /
|
|
7
|
+
* CI / scripted scenarios. Same path as pre-OAuth releases.
|
|
8
|
+
* 2. Stored OAuth bundle in the OS keychain (token-store). The user
|
|
9
|
+
* previously completed the device-grant flow; if the access
|
|
10
|
+
* token is still valid, use it.
|
|
11
|
+
* 3. Device-grant flow — issues a fresh code, prints the user code
|
|
12
|
+
* + verification URI to stderr, polls for approval, persists the
|
|
13
|
+
* resulting bundle to the keychain for next time.
|
|
14
|
+
*
|
|
15
|
+
* Returns `{ accessToken, source }` where `source` is one of
|
|
16
|
+
* `'pat' | 'stored-oauth' | 'device-grant'` for logging / debugging.
|
|
17
|
+
*
|
|
18
|
+
* Heavy dependencies (token store, device-grant flow, env reads) are
|
|
19
|
+
* injectable for tests.
|
|
20
|
+
*/
|
|
21
|
+
import { DeviceGrantError, runDeviceGrantFlow, } from './device-grant-client.js';
|
|
22
|
+
import { clearTokens as defaultClearTokens, getTokens as defaultGetTokens, isExpired as defaultIsExpired, setTokens as defaultSetTokens, } from './token-store.js';
|
|
23
|
+
const PAT_PREFIX = 'rh_pat_';
|
|
24
|
+
export class AuthResolutionError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = 'AuthResolutionError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/* v8 ignore next -- trivial stderr wrapper; tests inject a log stub via `opts.log` */
|
|
33
|
+
const DEFAULT_LOG = (msg) => process.stderr.write(`${msg}\n`);
|
|
34
|
+
export async function resolveAuth(opts) {
|
|
35
|
+
const clientId = opts.clientId ?? 'mcp-stdio';
|
|
36
|
+
const log = opts.log ?? DEFAULT_LOG;
|
|
37
|
+
const tokenStoreGet = opts.tokenStoreGet ?? defaultGetTokens;
|
|
38
|
+
const tokenStoreSet = opts.tokenStoreSet ?? defaultSetTokens;
|
|
39
|
+
const tokenStoreClear = opts.tokenStoreClear ?? defaultClearTokens;
|
|
40
|
+
const isExpiredFn = opts.isExpiredFn ?? defaultIsExpired;
|
|
41
|
+
const deviceGrantFlow = opts.deviceGrantFlow ?? runDeviceGrantFlow;
|
|
42
|
+
// 1. PAT env var — takes precedence; matches pre-OAuth behavior.
|
|
43
|
+
if (opts.patFromEnv) {
|
|
44
|
+
if (!opts.patFromEnv.startsWith(PAT_PREFIX)) {
|
|
45
|
+
throw new AuthResolutionError('pat_malformed', `ROCKHOPPER_TOKEN does not look like a valid Personal Access Token. Tokens start with "${PAT_PREFIX}".`);
|
|
46
|
+
}
|
|
47
|
+
return { accessToken: opts.patFromEnv, source: 'pat' };
|
|
48
|
+
}
|
|
49
|
+
// 2. Stored OAuth bundle.
|
|
50
|
+
let stored = null;
|
|
51
|
+
try {
|
|
52
|
+
stored = await tokenStoreGet();
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
// Keychain backend missing or locked. Don't abort yet — fall through
|
|
56
|
+
// to device-grant. If THAT fails too, surface a combined error.
|
|
57
|
+
log(`Could not read OS keychain (${e instanceof Error ? e.message : String(e)}). Falling back to device-grant flow.`);
|
|
58
|
+
}
|
|
59
|
+
if (stored && !isExpiredFn(stored)) {
|
|
60
|
+
return { accessToken: stored.accessToken, source: 'stored-oauth' };
|
|
61
|
+
}
|
|
62
|
+
if (stored) {
|
|
63
|
+
// Expired bundle — clear it and proceed.
|
|
64
|
+
try {
|
|
65
|
+
await tokenStoreClear();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Non-fatal — overwrite on the upcoming setTokens.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 3. Device-grant flow.
|
|
72
|
+
let bundle;
|
|
73
|
+
try {
|
|
74
|
+
bundle = await deviceGrantFlow({
|
|
75
|
+
baseUrl: opts.baseUrl,
|
|
76
|
+
clientId,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (e instanceof DeviceGrantError) {
|
|
81
|
+
throw new AuthResolutionError('device_grant_failed', `Device-grant flow failed (${e.code}): ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
throw new AuthResolutionError('device_grant_failed', `Device-grant flow failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
84
|
+
}
|
|
85
|
+
// Persist for next launch. Storage failure is non-fatal — the
|
|
86
|
+
// current token still works, the user just re-runs the flow next
|
|
87
|
+
// time.
|
|
88
|
+
try {
|
|
89
|
+
await tokenStoreSet({
|
|
90
|
+
accessToken: bundle.accessToken,
|
|
91
|
+
refreshToken: bundle.refreshToken,
|
|
92
|
+
expiresAt: Date.now() + bundle.expiresIn * 1000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
log(`Warning: could not persist OAuth token to OS keychain (${e instanceof Error ? e.message : String(e)}). You may need to re-authenticate on the next launch.`);
|
|
97
|
+
}
|
|
98
|
+
return { accessToken: bundle.accessToken, source: 'device-grant' };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=resolve-auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-auth.js","sourceRoot":"","sources":["../../src/auth/resolve-auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — RFC 8628 device-grant token storage.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `keytar` to persist OAuth tokens in the OS-native keychain
|
|
5
|
+
* (Keychain on macOS, Credential Manager on Windows, libsecret on
|
|
6
|
+
* Linux). The mcp-server is spawned by AI clients (Cursor, Claude
|
|
7
|
+
* Desktop) and survives across client launches, so tokens must live
|
|
8
|
+
* outside the process.
|
|
9
|
+
*
|
|
10
|
+
* One bundle per machine — there's no multi-user concept inside a
|
|
11
|
+
* single mcp-server install. If the user re-runs the device-grant
|
|
12
|
+
* flow, the prior bundle is overwritten.
|
|
13
|
+
*
|
|
14
|
+
* Linux without libsecret installed will throw on first keytar call
|
|
15
|
+
* (the native binding loads but the backend lookup fails). The CLI
|
|
16
|
+
* surfaces a clear remediation message ("apt-get install
|
|
17
|
+
* libsecret-tools" etc.) — we do NOT silently fall back to plaintext
|
|
18
|
+
* file storage. Encrypted file fallback may land in a follow-up.
|
|
19
|
+
*/
|
|
20
|
+
export interface OAuthTokenBundle {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
/** Optional — backend does not currently issue refresh tokens (ENG-1446). */
|
|
23
|
+
refreshToken?: string;
|
|
24
|
+
/** Epoch milliseconds. Null means "no expiry set" — treat as expired. */
|
|
25
|
+
expiresAt: number | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the persisted OAuth bundle. Returns null if no bundle is stored
|
|
29
|
+
* OR if the stored value is malformed (corrupted entry — treat as
|
|
30
|
+
* "no tokens" and let the CLI initiate a fresh device flow).
|
|
31
|
+
*/
|
|
32
|
+
export declare function getTokens(): Promise<OAuthTokenBundle | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Persist the OAuth bundle, overwriting any prior bundle.
|
|
35
|
+
*/
|
|
36
|
+
export declare function setTokens(bundle: OAuthTokenBundle): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Delete any persisted bundle. Safe to call when nothing is stored
|
|
39
|
+
* (keytar returns false; we ignore the return).
|
|
40
|
+
*/
|
|
41
|
+
export declare function clearTokens(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* `true` if `bundle.expiresAt` is in the past (or null). Pure helper —
|
|
44
|
+
* the device-grant client wires this into its refresh-on-expiry logic.
|
|
45
|
+
*
|
|
46
|
+
* A small safety margin (60s by default) treats tokens about to expire
|
|
47
|
+
* as already expired, so the client refreshes BEFORE the API rejects
|
|
48
|
+
* the next call.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isExpired(bundle: OAuthTokenBundle, marginMs?: number, now?: number): boolean;
|
|
51
|
+
//# sourceMappingURL=token-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAgDH,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAmBlE;AAED;;GAEG;AACH,wBAAsB,SAAS,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOvE;AAED;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAGjD;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,gBAAgB,EACxB,QAAQ,SAAS,EACjB,GAAG,SAAa,GACf,OAAO,CAGT"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-1444 / KI-081 — RFC 8628 device-grant token storage.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `keytar` to persist OAuth tokens in the OS-native keychain
|
|
5
|
+
* (Keychain on macOS, Credential Manager on Windows, libsecret on
|
|
6
|
+
* Linux). The mcp-server is spawned by AI clients (Cursor, Claude
|
|
7
|
+
* Desktop) and survives across client launches, so tokens must live
|
|
8
|
+
* outside the process.
|
|
9
|
+
*
|
|
10
|
+
* One bundle per machine — there's no multi-user concept inside a
|
|
11
|
+
* single mcp-server install. If the user re-runs the device-grant
|
|
12
|
+
* flow, the prior bundle is overwritten.
|
|
13
|
+
*
|
|
14
|
+
* Linux without libsecret installed will throw on first keytar call
|
|
15
|
+
* (the native binding loads but the backend lookup fails). The CLI
|
|
16
|
+
* surfaces a clear remediation message ("apt-get install
|
|
17
|
+
* libsecret-tools" etc.) — we do NOT silently fall back to plaintext
|
|
18
|
+
* file storage. Encrypted file fallback may land in a follow-up.
|
|
19
|
+
*/
|
|
20
|
+
let _keytar = null;
|
|
21
|
+
let _keytarErr = null;
|
|
22
|
+
async function loadKeytar() {
|
|
23
|
+
if (_keytar)
|
|
24
|
+
return _keytar;
|
|
25
|
+
/* v8 ignore next */
|
|
26
|
+
if (_keytarErr)
|
|
27
|
+
throw _keytarErr;
|
|
28
|
+
try {
|
|
29
|
+
_keytar = await import('keytar');
|
|
30
|
+
return _keytar;
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
/* v8 ignore next 9 -- runtime dlopen failure (Linux without libsecret); the catch can't be reached via vi.mock since the mock factory always resolves successfully. Behavior is covered by the CLI's end-to-end behavior on platforms where the import genuinely fails. */
|
|
34
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
35
|
+
_keytarErr = new Error(`OS keychain unavailable (${reason}). ` +
|
|
36
|
+
'On Linux, install libsecret (e.g. `apt-get install libsecret-1-dev` on Debian/Ubuntu, ' +
|
|
37
|
+
'`dnf install libsecret` on Fedora). Or set ROCKHOPPER_TOKEN to a Personal Access Token ' +
|
|
38
|
+
'to use PAT auth instead of OAuth.');
|
|
39
|
+
throw _keytarErr;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const KEYTAR_SERVICE = 'rockhopper-mcp';
|
|
43
|
+
const KEYTAR_ACCOUNT = 'oauth-tokens';
|
|
44
|
+
/**
|
|
45
|
+
* Read the persisted OAuth bundle. Returns null if no bundle is stored
|
|
46
|
+
* OR if the stored value is malformed (corrupted entry — treat as
|
|
47
|
+
* "no tokens" and let the CLI initiate a fresh device flow).
|
|
48
|
+
*/
|
|
49
|
+
export async function getTokens() {
|
|
50
|
+
const keytar = await loadKeytar();
|
|
51
|
+
const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
52
|
+
if (!raw)
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (typeof parsed?.accessToken !== 'string')
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
accessToken: parsed.accessToken,
|
|
60
|
+
refreshToken: typeof parsed.refreshToken === 'string'
|
|
61
|
+
? parsed.refreshToken
|
|
62
|
+
: undefined,
|
|
63
|
+
expiresAt: typeof parsed.expiresAt === 'number' ? parsed.expiresAt : null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Persist the OAuth bundle, overwriting any prior bundle.
|
|
72
|
+
*/
|
|
73
|
+
export async function setTokens(bundle) {
|
|
74
|
+
const keytar = await loadKeytar();
|
|
75
|
+
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, JSON.stringify(bundle));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Delete any persisted bundle. Safe to call when nothing is stored
|
|
79
|
+
* (keytar returns false; we ignore the return).
|
|
80
|
+
*/
|
|
81
|
+
export async function clearTokens() {
|
|
82
|
+
const keytar = await loadKeytar();
|
|
83
|
+
await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `true` if `bundle.expiresAt` is in the past (or null). Pure helper —
|
|
87
|
+
* the device-grant client wires this into its refresh-on-expiry logic.
|
|
88
|
+
*
|
|
89
|
+
* A small safety margin (60s by default) treats tokens about to expire
|
|
90
|
+
* as already expired, so the client refreshes BEFORE the API rejects
|
|
91
|
+
* the next call.
|
|
92
|
+
*/
|
|
93
|
+
export function isExpired(bundle, marginMs = 60_000, now = Date.now()) {
|
|
94
|
+
if (bundle.expiresAt === null)
|
|
95
|
+
return true;
|
|
96
|
+
return bundle.expiresAt - marginMs <= now;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=token-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-store.js","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAsBH,IAAI,OAAO,GAAwB,IAAI,CAAC;AACxC,IAAI,UAAU,GAAiB,IAAI,CAAC;AAEpC,KAAK,UAAU,UAAU;IACvB,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,oBAAoB;IACpB,IAAI,UAAU;QAAE,MAAM,UAAU,CAAC;IACjC,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,OAAO,CAAC;IACjB,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,2QAA2Q;QAC3Q,MAAM,MAAM,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1D,UAAU,GAAG,IAAI,KAAK,CACpB,4BAA4B,MAAM,KAAK;YACrC,wFAAwF;YACxF,yFAAyF;YACzF,mCAAmC,CACtC,CAAC;QACF,MAAM,UAAU,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,cAAc,GAAG,gBAAgB,CAAC;AACxC,MAAM,cAAc,GAAG,cAAc,CAAC;AAUtC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;IACrE,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,OAAO,MAAM,EAAE,WAAW,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QACzD,OAAO;YACL,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,YAAY,EACV,OAAO,MAAM,CAAC,YAAY,KAAK,QAAQ;gBACrC,CAAC,CAAC,MAAM,CAAC,YAAY;gBACrB,CAAC,CAAC,SAAS;YACf,SAAS,EACP,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;SACjE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,MAAwB;IACtD,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,MAAM,CAAC,WAAW,CACtB,cAAc,EACd,cAAc,EACd,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CACvB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,MAAM,CAAC,cAAc,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS,CACvB,MAAwB,EACxB,QAAQ,GAAG,MAAM,EACjB,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IAEhB,IAAI,MAAM,CAAC,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC3C,OAAO,MAAM,CAAC,SAAS,GAAG,QAAQ,IAAI,GAAG,CAAC;AAC5C,CAAC"}
|