@rockhopper-co/mcp-server 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/api-client.d.ts +136 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +185 -2
- package/dist/api-client.js.map +1 -1
- package/dist/capabilities.d.ts +5 -6
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +6 -9
- package/dist/capabilities.js.map +1 -1
- package/dist/drive-search.d.ts +162 -0
- package/dist/drive-search.d.ts.map +1 -0
- package/dist/drive-search.js +210 -0
- package/dist/drive-search.js.map +1 -0
- package/dist/enrollment.d.ts +121 -0
- package/dist/enrollment.d.ts.map +1 -0
- package/dist/enrollment.js +247 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/resources/orchestration-guide.md +58 -8
- package/dist/tools/connect-microsoft.d.ts +21 -0
- package/dist/tools/connect-microsoft.d.ts.map +1 -0
- package/dist/tools/connect-microsoft.js +138 -0
- package/dist/tools/connect-microsoft.js.map +1 -0
- package/dist/tools/drive-search-lanes.d.ts +65 -0
- package/dist/tools/drive-search-lanes.d.ts.map +1 -0
- package/dist/tools/drive-search-lanes.js +95 -0
- package/dist/tools/drive-search-lanes.js.map +1 -0
- package/dist/tools/drive-search.contract.d.ts +94 -0
- package/dist/tools/drive-search.contract.d.ts.map +1 -0
- package/dist/tools/drive-search.contract.js +235 -0
- package/dist/tools/drive-search.contract.js.map +1 -0
- package/dist/tools/drive-search.d.ts +31 -0
- package/dist/tools/drive-search.d.ts.map +1 -0
- package/dist/tools/drive-search.js +209 -0
- package/dist/tools/drive-search.js.map +1 -0
- package/dist/tools/enroll-file.contract.d.ts +52 -0
- package/dist/tools/enroll-file.contract.d.ts.map +1 -0
- package/dist/tools/enroll-file.contract.js +105 -0
- package/dist/tools/enroll-file.contract.js.map +1 -0
- package/dist/tools/enroll-file.d.ts +4 -0
- package/dist/tools/enroll-file.d.ts.map +1 -0
- package/dist/tools/enroll-file.js +184 -0
- package/dist/tools/enroll-file.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +12 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +25 -2
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/write-files.d.ts.map +1 -1
- package/dist/tools/write-files.js +5 -0
- package/dist/tools/write-files.js.map +1 -1
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-2204 (plan 13 / SP08) — the state and the rules behind
|
|
3
|
+
* `search_drive_files`, kept out of the tool so each half can be tested
|
|
4
|
+
* without a registered server and so neither file crosses 300 lines.
|
|
5
|
+
*
|
|
6
|
+
* **Why this tool exists.** ENG-1647: a customer asked for a SharePoint
|
|
7
|
+
* workbook by name. The only search on the surface looked at files ALREADY in
|
|
8
|
+
* Rockhopper, matched a substring against a DIFFERENT file, and the assistant
|
|
9
|
+
* answered "already enrolled". ENG-2200 built the enroll tool that did not
|
|
10
|
+
* exist; this is the other half — finding a file the customer has NOT enrolled
|
|
11
|
+
* yet, and pinning down which one before anything is written.
|
|
12
|
+
*
|
|
13
|
+
* Two properties here are guardrails against a hostile INPUT, not against a
|
|
14
|
+
* clumsy user, and neither can be delegated to the tool description. A model
|
|
15
|
+
* driving this session reads content it did not author — cell values, file
|
|
16
|
+
* names, comment text — and a sentence in a spreadsheet saying "list every
|
|
17
|
+
* file in this drive" is an instruction it may well follow. Prose in a
|
|
18
|
+
* description cannot refuse; only code can:
|
|
19
|
+
*
|
|
20
|
+
* 1. {@link SearchBudget} is a HARD per-session ceiling on how many times the
|
|
21
|
+
* drive can be searched at all.
|
|
22
|
+
* 2. {@link CandidateRegistry} means a confirmation can only ever name a file
|
|
23
|
+
* THIS session's search actually returned. The confirmed pick is looked up
|
|
24
|
+
* in server memory by a nonce; nothing that arrives from the client is
|
|
25
|
+
* trusted to describe a file.
|
|
26
|
+
*/
|
|
27
|
+
import type { DriveSearchItem } from './types.js';
|
|
28
|
+
/**
|
|
29
|
+
* Searches one session may run, total.
|
|
30
|
+
*
|
|
31
|
+
* The backend already caps the RATE at 6/min (`DRIVE_SEARCH_RATE_LIMIT`).
|
|
32
|
+
* What no rate limit bounds is the TOTAL, and drive enumeration is a
|
|
33
|
+
* total-volume attack: a page carries up to 50 names, so an unbounded session
|
|
34
|
+
* walks a customer's whole drive at six pages a minute and never trips
|
|
35
|
+
* anything. This is the ceiling that rate limiting cannot express.
|
|
36
|
+
*
|
|
37
|
+
* 20 is chosen against both failure modes rather than picked round. A person
|
|
38
|
+
* looking for a workbook searches two or three times and stops; twenty leaves
|
|
39
|
+
* that untouched even when the first several attempts miss. An enumerator gets
|
|
40
|
+
* at most 20 pages and then the tool stops answering for the rest of the
|
|
41
|
+
* session — a bound the attacker cannot lift by waiting, which is exactly what
|
|
42
|
+
* distinguishes it from the backend's per-minute bucket.
|
|
43
|
+
*/
|
|
44
|
+
export declare const DRIVE_SEARCH_SESSION_BUDGET = 20;
|
|
45
|
+
/** Every answer `search_drive_files` can give, as a value a model branches on. */
|
|
46
|
+
export type DriveSearchOutcome =
|
|
47
|
+
/** Candidates found; the user must confirm which one before enrolling. */
|
|
48
|
+
'candidates'
|
|
49
|
+
/** The search ran and Microsoft returned nothing. */
|
|
50
|
+
| 'no_matches'
|
|
51
|
+
/** The user picked one; its identifiers are in the result, ready to enroll. */
|
|
52
|
+
| 'confirmed'
|
|
53
|
+
/** The user was asked and said no. Nothing was searched further or written. */
|
|
54
|
+
| 'declined'
|
|
55
|
+
/** This session has used its whole search budget. Refused BEFORE the call. */
|
|
56
|
+
| 'search_limit_reached'
|
|
57
|
+
/** No delegated Microsoft grant — the connect link is in the result. */
|
|
58
|
+
| 'microsoft_not_connected'
|
|
59
|
+
/**
|
|
60
|
+
* The tenant has not approved Rockhopper and only an administrator can.
|
|
61
|
+
* Its OWN outcome and not a flavour of `microsoft_not_connected`, because
|
|
62
|
+
* the two name opposite actions: one is the user's to take and this one
|
|
63
|
+
* is not. Carries no connect link — there is nothing on the far end of
|
|
64
|
+
* one for this user yet.
|
|
65
|
+
*/
|
|
66
|
+
| 'microsoft_admin_approval_required'
|
|
67
|
+
/** Microsoft could not answer, or the caller is searching too fast. */
|
|
68
|
+
| 'search_unavailable'
|
|
69
|
+
/** This Rockhopper deployment serves no drive-search route yet. */
|
|
70
|
+
| 'backend_unsupported'
|
|
71
|
+
/** The confirmation named a file this session's search never returned. */
|
|
72
|
+
| 'unknown_candidate';
|
|
73
|
+
/**
|
|
74
|
+
* A hard per-session ceiling on drive searches.
|
|
75
|
+
*
|
|
76
|
+
* One instance per `createServer`, so the count belongs to the session and not
|
|
77
|
+
* to the module — two servers in one test process must not share a budget, and
|
|
78
|
+
* a module-level counter is exactly the shape that silently does.
|
|
79
|
+
*/
|
|
80
|
+
export declare class SearchBudget {
|
|
81
|
+
private readonly limit;
|
|
82
|
+
private used;
|
|
83
|
+
constructor(limit?: number);
|
|
84
|
+
get spent(): number;
|
|
85
|
+
get ceiling(): number;
|
|
86
|
+
/**
|
|
87
|
+
* Claim one search. `false` means the session is done searching, and the
|
|
88
|
+
* caller must not reach the network — the point of the cap is that the
|
|
89
|
+
* request never leaves, not that its answer is discarded.
|
|
90
|
+
*/
|
|
91
|
+
claim(): boolean;
|
|
92
|
+
}
|
|
93
|
+
/** One candidate, plus the identity `enroll_file` needs to act on it. */
|
|
94
|
+
export interface Candidate {
|
|
95
|
+
msId: string;
|
|
96
|
+
driveMsId: string | null;
|
|
97
|
+
name: string;
|
|
98
|
+
parentPath: string | null;
|
|
99
|
+
lastModifiedAt: string | null;
|
|
100
|
+
enrollmentState: DriveSearchItem['enrollmentState'];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The candidate sets THIS session has actually seen, keyed by a nonce.
|
|
104
|
+
*
|
|
105
|
+
* Why server memory rather than the round-trip. The 2026-07-28 confirmation
|
|
106
|
+
* lane hands the client a `requestState` string and gets it back on retry, and
|
|
107
|
+
* the SDK is explicit that the returned value is attacker-controlled: it
|
|
108
|
+
* travels through the client, nothing signs it, and `requestState()` hands
|
|
109
|
+
* back whatever came in. Putting the candidate list in there would mean a
|
|
110
|
+
* confirmation could describe ANY file — including one Microsoft never
|
|
111
|
+
* returned for this user — and the tool would then hand its identifiers to
|
|
112
|
+
* `enroll_file`. So the wire carries a lookup key and nothing else, and the
|
|
113
|
+
* files it can name are only ever the ones {@link remember} was given.
|
|
114
|
+
*
|
|
115
|
+
* Bounded because a long-lived stdio session is a long-lived process: only the
|
|
116
|
+
* most recent {@link MAX_REMEMBERED} sets are kept.
|
|
117
|
+
*/
|
|
118
|
+
export declare class CandidateRegistry {
|
|
119
|
+
static readonly MAX_REMEMBERED = 8;
|
|
120
|
+
private readonly sets;
|
|
121
|
+
remember(nonce: string, candidates: readonly Candidate[]): void;
|
|
122
|
+
/**
|
|
123
|
+
* The candidate this session offered under `nonce` at `index`, or `null`.
|
|
124
|
+
*
|
|
125
|
+
* `null` for an unknown nonce and for an out-of-range index alike: both mean
|
|
126
|
+
* the confirmation is describing something this session did not offer, and
|
|
127
|
+
* the caller's answer to that is the same refusal.
|
|
128
|
+
*/
|
|
129
|
+
resolve(nonce: string, index: number): Candidate | null;
|
|
130
|
+
/** The set offered under `nonce`, for re-rendering the question on retry. */
|
|
131
|
+
recall(nonce: string): readonly Candidate[] | null;
|
|
132
|
+
}
|
|
133
|
+
/** Turn a backend item into a candidate, dropping fields nothing renders. */
|
|
134
|
+
export declare function toCandidate(item: DriveSearchItem): Candidate;
|
|
135
|
+
export interface ClassifiedSearchFailure {
|
|
136
|
+
outcome: DriveSearchOutcome;
|
|
137
|
+
message: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Turn a thrown API error into one of the named outcomes.
|
|
141
|
+
*
|
|
142
|
+
* Keyed on the backend's `code` first and the status only as a fallback, for
|
|
143
|
+
* the same reason `classifyEnrollmentFailure` is: the codes name different
|
|
144
|
+
* remedies and the statuses do not. `NO_DELEGATED_TOKEN` is the only one whose
|
|
145
|
+
* remedy is the user's to take, and it is handled by the caller rather than
|
|
146
|
+
* here, because answering it means MINTING a connect link.
|
|
147
|
+
*/
|
|
148
|
+
export declare function classifyDriveSearchFailure(error: unknown): ClassifiedSearchFailure;
|
|
149
|
+
/** The backend's one refusal that means "the user must connect Microsoft". */
|
|
150
|
+
export declare function isNoDelegatedToken(error: unknown): boolean;
|
|
151
|
+
export declare function isAdminConsentRequired(error: unknown): boolean;
|
|
152
|
+
/**
|
|
153
|
+
* What the assistant is told when an administrator has to act.
|
|
154
|
+
*
|
|
155
|
+
* Names the ACTION and who takes it, and says plainly that retrying will not
|
|
156
|
+
* work — a model that reads "could not connect" will helpfully offer to try
|
|
157
|
+
* again, and every retry costs the user another dead end. It also does not
|
|
158
|
+
* hand out a connect link, because there is nothing on the other end of one
|
|
159
|
+
* for this user yet.
|
|
160
|
+
*/
|
|
161
|
+
export declare const ADMIN_CONSENT_TEXT: string;
|
|
162
|
+
//# sourceMappingURL=drive-search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drive-search.d.ts","sourceRoot":"","sources":["../src/drive-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C,kFAAkF;AAClF,MAAM,MAAM,kBAAkB;AAC5B,0EAA0E;AACxE,YAAY;AACd,qDAAqD;GACnD,YAAY;AACd,+EAA+E;GAC7E,WAAW;AACb,+EAA+E;GAC7E,UAAU;AACZ,8EAA8E;GAC5E,sBAAsB;AACxB,wEAAwE;GACtE,yBAAyB;AAC3B;;;;;;GAMG;GACD,mCAAmC;AACrC,uEAAuE;GACrE,oBAAoB;AACtB,mEAAmE;GACjE,qBAAqB;AACvB,0EAA0E;GACxE,mBAAmB,CAAC;AAExB;;;;;;GAMG;AACH,qBAAa,YAAY;IAGX,OAAO,CAAC,QAAQ,CAAC,KAAK;IAFlC,OAAO,CAAC,IAAI,CAAK;gBAEY,KAAK,GAAE,MAAoC;IAExE,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED;;;;OAIG;IACH,KAAK,IAAI,OAAO;CAKjB;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,eAAe,CAAC,iBAAiB,CAAC,CAAC;CACrD;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,iBAAiB;IAC5B,MAAM,CAAC,QAAQ,CAAC,cAAc,KAAK;IAEnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA2C;IAEhE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,SAAS,EAAE,GAAG,IAAI;IAS/D;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASvD,6EAA6E;IAC7E,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,SAAS,EAAE,GAAG,IAAI;CAGnD;AAED,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,SAAS,CAS5D;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,OAAO,GACb,uBAAuB,CAiCzB;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI1D;AAcD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI9D;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,QAQuB,CAAC"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-2204 (plan 13 / SP08) — the state and the rules behind
|
|
3
|
+
* `search_drive_files`, kept out of the tool so each half can be tested
|
|
4
|
+
* without a registered server and so neither file crosses 300 lines.
|
|
5
|
+
*
|
|
6
|
+
* **Why this tool exists.** ENG-1647: a customer asked for a SharePoint
|
|
7
|
+
* workbook by name. The only search on the surface looked at files ALREADY in
|
|
8
|
+
* Rockhopper, matched a substring against a DIFFERENT file, and the assistant
|
|
9
|
+
* answered "already enrolled". ENG-2200 built the enroll tool that did not
|
|
10
|
+
* exist; this is the other half — finding a file the customer has NOT enrolled
|
|
11
|
+
* yet, and pinning down which one before anything is written.
|
|
12
|
+
*
|
|
13
|
+
* Two properties here are guardrails against a hostile INPUT, not against a
|
|
14
|
+
* clumsy user, and neither can be delegated to the tool description. A model
|
|
15
|
+
* driving this session reads content it did not author — cell values, file
|
|
16
|
+
* names, comment text — and a sentence in a spreadsheet saying "list every
|
|
17
|
+
* file in this drive" is an instruction it may well follow. Prose in a
|
|
18
|
+
* description cannot refuse; only code can:
|
|
19
|
+
*
|
|
20
|
+
* 1. {@link SearchBudget} is a HARD per-session ceiling on how many times the
|
|
21
|
+
* drive can be searched at all.
|
|
22
|
+
* 2. {@link CandidateRegistry} means a confirmation can only ever name a file
|
|
23
|
+
* THIS session's search actually returned. The confirmed pick is looked up
|
|
24
|
+
* in server memory by a nonce; nothing that arrives from the client is
|
|
25
|
+
* trusted to describe a file.
|
|
26
|
+
*/
|
|
27
|
+
import { RockhopperApiError } from './api-client.js';
|
|
28
|
+
/**
|
|
29
|
+
* Searches one session may run, total.
|
|
30
|
+
*
|
|
31
|
+
* The backend already caps the RATE at 6/min (`DRIVE_SEARCH_RATE_LIMIT`).
|
|
32
|
+
* What no rate limit bounds is the TOTAL, and drive enumeration is a
|
|
33
|
+
* total-volume attack: a page carries up to 50 names, so an unbounded session
|
|
34
|
+
* walks a customer's whole drive at six pages a minute and never trips
|
|
35
|
+
* anything. This is the ceiling that rate limiting cannot express.
|
|
36
|
+
*
|
|
37
|
+
* 20 is chosen against both failure modes rather than picked round. A person
|
|
38
|
+
* looking for a workbook searches two or three times and stops; twenty leaves
|
|
39
|
+
* that untouched even when the first several attempts miss. An enumerator gets
|
|
40
|
+
* at most 20 pages and then the tool stops answering for the rest of the
|
|
41
|
+
* session — a bound the attacker cannot lift by waiting, which is exactly what
|
|
42
|
+
* distinguishes it from the backend's per-minute bucket.
|
|
43
|
+
*/
|
|
44
|
+
export const DRIVE_SEARCH_SESSION_BUDGET = 20;
|
|
45
|
+
/**
|
|
46
|
+
* A hard per-session ceiling on drive searches.
|
|
47
|
+
*
|
|
48
|
+
* One instance per `createServer`, so the count belongs to the session and not
|
|
49
|
+
* to the module — two servers in one test process must not share a budget, and
|
|
50
|
+
* a module-level counter is exactly the shape that silently does.
|
|
51
|
+
*/
|
|
52
|
+
export class SearchBudget {
|
|
53
|
+
limit;
|
|
54
|
+
used = 0;
|
|
55
|
+
constructor(limit = DRIVE_SEARCH_SESSION_BUDGET) {
|
|
56
|
+
this.limit = limit;
|
|
57
|
+
}
|
|
58
|
+
get spent() {
|
|
59
|
+
return this.used;
|
|
60
|
+
}
|
|
61
|
+
get ceiling() {
|
|
62
|
+
return this.limit;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Claim one search. `false` means the session is done searching, and the
|
|
66
|
+
* caller must not reach the network — the point of the cap is that the
|
|
67
|
+
* request never leaves, not that its answer is discarded.
|
|
68
|
+
*/
|
|
69
|
+
claim() {
|
|
70
|
+
if (this.used >= this.limit)
|
|
71
|
+
return false;
|
|
72
|
+
this.used += 1;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The candidate sets THIS session has actually seen, keyed by a nonce.
|
|
78
|
+
*
|
|
79
|
+
* Why server memory rather than the round-trip. The 2026-07-28 confirmation
|
|
80
|
+
* lane hands the client a `requestState` string and gets it back on retry, and
|
|
81
|
+
* the SDK is explicit that the returned value is attacker-controlled: it
|
|
82
|
+
* travels through the client, nothing signs it, and `requestState()` hands
|
|
83
|
+
* back whatever came in. Putting the candidate list in there would mean a
|
|
84
|
+
* confirmation could describe ANY file — including one Microsoft never
|
|
85
|
+
* returned for this user — and the tool would then hand its identifiers to
|
|
86
|
+
* `enroll_file`. So the wire carries a lookup key and nothing else, and the
|
|
87
|
+
* files it can name are only ever the ones {@link remember} was given.
|
|
88
|
+
*
|
|
89
|
+
* Bounded because a long-lived stdio session is a long-lived process: only the
|
|
90
|
+
* most recent {@link MAX_REMEMBERED} sets are kept.
|
|
91
|
+
*/
|
|
92
|
+
export class CandidateRegistry {
|
|
93
|
+
static MAX_REMEMBERED = 8;
|
|
94
|
+
sets = new Map();
|
|
95
|
+
remember(nonce, candidates) {
|
|
96
|
+
this.sets.set(nonce, candidates);
|
|
97
|
+
while (this.sets.size > CandidateRegistry.MAX_REMEMBERED) {
|
|
98
|
+
const oldest = this.sets.keys().next().value;
|
|
99
|
+
if (oldest === undefined)
|
|
100
|
+
break;
|
|
101
|
+
this.sets.delete(oldest);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The candidate this session offered under `nonce` at `index`, or `null`.
|
|
106
|
+
*
|
|
107
|
+
* `null` for an unknown nonce and for an out-of-range index alike: both mean
|
|
108
|
+
* the confirmation is describing something this session did not offer, and
|
|
109
|
+
* the caller's answer to that is the same refusal.
|
|
110
|
+
*/
|
|
111
|
+
resolve(nonce, index) {
|
|
112
|
+
const set = this.sets.get(nonce);
|
|
113
|
+
if (!set)
|
|
114
|
+
return null;
|
|
115
|
+
if (!Number.isInteger(index) || index < 0 || index >= set.length) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return set[index];
|
|
119
|
+
}
|
|
120
|
+
/** The set offered under `nonce`, for re-rendering the question on retry. */
|
|
121
|
+
recall(nonce) {
|
|
122
|
+
return this.sets.get(nonce) ?? null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Turn a backend item into a candidate, dropping fields nothing renders. */
|
|
126
|
+
export function toCandidate(item) {
|
|
127
|
+
return {
|
|
128
|
+
msId: item.msId,
|
|
129
|
+
driveMsId: item.driveMsId,
|
|
130
|
+
name: item.name,
|
|
131
|
+
parentPath: item.parentPath,
|
|
132
|
+
lastModifiedAt: item.lastModifiedAt,
|
|
133
|
+
enrollmentState: item.enrollmentState,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Turn a thrown API error into one of the named outcomes.
|
|
138
|
+
*
|
|
139
|
+
* Keyed on the backend's `code` first and the status only as a fallback, for
|
|
140
|
+
* the same reason `classifyEnrollmentFailure` is: the codes name different
|
|
141
|
+
* remedies and the statuses do not. `NO_DELEGATED_TOKEN` is the only one whose
|
|
142
|
+
* remedy is the user's to take, and it is handled by the caller rather than
|
|
143
|
+
* here, because answering it means MINTING a connect link.
|
|
144
|
+
*/
|
|
145
|
+
export function classifyDriveSearchFailure(error) {
|
|
146
|
+
if (error instanceof RockhopperApiError) {
|
|
147
|
+
// The npm package and the backend ship on separate clocks and a customer
|
|
148
|
+
// running `npx` picks up `latest` the moment it publishes, so calling a
|
|
149
|
+
// deployment that predates the route is a real case, not a theoretical
|
|
150
|
+
// one. It must not read as "you have no files".
|
|
151
|
+
if (error.status === 404) {
|
|
152
|
+
return {
|
|
153
|
+
outcome: 'backend_unsupported',
|
|
154
|
+
message: 'This Rockhopper deployment cannot search Microsoft files from an ' +
|
|
155
|
+
'assistant yet. Ask the user to paste the workbook\'s SharePoint or ' +
|
|
156
|
+
'OneDrive link and call `enroll_file` with it instead.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (error.status === 400) {
|
|
160
|
+
return {
|
|
161
|
+
outcome: 'search_unavailable',
|
|
162
|
+
message: 'The search needs something to look for. Ask the user for part of ' +
|
|
163
|
+
'the file name, or call again with scope="recent" to list the files ' +
|
|
164
|
+
'they have worked on lately.',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
outcome: 'search_unavailable',
|
|
170
|
+
message: 'Microsoft could not answer the file search just now. This is not an ' +
|
|
171
|
+
'empty drive — nothing was searched. Try again in a moment, or ask the ' +
|
|
172
|
+
'user to paste the workbook link and call `enroll_file` with it.',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** The backend's one refusal that means "the user must connect Microsoft". */
|
|
176
|
+
export function isNoDelegatedToken(error) {
|
|
177
|
+
return (error instanceof RockhopperApiError && error.code === 'NO_DELEGATED_TOKEN');
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The backend's `reason` for a refusal the USER CANNOT FIX (ENG-2614).
|
|
181
|
+
*
|
|
182
|
+
* The tenant has not approved Rockhopper, and Microsoft will not let an
|
|
183
|
+
* ordinary employee approve it — `Sites.Read.All` is administrator-only. So
|
|
184
|
+
* this arrives wearing the same coarse `NO_DELEGATED_TOKEN` code as "you
|
|
185
|
+
* have never connected", and treating the two alike is how the user ends up
|
|
186
|
+
* in a loop: handed a connect link, sent to Microsoft, refused, handed the
|
|
187
|
+
* same link again. Only the fine `reason` separates them.
|
|
188
|
+
*/
|
|
189
|
+
const CONSENT_REQUIRED = 'CONSENT_REQUIRED';
|
|
190
|
+
export function isAdminConsentRequired(error) {
|
|
191
|
+
return (error instanceof RockhopperApiError && error.reason === CONSENT_REQUIRED);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* What the assistant is told when an administrator has to act.
|
|
195
|
+
*
|
|
196
|
+
* Names the ACTION and who takes it, and says plainly that retrying will not
|
|
197
|
+
* work — a model that reads "could not connect" will helpfully offer to try
|
|
198
|
+
* again, and every retry costs the user another dead end. It also does not
|
|
199
|
+
* hand out a connect link, because there is nothing on the other end of one
|
|
200
|
+
* for this user yet.
|
|
201
|
+
*/
|
|
202
|
+
export const ADMIN_CONSENT_TEXT = 'Rockhopper cannot search this user\'s Microsoft files because their ' +
|
|
203
|
+
'organisation has not approved Rockhopper yet. Microsoft only lets a ' +
|
|
204
|
+
'Microsoft 365 administrator approve this — the user cannot grant it ' +
|
|
205
|
+
'themselves, and connecting or signing in again will not change the ' +
|
|
206
|
+
'answer. Ask the user to send their IT administrator to ' +
|
|
207
|
+
'https://docs.rockhopper.co/it-setup/approve-file-access, which has ' +
|
|
208
|
+
'the approval link and what it grants. Everything else in Rockhopper ' +
|
|
209
|
+
'keeps working meanwhile. Do not retry this search.';
|
|
210
|
+
//# sourceMappingURL=drive-search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drive-search.js","sourceRoot":"","sources":["../src/drive-search.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENG-2200 (plan 13 / SP03) — the vocabulary and the refusal mapping behind
|
|
3
|
+
* `enroll_file`, kept out of the tool so the branch table can be tested
|
|
4
|
+
* without a registered server and so neither file crosses 300 lines.
|
|
5
|
+
*
|
|
6
|
+
* **Why this tool exists.** A customer asked Claude to enroll a SharePoint
|
|
7
|
+
* workbook. A name-substring search matched a DIFFERENT, already-enrolled file
|
|
8
|
+
* and the assistant answered "already enrolled". Told it had the wrong file,
|
|
9
|
+
* it enumerated its tools and found no enroll tool existed at all (ENG-1647).
|
|
10
|
+
* Two failures: a wrong match reported as a fact, and a dead end. Every
|
|
11
|
+
* decision below is aimed at one of those — an outcome is never inferred from
|
|
12
|
+
* a name, and no path ends without a next action the user can take.
|
|
13
|
+
*/
|
|
14
|
+
import type { EnrollmentState, RockhopperId, ServerEnrollmentOutcome, Team, UserSummary } from './types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Every answer `enroll_file` can give, as a value a model can branch on
|
|
17
|
+
* instead of reading prose.
|
|
18
|
+
*
|
|
19
|
+
* The seven ENROLLMENT results — what happened to the file:
|
|
20
|
+
* - `enrolled` — accepted; the file was not here before.
|
|
21
|
+
* - `restored` — accepted; a file the user had removed is visible again.
|
|
22
|
+
* - `already_enrolled` — nothing to do; it is here and visible.
|
|
23
|
+
* - `access_unproven` — refused: this session has no Microsoft identity.
|
|
24
|
+
* - `admin_approval_required` — refused: the user's ORGANISATION has not
|
|
25
|
+
* approved Rockhopper. Separate from `access_unproven` because the user
|
|
26
|
+
* cannot fix it and must not be told to reconnect (ENG-2614).
|
|
27
|
+
* - `unresolvable` — refused: the link does not name a file we can find.
|
|
28
|
+
* - `unsupported_provider` — refused: not a Microsoft link.
|
|
29
|
+
*
|
|
30
|
+
* Three CONTROL answers — the tool needs something before it can proceed:
|
|
31
|
+
* - `share_with_required` — the model must ask the user who may see the file.
|
|
32
|
+
* - `restore_confirmation_required` — the target is hidden (D8); restoring is
|
|
33
|
+
* a second, explicit call.
|
|
34
|
+
* - `backend_unsupported` — this Rockhopper deployment has no enrollment API
|
|
35
|
+
* yet. Published separately from the backend, so this is reachable.
|
|
36
|
+
*/
|
|
37
|
+
export type EnrollOutcome = 'enrolled' | 'restored' | 'already_enrolled' | 'access_unproven' | 'admin_approval_required' | 'unresolvable' | 'unsupported_provider' | 'share_with_required' | 'restore_confirmation_required' | 'backend_unsupported';
|
|
38
|
+
/** Who may see the file. Asked EVERY time; never defaulted (D5/D6). */
|
|
39
|
+
export type ShareWith = 'me' | 'team';
|
|
40
|
+
/**
|
|
41
|
+
* There is deliberately NO client-side host allow-list here.
|
|
42
|
+
*
|
|
43
|
+
* The obvious optimisation is to screen `docs.google.com` locally and save a
|
|
44
|
+
* round trip. It was written and removed: the backend's real rule is narrower
|
|
45
|
+
* than the plausible guess — `/(^|\.)sharepoint\.com$/` ONLY, so
|
|
46
|
+
* `onedrive.live.com` and `*.sharepoint.us` are refused there — and only
|
|
47
|
+
* Google earns `unsupported_provider`, while Dropbox and Box are
|
|
48
|
+
* `unresolvable`. A second copy of that rule in a package customers upgrade on
|
|
49
|
+
* their own schedule would answer differently from the server the moment
|
|
50
|
+
* either side moved, and a pre-screen that refuses a link the backend would
|
|
51
|
+
* have taken is invisible: the user simply cannot add their file. One rule,
|
|
52
|
+
* one place; {@link classifyEnrollmentFailure} maps what it says.
|
|
53
|
+
*/
|
|
54
|
+
/** The outcome an already-known enrollment state implies, before any write. */
|
|
55
|
+
export declare function outcomeForState(state: EnrollmentState): 'already_enrolled' | 'restore_confirmation_required' | null;
|
|
56
|
+
/**
|
|
57
|
+
* ENG-2536 — turn the outcome the SERVER reported into what the user is told.
|
|
58
|
+
*
|
|
59
|
+
* Two things make this worth a function rather than a ternary at the call site.
|
|
60
|
+
*
|
|
61
|
+
* First, it is exhaustive with a `never` arm. `ServerEnrollmentOutcome` is a
|
|
62
|
+
* hand-kept mirror of a backend enum (this package ships over npm and cannot
|
|
63
|
+
* import the backend tree), so the only moment anybody notices a fourth value
|
|
64
|
+
* is when they edit that union — and this is what breaks and makes them handle
|
|
65
|
+
* it. ENG-2580 is the same shape gone wrong: fifteen if-chains where a new
|
|
66
|
+
* value fell into every `else` and nothing failed.
|
|
67
|
+
*
|
|
68
|
+
* Second, `already_enrolled` is the one answer that is not a promise about the
|
|
69
|
+
* future. Everything else here says "we are reading the workbook, come back";
|
|
70
|
+
* that one says nothing was written and nothing will be. Saying "Rockhopper
|
|
71
|
+
* reads the workbook in the background" about a file it read months ago sends
|
|
72
|
+
* the user to wait for an event that will never arrive — the ENG-1647 dead end
|
|
73
|
+
* with a friendlier voice.
|
|
74
|
+
*/
|
|
75
|
+
export declare function describeServerOutcome(serverOutcome: ServerEnrollmentOutcome, fileName: string, who: string): {
|
|
76
|
+
outcome: EnrollOutcome;
|
|
77
|
+
text: string;
|
|
78
|
+
};
|
|
79
|
+
export interface ClassifiedFailure {
|
|
80
|
+
outcome: EnrollOutcome | 'error';
|
|
81
|
+
message: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Turn a thrown API error into one of the named outcomes.
|
|
85
|
+
*
|
|
86
|
+
* Keyed on the backend's `code` first and the HTTP status only as a fallback,
|
|
87
|
+
* because the codes each name a DIFFERENT remedy and the statuses do not: four
|
|
88
|
+
* distinct refusals share 403, and answering all of them "you do not have
|
|
89
|
+
* permission" sends three of the four users to fix the wrong thing.
|
|
90
|
+
*/
|
|
91
|
+
export declare function classifyEnrollmentFailure(error: unknown): ClassifiedFailure;
|
|
92
|
+
/**
|
|
93
|
+
* The two reads `share_with: 'team'` needs. A narrow interface rather than the
|
|
94
|
+
* whole `ApiClient`, so the resolution can be tested without one and so this
|
|
95
|
+
* module never imports the client's concrete type.
|
|
96
|
+
*/
|
|
97
|
+
export interface TeamDirectory {
|
|
98
|
+
getMe(): Promise<UserSummary>;
|
|
99
|
+
getTeam(teamId: RockhopperId): Promise<Team>;
|
|
100
|
+
}
|
|
101
|
+
/** Raised when `share_with: 'team'` names a team we cannot resolve. */
|
|
102
|
+
export declare class TeamUnresolvedError extends Error {
|
|
103
|
+
constructor(message: string);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The teammates a `share_with: 'team'` enroll fans the file out to.
|
|
107
|
+
*
|
|
108
|
+
* Mirrors the web enrollment wizard exactly — the caller's FIRST team
|
|
109
|
+
* membership, its roster, minus the caller — because SP04 defines "team" as
|
|
110
|
+
* "the wizard's default set" and two surfaces answering the same question
|
|
111
|
+
* differently is worse than either answer. The backend has no team-vs-private
|
|
112
|
+
* enum to lean on: `POST /enrolled-files/batch` takes an explicit list of
|
|
113
|
+
* platform ids and nothing else, so the expansion happens here or nowhere.
|
|
114
|
+
*
|
|
115
|
+
* THROWS rather than returning an empty list when there is no team or no
|
|
116
|
+
* teammate. Enrolling privately after the user said "my team" is a silent
|
|
117
|
+
* substitution of a different answer for the one they gave, and it is
|
|
118
|
+
* invisible: the file simply never appears for anyone else.
|
|
119
|
+
*/
|
|
120
|
+
export declare function resolveTeamShareTargets(api: TeamDirectory): Promise<string[]>;
|
|
121
|
+
//# sourceMappingURL=enrollment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enrollment.d.ts","sourceRoot":"","sources":["../src/enrollment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EACV,eAAe,EACf,YAAY,EACZ,uBAAuB,EACvB,IAAI,EACJ,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,aAAa,GACrB,UAAU,GACV,UAAU,GACV,kBAAkB,GAClB,iBAAiB,GACjB,yBAAyB,GACzB,cAAc,GACd,sBAAsB,GACtB,qBAAqB,GACrB,+BAA+B,GAC/B,qBAAqB,CAAC;AAE1B,uEAAuE;AACvE,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC;AAmBtC;;;;;;;;;;;;;GAaG;AAEH,+EAA+E;AAC/E,wBAAgB,eAAe,CAC7B,KAAK,EAAE,eAAe,GACrB,kBAAkB,GAAG,+BAA+B,GAAG,IAAI,CAI7D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,uBAAuB,EACtC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV;IAAE,OAAO,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAgC1C;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,CAwG3E;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9C;AAED,uEAAuE;AACvE,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CA6BnB"}
|