fix(discord): report a refused post as a failure, bound the file probe, drop threads of a previous channel

- codeman notify exits 1 when the route answers 200 with data.ok false
- notify request times out at 30 s instead of hanging
- fileProblem probes the file with the bounded probe before realpath
- the thread map records its parent channel; a new channel starts afresh
- threadFor returns its failure instead of sharing this.last
This commit is contained in:
Devvyn committed 2026-10-30 04:58:17 +08:00
1 parent 0db9b55961
commit 1ab54b9e9b
7 files changed
+142 -29

No files matched your search

+13 -2
View File
@@ -10,8 +10,8 @@ import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
function fake(status: number, error?: string) {
const post = vi.fn<JsonPost>(async () => ({ status, error }));
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
return post;
}
@@ -47,6 +47,17 @@ describe('runNotify', () => {
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
});
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
const r = await runNotify(
{ message: 'x' },
ENV,
'/w',
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
);
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
});
it('treats a disabled bridge (409) as success that says so', async () => {
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
expect(r.code).toBe(0);
+44
View File
@@ -477,6 +477,50 @@ describe('DiscordBridge', () => {
});
});
it('starts afresh when the parent channel changes: tabs get a thread in the new channel', async () => {
const b = bridge();
await b.notify(MSG);
expect(await readThreadMap(dir)).toEqual({ s1: THREAD });
posts.length = 0;
await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' });
await b.reload();
await b.notify({ ...MSG, event: 'hook:idle_prompt' });
expect(posts.find((p) => p.path.endsWith('/threads'))?.path).toBe('/channels/777777777777777777/threads');
b.stop();
});
it('keeps the threads across a restart under the same parent channel', async () => {
const first = bridge();
await first.notify(MSG);
first.stop();
posts.length = 0;
const second = bridge();
await second.reload();
await second.notify({ ...MSG, event: 'hook:idle_prompt' });
expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(false);
second.stop();
});
it('a failed thread creation reports its own failure even if another post succeeds meanwhile', async () => {
let n = 0;
const b = bridge({}, async (url) => {
if (String(url).endsWith('/threads')) {
await new Promise((r) => setTimeout(r, 20));
return new Response('{}', { status: 403 });
}
n++;
return jsonRes({ id: 'msg' });
});
const [failed, ok] = await Promise.all([
b.notify(MSG),
b.notify({ ...MSG, sessionId: undefined, event: 'session:error' }),
]);
expect(n).toBeGreaterThan(0);
expect(ok?.ok).toBe(true);
expect(failed).toMatchObject({ ok: false, status: 403 });
b.stop();
});
describe('sendTest', () => {
it('posts with the config it is given and leaves the live config alone', async () => {
const auth: string[] = [];
+17
View File
@@ -175,6 +175,16 @@ describe('POST /api/sessions/:id/discord-notify', () => {
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
@@ -256,6 +266,13 @@ describe('discord-notify with a file', () => {
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));