Skip to content

Commit 17c5922

Browse files
committed
fix(connectors): second validation pass
Re-validated every connector against live provider docs. The second pass found defects in the first pass's own fixes, so several entries below are regressions introduced during validation rather than in the original code. Regressions from the first validation pass: - trello: the >1000-card `before` walk rests on an ordering guarantee Trello does not document. The route declares no query params, and list cards are returned in `pos` order, so the 1000-result truncation is not guaranteed to keep the newest 1000 — while the rewrite had stopped setting listingCapped for multi-page lists, re-enabling reconciliation against an unverifiable set. Extra pages are still collected, but the listing is flagged again. - trello: the batching loop only broke on reaching the document target, so a workspace of many small lists issued thousands of sequential requests in one call. Capped per call. - zoho-desk: the new probe requests one record past the cap, which walks into Zoho's hard `from` <= 4999 ceiling and 422s the whole listing at the documented 5000 maximum. Guarded, flagging capped instead of throwing. - pagerduty: the incremental window was only half pinned — `until` was cached but the since-versus-date_range decision was recomputed per page, so a run near the 180-day threshold could flip mid-pagination and carry an offset into a different result set. - sftp: a fingerprint that is non-blank but normalizes away (`SHA256:`) installed no host verifier at all, silently trusting any host. - sftp: depth-limit pruning set listingCapped on every sync of a deep tree, permanently suppressing deletion reconciliation. - mintlify: the HTML fallback used the shared htmlToPlainText, which strips tags but not script contents — 294KB of RSC payload per page on the very site the fallback exists for. - mintlify: a sitemap-index child that 404s was skipped despite a comment claiming failure was fatal, and the origin-level index returned early with a fraction of a sub-path site's pages. Both silently collapse a listing. Claims the second pass refuted: - google-vault: the cursor bug the first pass "fixed" was unreachable. The engine reads sourceConfig once before the pagination loop, so the enabled kinds cannot change mid-sync. The defensive branch is removed. - google-vault: the real defect was page budget. One call per matter per kind truncated at roughly 249 matters, silently never indexing the rest. Now batches matters per call, raising the ceiling to roughly 3500. - mintlify: `.well-known/llms.txt` 404s on every site checked. - pagerduty: a postmortem GET does exist, but StatusPagePost.linked_resource carries no documented incident join, so it belongs in its own connector rather than the incident walk. Other fixes: - microsoft-excel: dates and currency indexed as raw serial numbers. Graph documents that Range.text is independent of cell width, so the `#######` objection does not apply; the connector now prefers text, matching the Google Sheets connector. A content-format token forces one re-index, since the hash is metadata-only and would otherwise keep stale content. - google-slides: a deck with no extractable text returned null, which the engine drops with no reason recorded. Now surfaces a skipped row. - google-vault: saved queries scoped by Chat space, Sites URL, or Drive document rendered no target at all. - zoho-desk: declare Desk.organization.READ, which validateConfig needs. - oauth: a granted read-write scope now satisfies a required `.readonly` sibling, so narrowing a consumer to least privilege does not ask every existing credential to re-consent for nothing. - copilot: drop the adapter-local credential check that made a keyless connector creatable in the UI but not through Chat. The use case decides.
1 parent a0d3abb commit 17c5922

12 files changed

Lines changed: 536 additions & 218 deletions

File tree

apps/sim/app/api/tools/sftp/utils.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,23 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis
181181
connectConfig.timeout = config.timeout
182182
}
183183

184-
const expectedFingerprint = config.hostFingerprint?.trim()
185-
? normalizeSha256Fingerprint(config.hostFingerprint)
184+
const suppliedFingerprint = config.hostFingerprint?.trim()
185+
const expectedFingerprint = suppliedFingerprint
186+
? normalizeSha256Fingerprint(suppliedFingerprint)
186187
: undefined
187188

189+
/**
190+
* Fail closed rather than silently skipping verification. A value that is
191+
* non-blank but normalizes away (`SHA256:`, `=`) would otherwise leave no
192+
* `hostVerifier` installed, trusting whatever host answers — the opposite
193+
* of what supplying a fingerprint asks for.
194+
*/
195+
if (suppliedFingerprint && !expectedFingerprint) {
196+
throw new Error(
197+
'Host key fingerprint is not a valid SHA-256 fingerprint. Expected the base64 form printed by `ssh-keyscan <host> | ssh-keygen -lf -`.'
198+
)
199+
}
200+
188201
/**
189202
* Set when the pinned fingerprint does not match. ssh2 reports the
190203
* rejection through a generic `'error'` event, so the precise cause is

apps/sim/connectors/google-slides/google-slides.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const logger = createLogger('GoogleSlidesConnector')
1414

1515
const PRESENTATION_MIME_TYPE = 'application/vnd.google-apps.presentation'
1616

17+
/** Reason recorded for a presentation whose slides contain no extractable text. */
18+
const NO_TEXT = 'No extractable text'
19+
1720
/** Guards against a pathological (or cyclic) group nesting depth. */
1821
const MAX_GROUP_DEPTH = 12
1922

