Skip to content

Commit a96c9d0

Browse files
kjgbotkjgbotagent-relay-code[bot]
authored
Keep integration streams alive across token refresh (#120)
* adopt explicit relayfile mount contract * share Slack writeback command root grammar * chore: apply pr-reviewer fixes for #117 * Keep integration event streams on refreshable tokens * chore: apply pr-reviewer fixes for #120 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev> Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com>
1 parent 2051dd1 commit a96c9d0

7 files changed

Lines changed: 77 additions & 26 deletions

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"@agent-relay/sdk": "^8.1.2",
3030
"@agentworkforce/deploy": "^3.0.50",
3131
"@relayburn/sdk": "^3.2.0",
32-
"@relayfile/sdk": "^0.8.10",
32+
"@relayfile/sdk": "^0.8.11",
3333
"@xterm/addon-fit": "^0.10.0",
3434
"@xterm/addon-web-links": "^0.11.0",
3535
"@xterm/addon-webgl": "^0.18.0",

src/main/__tests__/integration-event-bridge.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
IntegrationEventBridge,
99
createWorkspaceScopedEventClient,
1010
integrationSubscriptionSummaries,
11+
integrationRelayFileSyncOptions,
1112
localWatchEventPathsForFilename,
1213
localWatchRootsFor,
1314
relayfileSdkPathFiltersFor,
@@ -271,6 +272,20 @@ test('relayfile sdk path filters broaden partial-segment Slack DM globs', () =>
271272
])
272273
})
273274

