Skip to content

Commit c660556

Browse files
khaliqgantclaudeagent-relay-code[bot]
authored
fix(slack): local-disk fallback for event preview + suffixed path emission (#155)
* fix(slack): local-disk fallback for event preview + suffixed path emission readEventContextPreview() was SDK-read-only (client.readFile), which missed the suffixed mount candidate under remote-consistency lag even when the file was already on local disk. Events therefore emitted bare-path + "content unavailable" on a current build — #154 fixed candidate generation, not the read source. - Add local-disk fallback scoped to matched concrete mount roots only (no broad .integrations scan; preserves DM "watched-but-not-mounted" state) - Emit the resolved suffixed path for visible Path:, data.path, and data.resource.path (closes the structured-metadata-stays-bare gap) - Regression for bare event path + suffixed local mount + SDK read miss Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: apply pr-reviewer fixes for #155 * chore: apply pr-reviewer fixes for #155 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com>
1 parent d2449ff commit c660556

2 files changed

Lines changed: 278 additions & 15 deletions

File tree

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import assert from 'node:assert/strict'
2+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
24
import { join } from 'node:path'
35
import { beforeEach, test } from 'node:test'
46

@@ -873,6 +875,159 @@ test('slack raw-id event resolves context through mounted slug alias', async ()
873875
assert.match(harness.sent[1].input.text, /Message:\nedited Slack message/u)
874876
})
875877

878+
test('slack raw-id event falls back to matched local suffixed mount when remote read misses', async () => {
879+
const tempRoot = await mkdtemp(join(tmpdir(), 'pear-slack-local-context-'))
880+
const localRoot = join(tempRoot, 'workspace-id', 'slack', 'channels', 'C123ABC__proj-cloud')
881+
const remotePath = '/slack/channels/C123ABC/messages/1780668000_000000/meta.json'
882+
const localRemotePath = '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json'
883+
884+
try {
885+
await mkdir(join(localRoot, 'messages', '1780668000_000000'), { recursive: true })
886+
await writeFile(
887+
join(localRoot, 'messages', '1780668000_000000', 'meta.json'),
888+
JSON.stringify({ provider: 'slack', text: 'local mounted Slack message' })
889+
)
890+
const harness = makeHarness(['alice'], {
891+
failReadFile: true
892+
})
893+
894+
await withMockedNow('2026-06-05T14:00:00.000Z', async () => {
895+
await harness.bridge.reconcile('project-1', [
896+
integration({
897+
provider: 'slack',
898+
integrationId: 'slack-1',
899+
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
900+
localMountPaths: [localRoot],
901+
downloadHistoricalData: false,
902+
scope: { notifyAgents: ['alice'] }
903+
})
904+
])
905+
})
906+
907+
await harness.emit({
908+
...changeEvent(
909+
remotePath,
910+
'slack',
911+
{ digest: 'revision:raw-copy' }
912+
),
913+
expand: async () => ({
914+
level: 'full',
915+
path: remotePath,
916+
data: {
917+
path: remotePath,
918+
deleted: false
919+
}
920+
})
921+
} as ChangeEvent)
922+
await waitForSent(harness, 1, 2_500)
923+
924+
assert.match(harness.sent[0].input.text, /Message:\nlocal mounted Slack message/u)
925+
assert.match(harness.sent[0].input.text, /Path: \.integrations\/slack\/channels\/C123ABC__proj-cloud\/messages\/1780668000_000000\/meta\.json/u)
926+
assert.equal(harness.sent[0].input.data?.path, localRemotePath)
927+
assert.deepEqual(
928+
(harness.sent[0].input.data?.resource as { path?: string } | undefined)?.path,
929+
localRemotePath
930+
)
931+
assert.equal((harness.sent[0].input.data?.contextPreview as { path?: string } | undefined)?.path, localRemotePath)
932+
assert.deepEqual(harness.readFileCalls.slice(0, 2), [
933+
{
934+
workspaceId: 'workspace-id',
935+
path: remotePath
936+
},
937+
{
938+
workspaceId: 'workspace-id',
939+
path: localRemotePath
940+
}
941+
])
942+
943+
await harness.emit(changeEvent(
944+
localRemotePath,
945+
'slack',
946+
{ digest: 'revision:slug-copy' }
947+
))
948+
await waitForDropped('project-1', 1, 2_500)
949+
950+
assert.equal(harness.sent.length, 1)
951+
assert.deepEqual(harness.readFileCalls.slice(8, 10), [
952+
{
953+
workspaceId: 'workspace-id',
954+
path: localRemotePath
955+
},
956+
{
957+
workspaceId: 'workspace-id',
958+
path: remotePath
959+
}
960+
])
961+
} finally {
962+
await rm(tempRoot, { recursive: true, force: true })
963+
}
964+
})
965+
966+
test('slack local context fallback rejects traversal outside matched mount root', async () => {
967+
const tempRoot = await mkdtemp(join(tmpdir(), 'pear-slack-local-traversal-'))
968+
const localRoot = join(tempRoot, 'workspace-id', 'slack', 'channels', 'C123ABC__proj-cloud')
969+
const escapedRoot = join(tempRoot, 'workspace-id', 'slack', 'leaked')
970+
const remotePath = '/slack/channels/C123ABC/messages/1780668000_000000/../../../../leaked/meta.json'
971+
const localRemotePath = '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/../../../../leaked/meta.json'
972+
973+
try {
974+
await mkdir(escapedRoot, { recursive: true })
975+
await writeFile(
976+
join(escapedRoot, 'meta.json'),
977+
JSON.stringify({ provider: 'slack', text: 'escaped Slack message' })
978+
)
979+
const harness = makeHarness(['alice'], {
980+
failReadFile: true
981+
})
982+
983+
await withMockedNow('2026-06-05T14:00:00.000Z', async () => {
984+
await harness.bridge.reconcile('project-1', [
985+
integration({
986+
provider: 'slack',
987+
integrationId: 'slack-1',
988+
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
989+
localMountPaths: [localRoot],
990+
downloadHistoricalData: false,
991+
scope: { notifyAgents: ['alice'] }
992+
})
993+
])
994+
})
995+
996+
await harness.emit({
997+
...changeEvent(
998+
remotePath,
999+
'slack',
1000+
{ digest: 'revision:traversal-copy' }
1001+
),
1002+
expand: async () => ({
1003+
level: 'full',
1004+
path: remotePath,
1005+
data: {
1006+
path: remotePath,
1007+
deleted: false
1008+
}
1009+
})
1010+
} as ChangeEvent)
1011+
await waitForSent(harness, 1, 2_500)
1012+
1013+
assert.doesNotMatch(harness.sent[0].input.text, /escaped Slack message/u)
1014+
assert.match(harness.sent[0].input.text, /Message: unavailable/u)
1015+
assert.equal(harness.sent[0].input.data?.contextPreview, undefined)
1016+
assert.deepEqual(harness.readFileCalls.slice(0, 2), [
1017+
{
1018+
workspaceId: 'workspace-id',
1019+
path: remotePath
1020+
},
1021+
{
1022+
workspaceId: 'workspace-id',
1023+
path: localRemotePath
1024+
}
1025+
])
1026+
} finally {
1027+
await rm(tempRoot, { recursive: true, force: true })
1028+
}
1029+
})
1030+
8761031
test('slack unchanged-content replay re-drives after injected delivery is not confirmed', async () => {
8771032
const options = { failInjected: true }
8781033
const harness = makeHarness(['alice'], options)

0 commit comments

Comments
 (0)