diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 34cd0761..849250d5 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -178,7 +178,9 @@ export class ScheduledTaskExecutor { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. - env, + // CLAUDECODE stripped last to prevent nested-session detection regardless + // of what buildProviderAwareCliEnv merges in. + env: { ...env, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 21ec5c63..32c19341 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise { let _resolvedNodePath: string | undefined; +export function clearResolvedNodePathForTests(): void { + _resolvedNodePath = undefined; +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 01b50283..cf98ab04 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + TeamMcpConfigBuilder, + clearResolvedNodePathForTests, +} from '@main/services/team/TeamMcpConfigBuilder'; describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; @@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => { entry: string ): void { expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function expectNodeTsxSourceEntry( @@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => { sourceEntry: string ): void { expect(server?.args).toEqual([tsxCli, sourceEntry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => { } beforeEach(() => { + clearResolvedNodePathForTests(); originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); createdDirs.push(tempAppData); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 1b5e8b7e..ab386b99 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -16,9 +16,16 @@ const hoisted = vi.hoisted(() => { error.code = 'ENOENT'; throw error; } + const size = Buffer.byteLength(data, 'utf8'); return { isFile: () => true, - size: Buffer.byteLength(data, 'utf8'), + size, + mode: 0o644, + dev: 0, + ino: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, }; }); @@ -54,22 +61,20 @@ const hoisted = vi.hoisted(() => { files.set(sentMessagesPath, JSON.stringify(rows)); return message; }), - sendInboxMessage: vi.fn( - (teamName: string, message: Record) => { - const member = - typeof message.member === 'string' - ? message.member - : typeof message.to === 'string' - ? message.to - : 'unknown'; - const p = `/mock/teams/${teamName}/inboxes/${member}.json`; - const current = files.get(p); - const rows = current ? (JSON.parse(current) as unknown[]) : []; - rows.push(message); - files.set(p, JSON.stringify(rows)); - return { deliveredToInbox: true, messageId: 'mock-id', message }; - } - ), + sendInboxMessage: vi.fn((teamName: string, message: Record) => { + const member = + typeof message.member === 'string' + ? message.member + : typeof message.to === 'string' + ? message.to + : 'unknown'; + const p = `/mock/teams/${teamName}/inboxes/${member}.json`; + const current = files.get(p); + const rows = current ? (JSON.parse(current) as unknown[]) : []; + rows.push(message); + files.set(p, JSON.stringify(rows)); + return { deliveredToInbox: true, messageId: 'mock-id', message }; + }), setAtomicWriteShouldFail: (next: boolean) => { atomicWriteShouldFail = next; }, @@ -371,7 +376,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: system_notification'); expect(payload).toContain('summary looks like \\"Comment on #...\\"'); - expect(payload).toContain('reply via task_add_comment only when you have a substantive board update'); + expect(payload).toContain( + 'reply via task_add_comment only when you have a substantive board update' + ); expect(payload).toContain('Do NOT post acknowledgement-only task comments'); (service as any).handleStreamJsonMessage(run, { @@ -492,9 +499,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -538,14 +549,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); const inboxDeferred = createDeferred<[typeof permissionMessage]>(); - const inboxReader = (service as unknown as { - inboxReader: { - getMessagesFor: ( - team: string, - member: string - ) => Promise<[typeof permissionMessage]>; - }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -654,7 +664,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: cross_team'); expect(payload).toContain('Cross-team conversationId: conv-explicit'); - expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain( + 'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"' + ); expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); @@ -905,7 +917,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { attachAliveRun(service, teamName); const run = (service as unknown as { runs: Map }).runs.get('run-1') as { - silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null; + silentUserDmForward: { + target: string; + startedAt: string; + mode: 'user_dm' | 'member_inbox_relay'; + } | null; }; run.silentUserDmForward = { target: 'alice', @@ -1072,9 +1088,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -1284,11 +1304,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { await (service as any).markInboxMessagesRead(teamName, 'alice', [ { - messageId: buildLegacyInboxMessageId( - legacyRow.from, - legacyRow.timestamp, - legacyRow.text - ), + messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text), }, ]); @@ -1684,9 +1700,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -1732,9 +1746,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { failed: 0, lastDelivery: { delivered: true, responsePending: true }, }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -1866,9 +1878,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-terminal-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); @@ -1952,9 +1962,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_attachments_not_supported_for_secondary_runtime' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); expect(records[0]).toMatchObject({ inboxMessageId: 'opencode-attachment-1', @@ -1979,7 +1987,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ], }) ); - const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack'); + const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity( + teamName, + 'jack' + ); expect(identity.ok).toBe(true); const laneId = identity.laneId; const records: any[] = []; @@ -2000,7 +2011,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { return record; }), markAcceptanceUnknown: vi.fn( - async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => { + async (input: { + id: string; + reason: string; + nextAttemptAt: string; + markedAt: string; + }) => { const record = records.find((candidate) => candidate.id === input.id); Object.assign(record, { status: 'failed_retryable', @@ -2132,9 +2148,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-inflight-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); }); @@ -2211,9 +2225,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -2303,9 +2315,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'OpenCode inbox relay failed for jack/opencode-relay-failed-1' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -2337,9 +2347,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { delivered: true, diagnostics: [], }); - vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( - new Error('write failed') - ); + vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed')); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); @@ -2360,9 +2368,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_inbox_mark_read_failed_after_delivery' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); });