@@ -373,21 +376,23 @@ export const googleSlidesConnector: ConnectorConfig = {
373376
if (file.trashed) return null
374377
if (file.mimeType !== PRESENTATION_MIME_TYPE) return null
375378

376-
try {
377-
const content = await fetchPresentationContent(
378-
accessToken,
379-
file.id,
380-
shouldIncludeSpeakerNotes(sourceConfig)
381-
)
382-
if (!content.trim()) return null
383-
384-
return { ...fileToStub(file), content, contentDeferred: false }
385-
} catch (error) {
386-
logger.warn(`Failed to extract content from presentation: ${file.name} (${file.id})`, {
387-
error: toError(error).message,
388-
})
389-
return null
379+
const content = await fetchPresentationContent(
380+
accessToken,
381+
file.id,
382+
shouldIncludeSpeakerNotes(sourceConfig)
383+
)
384+
385+
/**
386+
* An image-only deck carries no extractable text. Surfacing it as a skipped
387+
* row keeps it visible in the knowledge base UI — returning `null` would
388+
* make the engine drop the document with no reason recorded, so the
389+
* presentation would simply be missing and re-fetched on every sync.
390+
*/
391+
if (!content.trim()) {
392+
return { ...fileToStub(file), content: '', contentDeferred: false, skippedReason: NO_TEXT }
390393
}
394+
395+
return { ...fileToStub(file), content, contentDeferred: false }
391396
},
392397

393398
validateConfig: async (

apps/sim/connectors/google-vault/google-vault.ts

Lines changed: 117 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ const VAULT_API_BASE = 'https://vault.googleapis.com/v1'
1212
/** Vault caps both `matters.list` and `matters.holds.list` page sizes at 100. */
1313
const PAGE_SIZE = 100
1414

15+
/**
16+
* Matters whose child resources are enumerated in a single `listDocuments` call.
17+
*
18+
* The sync engine allows a bounded number of `listDocuments` calls per run, so
19+
* spending one call per matter per child kind would truncate the listing after a
20+
* few hundred matters. Draining a batch of matters per call keeps the call count
21+
* proportional to `matters / BATCH` instead of `matters × kinds`.
22+
*/
23+
const CHILD_MATTER_BATCH = 8
24+
25+
/** Concurrent child listings issued within one batch. */
26+
const CHILD_CONCURRENCY = 4
27+
28+
/**
29+
* Upper bound on child pages drained for a single matter/kind pair. Vault returns at
30+
* most 100 children per page, so this covers 5,000 holds or saved queries in one
31+
* matter; exceeding it marks the listing capped rather than looping unbounded.
32+
*/
33+
const MAX_CHILD_PAGES = 50
34+
1535
/**
1636
* Google Vault matter, as returned by `matters.list`/`matters.get` with `view=FULL`.
1737
* @see https://developers.google.com/workspace/vault/reference/rest/v1/matters
@@ -73,6 +93,10 @@ interface VaultQuery {
7393
accountInfo?: { emails?: string[] }
7494
orgUnitInfo?: { orgUnitId?: string }
7595
sharedDriveInfo?: { sharedDriveIds?: string[] }
96+
teamDriveInfo?: { teamDriveIds?: string[] }
97+
hangoutsChatInfo?: { roomId?: string[] }
98+
sitesUrlInfo?: { urls?: string[] }
99+
driveDocumentInfo?: { documentIds?: { ids?: string[] } }
76100
}
77101

78102
/**
@@ -96,6 +120,9 @@ type VaultChildKind = 'holds' | 'savedQueries'
96120
* A single sync interleaves two levels of pagination: a page of matters, then the
97121
* child resources of every matter on that page. The cursor carries the matter IDs of
98122
* the current page so a resumed call never has to re-derive them.
123+
*
124+
* Child pagination is fully drained inside one call per batch of matters, so the
125+
* cursor only has to remember how far through the matter page it has walked.
99126
*/
100127
interface VaultCursor {
101128
phase: 'matters' | 'children'
@@ -104,9 +131,8 @@ interface VaultCursor {
104131
/** Page token for the matters page that follows the one currently being walked. */
105132
nextMattersPageToken?: string
106133
matterIds?: string[]
134+
/** Index into `matterIds` of the first matter in the next batch to process. */
107135
matterIndex?: number
108-
kindIndex?: number
109-
childPageToken?: string
110136
}
111137

112138
function encodeCursor(cursor: VaultCursor): string {
@@ -249,7 +275,14 @@ function renderSavedQuery(savedQuery: VaultSavedQuery, matterId: string): string
249275
appendLine(lines, 'Time zone', query?.timeZone)
250276
appendLine(lines, 'Accounts', query?.accountInfo?.emails?.join(', '))
251277
appendLine(lines, 'Organizational unit', query?.orgUnitInfo?.orgUnitId)
252-
appendLine(lines, 'Shared drives', query?.sharedDriveInfo?.sharedDriveIds?.join(', '))
278+
appendLine(
279+
lines,
280+
'Shared drives',
281+
(query?.sharedDriveInfo?.sharedDriveIds ?? query?.teamDriveInfo?.teamDriveIds)?.join(', ')
282+
)
283+
appendLine(lines, 'Chat spaces', query?.hangoutsChatInfo?.roomId?.join(', '))
284+
appendLine(lines, 'Site URLs', query?.sitesUrlInfo?.urls?.join(', '))
285+
appendLine(lines, 'Drive documents', query?.driveDocumentInfo?.documentIds?.ids?.join(', '))
253286

254287
return lines.join('\n')
255288
}
@@ -312,6 +345,8 @@ function holdToDocument(hold: VaultHold, matterId: string): ExternalDocument {
312345
/**
313346
* Builds a saved query document. Saved queries are immutable once created (the API
314347
* exposes only create, get, list, and delete), so `createTime` identifies the version.
348+
* The `v2` token in the hash tracks the rendering itself: because the source can never
349+
* change, a rendering change would otherwise never re-index existing documents.
315350
*/
316351
function savedQueryToDocument(savedQuery: VaultSavedQuery, matterId: string): ExternalDocument {
317352
const savedQueryId = savedQuery.savedQueryId ?? ''
@@ -320,7 +355,7 @@ function savedQueryToDocument(savedQuery: VaultSavedQuery, matterId: string): Ex
320355
title: savedQuery.displayName || `Saved query ${savedQueryId}`,
321356
content: renderSavedQuery(savedQuery, matterId),
322357
mimeType: 'text/plain',
323-
contentHash: `gvault:savedquery:${matterId}:${savedQueryId}:${savedQuery.createTime ?? ''}`,
358+
contentHash: `gvault:savedquery:v2:${matterId}:${savedQueryId}:${savedQuery.createTime ?? ''}`,
324359
metadata: {
325360
resourceType: 'savedQuery',
326361
matterId,
@@ -409,32 +444,74 @@ async function fetchChildPage(
409444
}
410445

411446
/**
412-
* Computes the cursor state that follows the page just emitted: continue the current
413-
* child listing, advance to the next kind, the next matter, the next matters page, or
414-
* finish.
447+
* Drains every page of one matter's child listing of the given kind.
448+
*
449+
* `capped` is true when the listing was cut short — by the page bound or by a request
450+
* failure (a matter the caller cannot read, or a transient error). The caller turns
451+
* that into `syncContext.listingCapped` so deletion reconciliation is skipped for the
452+
* run rather than hard-deleting documents that still exist at the source.
415453
*/
416-
function advanceCursor(
417-
state: VaultCursor,
418-
kinds: VaultChildKind[],
419-
childPageToken?: string
420-
): VaultCursor | undefined {
421-
const matterIds = state.matterIds ?? []
422-
const matterIndex = state.matterIndex ?? 0
423-
const kindIndex = state.kindIndex ?? 0
424-
425-
if (childPageToken) {
426-
return { ...state, childPageToken }
427-
}
428-
if (kindIndex + 1 < kinds.length) {
429-
return { ...state, kindIndex: kindIndex + 1, childPageToken: undefined }
454+
async function fetchAllChildren(
455+
accessToken: string,
456+
matterId: string,
457+
kind: VaultChildKind
458+
): Promise<{ documents: ExternalDocument[]; capped: boolean }> {
459+
const documents: ExternalDocument[] = []
460+
let pageToken: string | undefined
461+
462+
try {
463+
for (let page = 0; page < MAX_CHILD_PAGES; page++) {
464+
const result = await fetchChildPage(accessToken, matterId, kind, pageToken)
465+
documents.push(...result.documents)
466+
if (!result.nextPageToken) return { documents, capped: false }
467+
pageToken = result.nextPageToken
468+
}
469+
} catch (error) {
470+
logger.warn(`Failed to list ${kind} for Vault matter ${matterId}`, {
471+
error: toError(error).message,
472+
})
473+
return { documents, capped: true }
430474
}
431-
if (matterIndex + 1 < matterIds.length) {
432-
return { ...state, matterIndex: matterIndex + 1, kindIndex: 0, childPageToken: undefined }
475+
476+
logger.warn(`Stopped listing ${kind} for Vault matter ${matterId} at the page bound`, {
477+
maxChildPages: MAX_CHILD_PAGES,
478+
})
479+
return { documents, capped: true }
480+
}
481+
482+
/**
483+
* Lists every enabled child resource for a batch of matters, bounded concurrency.
484+
*
485+
* One `listDocuments` call covers a whole batch, which keeps the number of calls a
486+
* sync needs proportional to the matter count rather than to `matters × kinds ×
487+
* child pages`.
488+
*/
489+
async function fetchChildrenForMatters(
490+
accessToken: string,
491+
matterIds: string[],
492+
kinds: VaultChildKind[]
493+
): Promise<{ documents: ExternalDocument[]; capped: boolean }> {
494+
const tasks: { matterId: string; kind: VaultChildKind }[] = []
495+
for (const matterId of matterIds) {
496+
for (const kind of kinds) tasks.push({ matterId, kind })
433497
}
434-
if (state.nextMattersPageToken) {
435-
return { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
498+
499+
const documents: ExternalDocument[] = []
500+
let capped = false
501+
502+
for (let index = 0; index < tasks.length; index += CHILD_CONCURRENCY) {
503+
const results = await Promise.all(
504+
tasks
505+
.slice(index, index + CHILD_CONCURRENCY)
506+
.map((task) => fetchAllChildren(accessToken, task.matterId, task.kind))
507+
)
508+
for (const result of results) {
509+
documents.push(...result.documents)
510+
if (result.capped) capped = true
511+
}
436512
}
437-
return undefined
513+
514+
return { documents, capped }
438515
}
439516

440517
export const googleVaultConnector: ConnectorConfig = {
@@ -469,53 +546,27 @@ export const googleVaultConnector: ConnectorConfig = {
469546
phase: 'children',
470547
matterIds,
471548
matterIndex: 0,
472-
kindIndex: 0,
473549
nextMattersPageToken: nextPageToken,
474550
}
475551
} else if (nextPageToken) {
476552
nextState = { phase: 'matters', mattersPageToken: nextPageToken }
477553
}
478554
} else {
479555
const matterIds = state.matterIds ?? []
480-
const matterIndex = state.matterIndex ?? 0
481-
const matterId = matterIds[matterIndex]
482-
const kind = kinds[state.kindIndex ?? 0]
483-
484-
if (!matterId || kinds.length === 0) {
485-
nextState = state.nextMattersPageToken
486-
? { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
487-
: undefined
488-
} else if (!kind) {
489-
/**
490-
* The enabled child kinds shrank while a cursor was in flight (the user
491-
* turned holds or saved queries off mid-sync), so the remaining children of
492-
* this matter are not listed. Skipping them silently would let deletion
493-
* reconciliation hard-delete still-existing documents.
494-
*/
495-
if (syncContext) syncContext.listingCapped = true
496-
nextState =
497-
matterIndex + 1 < matterIds.length
498-
? { ...state, matterIndex: matterIndex + 1, kindIndex: 0, childPageToken: undefined }
499-
: state.nextMattersPageToken
500-
? { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
501-
: undefined
502-
} else {
503-
try {
504-
const page = await fetchChildPage(accessToken, matterId, kind, state.childPageToken)
505-
pageDocuments = page.documents
506-
nextState = advanceCursor(state, kinds, page.nextPageToken)
507-
} catch (error) {
508-
/**
509-
* A matter the caller cannot read (or a transient failure) must not make the
510-
* engine believe those documents are gone — flag the listing as incomplete so
511-
* deletion reconciliation is skipped for this run, then continue.
512-
*/
513-
logger.warn(`Failed to list ${kind} for Vault matter ${matterId}`, {
514-
error: toError(error).message,
515-
})
516-
if (syncContext) syncContext.listingCapped = true
517-
nextState = advanceCursor(state, kinds, undefined)
518-
}
556+
const batchStart = state.matterIndex ?? 0
557+
const batch = matterIds.slice(batchStart, batchStart + CHILD_MATTER_BATCH)
558+
559+
if (batch.length > 0 && kinds.length > 0) {
560+
const children = await fetchChildrenForMatters(accessToken, batch, kinds)
561+
pageDocuments = children.documents
562+
if (children.capped && syncContext) syncContext.listingCapped = true
563+
}
564+
565+
const nextMatterIndex = batchStart + batch.length
566+
if (nextMatterIndex < matterIds.length && kinds.length > 0) {
567+
nextState = { ...state, matterIndex: nextMatterIndex }
568+
} else if (state.nextMattersPageToken) {
569+
nextState = { phase: 'matters', mattersPageToken: state.nextMattersPageToken }
519570
}
520571
}
521572

0 commit comments

Comments
 (0)