275+
test('integration event remote stream keeps a refreshable relayfile token provider', () => {
276+
const tokenProvider = async () => 'workspace-token'
277+
const options = integrationRelayFileSyncOptions({
278+
client: {} as never,
279+
workspaceId: 'workspace-id',
280+
baseUrl: 'https://relayfile.example',
281+
tokenProvider,
282+
from: 'legacy',
283+
paths: ['/slack/channels/*/**']
284+
})
285+
286+
assert.equal(options.token, tokenProvider)
287+
})
288+
274289
test('integration event remote stream falls back to event feed polling after repeated stream errors', async () => {
275290
const syncs: FakeRelayFileSync[] = []
276291
const received: ChangeEvent[] = []

src/main/integration-event-bridge.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ type RelayfileWorkspaceHandle = {
184184
type TokenProvider = () => Promise<string | undefined>
185185
type RelayFileSyncFactory = (options: RelayFileSyncOptions) => RelayFileSync
186186

187+
type IntegrationRelayFileSyncOptionsInput = Omit<RelayFileSyncOptions, 'token'> & {
188+
tokenProvider: TokenProvider
189+
}
190+
187191
type IntegrationEventBridgeDeps = {
188192
broker?: BrokerEventBridge
189193
getWorkspaceHandle?: () => Promise<RelayfileWorkspaceHandle>
@@ -264,6 +268,16 @@ export function resetIntegrationEventTelemetryForTests(): void {
264268
aggregatedWarnings.clear()
265269
}
266270

271+
export function integrationRelayFileSyncOptions(
272+
input: IntegrationRelayFileSyncOptionsInput
273+
): RelayFileSyncOptions {
274+
const { tokenProvider, ...options } = input
275+
return {
276+
...options,
277+
token: tokenProvider
278+
}
279+
}
280+
267281
function toErrorMessage(error: unknown): string {
268282
if (error instanceof Error) {
269283
const message = error.message.trim()
@@ -274,21 +288,21 @@ function toErrorMessage(error: unknown): string {
274288
return message || 'empty string error'
275289
}
276290
if (isRecord(error)) {
277-
const message = error.message
291+
const record = error as Record<string, unknown>
292+
const message = record.message
278293
if (typeof message === 'string' && message.trim()) return message.trim()
279-
const reason = error.reason
294+
const reason = record.reason
280295
if (typeof reason === 'string' && reason.trim()) return reason.trim()
281296
const parts = [
282-
typeof error.name === 'string' && error.name.trim() ? error.name.trim() : null,
283-
typeof error.type === 'string' && error.type.trim() ? `type=${error.type.trim()}` : null,
284-
typeof error.code === 'string' && error.code.trim() ? `code=${error.code.trim()}` : typeof error.code === 'number' ? `code=${error.code}` : null,
285-
typeof error.status === 'string' && error.status.trim() ? `status=${error.status.trim()}` : typeof error.status === 'number' ? `status=${error.status}` : null,
286-
typeof error.statusCode === 'string' && error.statusCode.trim() ? `statusCode=${error.statusCode.trim()}` : typeof error.statusCode === 'number' ? `statusCode=${error.statusCode}` : null,
287-
typeof error.httpStatus === 'string' && error.httpStatus.trim() ? `httpStatus=${error.httpStatus.trim()}` : typeof error.httpStatus === 'number' ? `httpStatus=${error.httpStatus}` : null
297+
typeof record.name === 'string' && record.name.trim() ? record.name.trim() : null,
298+
typeof record.type === 'string' && record.type.trim() ? `type=${record.type.trim()}` : null,
299+
typeof record.code === 'string' && record.code.trim() ? `code=${record.code.trim()}` : typeof record.code === 'number' ? `code=${record.code}` : null,
300+
typeof record.status === 'string' && record.status.trim() ? `status=${record.status.trim()}` : typeof record.status === 'number' ? `status=${record.status}` : null,
301+
typeof record.statusCode === 'string' && record.statusCode.trim() ? `statusCode=${record.statusCode.trim()}` : typeof record.statusCode === 'number' ? `statusCode=${record.statusCode}` : null,
302+
typeof record.httpStatus === 'string' && record.httpStatus.trim() ? `httpStatus=${record.httpStatus.trim()}` : typeof record.httpStatus === 'number' ? `httpStatus=${record.httpStatus}` : null
288303
].filter((entry): entry is string => entry !== null)
289304
if (parts.length > 0) return parts.join(' ')
290-
291-
const constructorName = (error as { constructor?: { name?: string } }).constructor?.name
305+
const constructorName = record.constructor?.name
292306
if (constructorName && constructorName !== 'Object') return constructorName
293307

294308
try {
@@ -770,11 +784,11 @@ export function createWorkspaceScopedEventClient(
770784
from: options?.from ?? 'now',
771785
transport: baseUrl ? 'websocket' : 'polling'
772786
})
773-
sync = syncFactory({
787+
sync = syncFactory(integrationRelayFileSyncOptions({
774788
client,
775789
workspaceId,
776790
baseUrl,
777-
token,
791+
tokenProvider,
778792
from: options?.from ?? 'now',
779793
paths: relayfilePathFilters,
780794
onPollingFallback: (info) => {
@@ -787,7 +801,7 @@ export function createWorkspaceScopedEventClient(
787801
}
788802
)
789803
}
790-
})
804+
}))
791805
sync.on('event', handleEvent)
792806
sync.on('state', (state) => {
793807
if (state === 'open') consecutiveStreamErrors = 0

src/main/integration-mounts.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,19 @@ describe('IntegrationMountManager', () => {
271271
})
272272
})
273273

274+
it('rejects Slack command roots with traversal segments', async () => {
275+
const manager = new IntegrationMountManager()
276+
277+
await manager.ensureMounted([
278+
{
279+
provider: 'slack',
280+
mountPaths: ['/slack/channels/C123/../messages']
281+
}
282+
])
283+
284+
expect(mock.mountInputs).toHaveLength(0)
285+
})
286+
274287
it('scopes bare discovery mounts to the integration provider', async () => {
275288
const manager = new IntegrationMountManager()
276289

src/main/integration-mounts.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ function remotePathSegments(remotePath: string): string[] {
5959
.map(sanitizePathSegment)
6060
}
6161

62+
function hasTraversalPathSegment(remotePath: string): boolean {
63+
return remotePath
64+
.split(/[\\/]+/u)
65+
.map((segment) => segment.trim())
66+
.some((segment) => segment === '.' || segment === '..')
67+
}
68+
6269
export function integrationMountRootForWorkspace(workspaceId: string): string {
6370
return integrationMountWorkspaceRoot(workspaceId)
6471
}
@@ -88,6 +95,7 @@ export function integrationProviderRoot(mountPath: string): string | null {
8895
}
8996

9097
function canonicalIntegrationMountPath(mountPath: string, provider: string): string | null {
98+
if (hasTraversalPathSegment(mountPath)) return null
9199
const providerSegment = sanitizePathSegment(provider.trim().toLowerCase())
92100
const segments = remotePathSegments(mountPath)
93101
if (segments[0] === 'discovery') {
@@ -361,7 +369,8 @@ export class IntegrationMountManager {
361369
}
362370

363371
private createContractLauncher(spec: IntegrationMountSpec, launcher: MountLauncher): MountLauncher {
364-
return Object.assign({
372+
return {
373+
...launcher,
365374
start: (input: MountLauncherStart) => launcher.start({
366375
...input,
367376
env: {
@@ -370,9 +379,7 @@ export class IntegrationMountManager {
370379
RELAYFILE_MOUNT_SYNC_MODE: spec.syncMode
371380
}
372381
})
373-
}, {
374-
__options: (launcher as { __options?: unknown }).__options
375-
}) as MountLauncher
382+
}
376383
}
377384

378385
private async removeLegacyIntegrationMountRoot(mountRoot: string): Promise<void> {

src/main/slack-writeback-command-roots.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const SLACK_WRITEBACK_COLLECTIONS = new Set(['channels', 'dms', 'users'])
22

33
function normalizeRemotePath(path: string): string | null {
4+
if (typeof path !== 'string') return null
45
const segments = path
56
.trim()
67
.split(/[\\/]+/)
@@ -10,6 +11,7 @@ function normalizeRemotePath(path: string): string | null {
1011
}
1112

1213
function isSlackProvider(provider: string): boolean {
14+
if (typeof provider !== 'string') return false
1315
const normalized = provider.trim().toLowerCase()
1416
return normalized === 'slack' || normalized.startsWith('slack-')
1517
}

0 commit comments

Comments
 (0)