kimaki 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -0
- package/dist/cloudflare-transcription.js +56 -0
- package/dist/commands/model-variant.js +17 -1
- package/dist/commands/queue.js +64 -0
- package/dist/config.js +9 -0
- package/dist/discord-bot.js +1 -5
- package/dist/discord-command-registration.js +17 -12
- package/dist/external-directory-permissions.e2e.test.js +82 -0
- package/dist/external-directory-project-deny.e2e.test.js +68 -0
- package/dist/format-tables.js +53 -5
- package/dist/format-tables.test.js +121 -0
- package/dist/interaction-handler.js +8 -6
- package/dist/message-preprocessing.js +2 -25
- package/dist/opencode.js +126 -96
- package/dist/queue-advanced-e2e-setup.js +63 -27
- package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +5 -20
- package/dist/store.js +1 -0
- package/dist/voice-handler.js +37 -11
- package/package.json +3 -3
- package/src/cli.ts +11 -0
- package/src/cloudflare-transcription.ts +76 -0
- package/src/commands/model-variant.ts +26 -0
- package/src/commands/queue.ts +83 -0
- package/src/config.ts +10 -0
- package/src/discord-bot.ts +0 -5
- package/src/discord-command-registration.ts +22 -15
- package/src/external-directory-permissions.e2e.test.ts +99 -0
- package/src/external-directory-project-deny.e2e.test.ts +82 -0
- package/src/format-tables.test.ts +126 -0
- package/src/format-tables.ts +68 -5
- package/src/interaction-handler.ts +10 -5
- package/src/message-preprocessing.ts +2 -37
- package/src/opencode.ts +132 -111
- package/src/queue-advanced-e2e-setup.ts +78 -29
- package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
- package/src/session-handler/thread-runtime-state.ts +0 -4
- package/src/session-handler/thread-session-runtime.ts +3 -25
- package/src/store.ts +10 -0
- package/src/voice-handler.ts +37 -10
- package/dist/channel-reference-permissions.e2e.test.js +0 -87
- package/dist/commands/add-dir.js +0 -169
- package/dist/commands/add-dir.test.js +0 -126
- package/src/channel-reference-permissions.e2e.test.ts +0 -104
- package/src/commands/add-dir.test.ts +0 -154
- package/src/commands/add-dir.ts +0 -244
package/dist/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ cli
|
|
|
39
39
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
40
40
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
41
41
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
42
|
+
.option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
|
|
42
43
|
.option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
|
|
43
44
|
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
44
45
|
.option('--no-analytics', 'Disable anonymous product analytics (Strada). Same as KIMAKI_STRADA_ENABLED=0')
|
|
@@ -155,6 +156,7 @@ cli
|
|
|
155
156
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
156
157
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
157
158
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
159
|
+
...(options.restrictDirectories && { restrictExternalDirectories: true }),
|
|
158
160
|
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
159
161
|
...(options.noAutoUpgrade && { autoUpgradeEnabled: false }),
|
|
160
162
|
...(options.disableSync && { syncEnabled: false }),
|
|
@@ -171,6 +173,9 @@ cli
|
|
|
171
173
|
if (options.allowAllUsers) {
|
|
172
174
|
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
173
175
|
}
|
|
176
|
+
if (options.restrictDirectories) {
|
|
177
|
+
cliLogger.log('Restricted directories: the agent asks before reading outside the working directory');
|
|
178
|
+
}
|
|
174
179
|
if (permissionTimeoutMs !== undefined) {
|
|
175
180
|
cliLogger.log(`Permission timeout set to ${options.permissionTimeoutMinutes} minutes`);
|
|
176
181
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Free Whisper transcription fallback via the kimaki.dev Cloudflare Worker.
|
|
2
|
+
// Only usable in gateway mode: the worker authenticates the request against
|
|
3
|
+
// the shared gateway_clients Postgres table using the CLI's own
|
|
4
|
+
// clientId:clientSecret pair (the same credentials already used for
|
|
5
|
+
// gateway-proxy REST/WebSocket calls), then runs
|
|
6
|
+
// @cf/openai/whisper-large-v3-turbo on Cloudflare's own Workers AI account.
|
|
7
|
+
// This means transcription costs Kimaki, never the user, so voice messages
|
|
8
|
+
// work out of the box for gateway-mode installs with no OpenAI/Gemini key
|
|
9
|
+
// configured.
|
|
10
|
+
//
|
|
11
|
+
// See website/src/server.tsx POST /api/transcribe for the server side.
|
|
12
|
+
// Rate limits (per-client burst + daily request cap) are enforced there,
|
|
13
|
+
// not here — a 429 from the worker just means this fallback is unavailable
|
|
14
|
+
// right now and the caller should fall back to the "add API key" dialog.
|
|
15
|
+
import { TranscriptionError } from './errors.js';
|
|
16
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
17
|
+
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
18
|
+
const KIMAKI_TRANSCRIBE_URL = process.env.KIMAKI_TRANSCRIBE_URL || 'https://kimaki.dev/api/transcribe';
|
|
19
|
+
export async function transcribeViaKimakiGateway({ audio, mediaType, clientId, clientSecret, }) {
|
|
20
|
+
const response = await fetch(KIMAKI_TRANSCRIBE_URL, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${clientId}:${clientSecret}`,
|
|
24
|
+
'Content-Type': mediaType || 'audio/ogg',
|
|
25
|
+
},
|
|
26
|
+
body: audio,
|
|
27
|
+
}).catch((cause) => {
|
|
28
|
+
return new TranscriptionError({
|
|
29
|
+
reason: 'Failed to reach kimaki.dev transcription endpoint',
|
|
30
|
+
cause,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
if (response instanceof TranscriptionError)
|
|
34
|
+
return response;
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const bodyText = await response.text().catch(() => '');
|
|
37
|
+
voiceLogger.log(`Kimaki gateway transcription failed: ${response.status} ${bodyText}`);
|
|
38
|
+
return new TranscriptionError({
|
|
39
|
+
reason: `kimaki.dev returned ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const data = (await response.json().catch((cause) => {
|
|
43
|
+
return new TranscriptionError({
|
|
44
|
+
reason: 'Failed to parse transcription response',
|
|
45
|
+
cause,
|
|
46
|
+
});
|
|
47
|
+
}));
|
|
48
|
+
if (data instanceof TranscriptionError)
|
|
49
|
+
return data;
|
|
50
|
+
if (!data.text) {
|
|
51
|
+
return new TranscriptionError({
|
|
52
|
+
reason: 'Empty transcription response from kimaki gateway',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return data.text;
|
|
56
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Cross-menu state: Discord doesn't expose already-selected values on sibling
|
|
7
7
|
// select menus in the same message. We track partial selections in the context
|
|
8
8
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
9
|
-
import { StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
12
12
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -39,6 +39,22 @@ function formatSourceLabel(info) {
|
|
|
39
39
|
return 'none';
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/** /model-variant slash command entrypoint — defers reply then delegates to the shared picker. */
|
|
43
|
+
export async function handleModelVariantCommand({ interaction, appId, }) {
|
|
44
|
+
await interaction.deferReply();
|
|
45
|
+
const channel = interaction.channel;
|
|
46
|
+
if (!channel) {
|
|
47
|
+
await interaction.editReply({
|
|
48
|
+
content: 'This command can only be used in a channel',
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await showModelVariantPicker({
|
|
53
|
+
channel,
|
|
54
|
+
appId,
|
|
55
|
+
editReply: (options) => interaction.editReply(options),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
42
58
|
export async function showModelVariantPicker({ channel, appId, editReply, }) {
|
|
43
59
|
const isThread = [
|
|
44
60
|
ChannelType.PublicThread,
|
package/dist/commands/queue.js
CHANGED
|
@@ -114,6 +114,70 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
114
114
|
flags: SILENT_MESSAGE_FLAGS,
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
export async function handleClearQueueCommand({ command, }) {
|
|
118
|
+
const channel = command.channel;
|
|
119
|
+
const position = command.options.getInteger('position') ?? undefined;
|
|
120
|
+
if (!channel) {
|
|
121
|
+
await command.reply({
|
|
122
|
+
content: 'This command can only be used in a channel',
|
|
123
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const isThread = [
|
|
128
|
+
ChannelType.PublicThread,
|
|
129
|
+
ChannelType.PrivateThread,
|
|
130
|
+
ChannelType.AnnouncementThread,
|
|
131
|
+
].includes(channel.type);
|
|
132
|
+
if (!isThread) {
|
|
133
|
+
await command.reply({
|
|
134
|
+
content: 'This command can only be used in a thread',
|
|
135
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const runtime = getRuntime(channel.id);
|
|
140
|
+
const queueLength = runtime?.getQueueLength() ?? 0;
|
|
141
|
+
if (queueLength === 0) {
|
|
142
|
+
await command.reply({
|
|
143
|
+
content: 'No messages in queue',
|
|
144
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (position !== undefined) {
|
|
149
|
+
const removed = runtime?.removeQueuePosition(position);
|
|
150
|
+
if (!removed) {
|
|
151
|
+
await command.reply({
|
|
152
|
+
content: `No queued message at position ${position}`,
|
|
153
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await command.reply({
|
|
158
|
+
content: `Cleared queued message at position ${position}`,
|
|
159
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
160
|
+
});
|
|
161
|
+
logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const cleared = runtime?.clearQueue() ?? [];
|
|
165
|
+
const lines = cleared.map((item, i) => {
|
|
166
|
+
const label = item.command
|
|
167
|
+
? `/${item.command.name}`
|
|
168
|
+
: item.prompt;
|
|
169
|
+
return `${i + 1}. ${label}`;
|
|
170
|
+
});
|
|
171
|
+
let list = lines.join('\n');
|
|
172
|
+
if (list.length > 600) {
|
|
173
|
+
list = list.slice(0, 597) + '...';
|
|
174
|
+
}
|
|
175
|
+
await command.reply({
|
|
176
|
+
content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
|
|
177
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
178
|
+
});
|
|
179
|
+
logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
|
|
180
|
+
}
|
|
117
181
|
export async function handleQueueCommandCommand({ command, appId, }) {
|
|
118
182
|
const commandName = command.options.getString('command', true);
|
|
119
183
|
const args = command.options.getString('arguments') || '';
|
package/dist/config.js
CHANGED
|
@@ -70,6 +70,15 @@ export function setProjectsDir(dir) {
|
|
|
70
70
|
export function getPermissionTimeoutMs() {
|
|
71
71
|
return store.getState().permissionTimeoutMs;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether external directory access is restricted to the session working
|
|
75
|
+
* directory plus a few known-safe paths.
|
|
76
|
+
* Defaults to false: every directory is allowed and users protect specific
|
|
77
|
+
* folders with their own `deny`/`ask` rules in opencode.json.
|
|
78
|
+
*/
|
|
79
|
+
export function getRestrictExternalDirectories() {
|
|
80
|
+
return store.getState().restrictExternalDirectories;
|
|
81
|
+
}
|
|
73
82
|
const DEFAULT_LOCK_PORT = 29988;
|
|
74
83
|
/**
|
|
75
84
|
* Derive a lock port from the data directory path.
|
package/dist/discord-bot.js
CHANGED
|
@@ -20,7 +20,7 @@ import { getFileAttachments, getTextAttachments, resolveMentions, } from './mess
|
|
|
20
20
|
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
21
21
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
22
22
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
23
|
-
import { extractQueueSuffix,
|
|
23
|
+
import { extractQueueSuffix, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
24
24
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
25
25
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
26
26
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
@@ -1088,12 +1088,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1088
1088
|
}
|
|
1089
1089
|
: undefined,
|
|
1090
1090
|
preprocess: async () => {
|
|
1091
|
-
const permissionRules = await getChannelReferencePermissionRules({
|
|
1092
|
-
message: starterMessage,
|
|
1093
|
-
});
|
|
1094
1091
|
return {
|
|
1095
1092
|
prompt,
|
|
1096
|
-
permissionRules,
|
|
1097
1093
|
mode: 'opencode',
|
|
1098
1094
|
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1099
1095
|
};
|
|
@@ -167,18 +167,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
167
167
|
})
|
|
168
168
|
.setDMPermission(false)
|
|
169
169
|
.toJSON(),
|
|
170
|
-
new SlashCommandBuilder()
|
|
171
|
-
.setName('add-dir')
|
|
172
|
-
.setDescription(truncateCommandDescription('Allow the current session to access an extra directory or * for all folders'))
|
|
173
|
-
.addStringOption((option) => {
|
|
174
|
-
option
|
|
175
|
-
.setName('directory')
|
|
176
|
-
.setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
|
|
177
|
-
.setRequired(false);
|
|
178
|
-
return option;
|
|
179
|
-
})
|
|
180
|
-
.setDMPermission(false)
|
|
181
|
-
.toJSON(),
|
|
182
170
|
new SlashCommandBuilder()
|
|
183
171
|
.setName('abort')
|
|
184
172
|
.setDescription(truncateCommandDescription('Abort, stop, terminate, or cancel the current OpenCode request in this thread'))
|
|
@@ -226,6 +214,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
226
214
|
.setDescription(truncateCommandDescription('Set model preference, change thinking level, or clear overrides'))
|
|
227
215
|
.setDMPermission(false)
|
|
228
216
|
.toJSON(),
|
|
217
|
+
new SlashCommandBuilder()
|
|
218
|
+
.setName('model-variant')
|
|
219
|
+
.setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
|
|
220
|
+
.setDMPermission(false)
|
|
221
|
+
.toJSON(),
|
|
229
222
|
new SlashCommandBuilder()
|
|
230
223
|
.setName('login')
|
|
231
224
|
.setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
|
|
@@ -248,6 +241,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
248
241
|
})
|
|
249
242
|
.setDMPermission(false)
|
|
250
243
|
.toJSON(),
|
|
244
|
+
new SlashCommandBuilder()
|
|
245
|
+
.setName('clear-queue')
|
|
246
|
+
.setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
|
|
247
|
+
.addIntegerOption((option) => {
|
|
248
|
+
option
|
|
249
|
+
.setName('position')
|
|
250
|
+
.setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
|
|
251
|
+
.setMinValue(1);
|
|
252
|
+
return option;
|
|
253
|
+
})
|
|
254
|
+
.setDMPermission(false)
|
|
255
|
+
.toJSON(),
|
|
251
256
|
new SlashCommandBuilder()
|
|
252
257
|
.setName('queue-command')
|
|
253
258
|
.setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// E2e test for the default external_directory permission: every directory is
|
|
2
|
+
// allowed, so reading a file outside the project never shows a permission
|
|
3
|
+
// prompt. The old behaviour (allow-list + prompt) is now opt-in behind the
|
|
4
|
+
// --restrict-directories CLI flag.
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
8
|
+
import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
9
|
+
const TEXT_CHANNEL_ID = '200000000000001021';
|
|
10
|
+
describe('external directory permissions', () => {
|
|
11
|
+
const ctx = setupQueueAdvancedSuite({
|
|
12
|
+
channelId: TEXT_CHANNEL_ID,
|
|
13
|
+
channelName: 'qa-external-directory-e2e',
|
|
14
|
+
dirName: 'qa-external-directory-e2e',
|
|
15
|
+
username: 'external-directory-tester',
|
|
16
|
+
});
|
|
17
|
+
test('reads outside the project without a permission prompt', async () => {
|
|
18
|
+
fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
|
|
19
|
+
fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'external directory file');
|
|
20
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
21
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER first',
|
|
22
|
+
});
|
|
23
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
24
|
+
timeout: 4_000,
|
|
25
|
+
predicate: (t) => {
|
|
26
|
+
return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const th = ctx.discord.thread(thread.id);
|
|
30
|
+
await waitForBotMessageContaining({
|
|
31
|
+
discord: ctx.discord,
|
|
32
|
+
threadId: thread.id,
|
|
33
|
+
userId: TEST_USER_ID,
|
|
34
|
+
text: 'external-directory-probe-done',
|
|
35
|
+
timeout: 8_000,
|
|
36
|
+
});
|
|
37
|
+
await waitForFooterMessage({
|
|
38
|
+
discord: ctx.discord,
|
|
39
|
+
threadId: thread.id,
|
|
40
|
+
timeout: 4_000,
|
|
41
|
+
afterMessageIncludes: 'external-directory-probe-done',
|
|
42
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
43
|
+
});
|
|
44
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
45
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER followup',
|
|
46
|
+
});
|
|
47
|
+
await waitForBotMessageContaining({
|
|
48
|
+
discord: ctx.discord,
|
|
49
|
+
threadId: thread.id,
|
|
50
|
+
userId: TEST_USER_ID,
|
|
51
|
+
text: 'external-directory-probe-done',
|
|
52
|
+
afterUserMessageIncludes: 'followup',
|
|
53
|
+
timeout: 8_000,
|
|
54
|
+
});
|
|
55
|
+
await waitForFooterMessage({
|
|
56
|
+
discord: ctx.discord,
|
|
57
|
+
threadId: thread.id,
|
|
58
|
+
timeout: 4_000,
|
|
59
|
+
afterMessageIncludes: 'external-directory-probe-done',
|
|
60
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
61
|
+
});
|
|
62
|
+
const text = await th.text();
|
|
63
|
+
expect(text).toMatchInlineSnapshot(`
|
|
64
|
+
"--- from: user (external-directory-tester)
|
|
65
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER first
|
|
66
|
+
--- from: assistant (TestBot)
|
|
67
|
+
*using deterministic-provider/deterministic-v2*
|
|
68
|
+
⬥ reading external directory
|
|
69
|
+
┣ read *probe.txt*
|
|
70
|
+
⬥ external-directory-probe-done
|
|
71
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
72
|
+
--- from: user (external-directory-tester)
|
|
73
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
74
|
+
--- from: assistant (TestBot)
|
|
75
|
+
⬥ reading external directory
|
|
76
|
+
┣ read *probe.txt*
|
|
77
|
+
⬥ external-directory-probe-done
|
|
78
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
79
|
+
`);
|
|
80
|
+
expect(text).not.toContain('Permission Required');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Regression test: a `deny` rule in the project's own opencode.json must still
|
|
2
|
+
// win over kimaki's allow-all default.
|
|
3
|
+
//
|
|
4
|
+
// This guards a real bug. opencode evaluates permissions with findLast() over
|
|
5
|
+
// merge(agent.permission, session.permission), so session rules are evaluated
|
|
6
|
+
// LAST and override user config. An earlier version of the allow-all default
|
|
7
|
+
// put `external_directory: '*' allow` into the session ruleset, which silently
|
|
8
|
+
// made every user `deny` rule a no-op. The allow now lives in the generated
|
|
9
|
+
// server config instead, where the project opencode.json deep-merges on top.
|
|
10
|
+
import { describe, expect, test } from 'vitest';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
13
|
+
import { waitForBotMessageContaining } from './test-utils.js';
|
|
14
|
+
const TEXT_CHANNEL_ID = '200000000000001023';
|
|
15
|
+
describe('external directory project deny', () => {
|
|
16
|
+
const ctx = setupQueueAdvancedSuite({
|
|
17
|
+
channelId: TEXT_CHANNEL_ID,
|
|
18
|
+
channelName: 'qa-external-directory-deny-e2e',
|
|
19
|
+
dirName: 'qa-external-directory-deny-e2e',
|
|
20
|
+
username: 'external-directory-deny-tester',
|
|
21
|
+
// Exactly what a user would write in their own opencode.json to protect a
|
|
22
|
+
// folder. Kimaki's generated config allows '*', this must still beat it.
|
|
23
|
+
projectPermission: {
|
|
24
|
+
external_directory: {
|
|
25
|
+
[EXTERNAL_DIRECTORY_PROBE_DIR]: 'deny',
|
|
26
|
+
[`${EXTERNAL_DIRECTORY_PROBE_DIR}/*`]: 'deny',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
test('project opencode.json deny beats the allow-all default', async () => {
|
|
31
|
+
fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
|
|
32
|
+
fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'protected file');
|
|
33
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
34
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER denied',
|
|
35
|
+
});
|
|
36
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
37
|
+
timeout: 4_000,
|
|
38
|
+
predicate: (t) => {
|
|
39
|
+
return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const th = ctx.discord.thread(thread.id);
|
|
43
|
+
// The deterministic matcher only emits this after the read comes back
|
|
44
|
+
// rejected by a permission rule, so seeing it proves the deny applied.
|
|
45
|
+
await waitForBotMessageContaining({
|
|
46
|
+
discord: ctx.discord,
|
|
47
|
+
threadId: thread.id,
|
|
48
|
+
userId: TEST_USER_ID,
|
|
49
|
+
text: 'external-directory-probe-denied',
|
|
50
|
+
timeout: 8_000,
|
|
51
|
+
});
|
|
52
|
+
const text = await th.text();
|
|
53
|
+
expect(text).toMatchInlineSnapshot(`
|
|
54
|
+
"--- from: user (external-directory-deny-tester)
|
|
55
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER denied
|
|
56
|
+
--- from: assistant (TestBot)
|
|
57
|
+
*using deterministic-provider/deterministic-v2*
|
|
58
|
+
⬥ reading external directory
|
|
59
|
+
┣ read *probe.txt*
|
|
60
|
+
⬥ external-directory-probe-denied"
|
|
61
|
+
`);
|
|
62
|
+
// A deny is silent: it must not fall back to asking the user.
|
|
63
|
+
expect(text).not.toContain('Permission Required');
|
|
64
|
+
// And the read must not have succeeded, which is what the old
|
|
65
|
+
// session-level '*' allow rule caused.
|
|
66
|
+
expect(text).not.toContain('external-directory-probe-done');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/dist/format-tables.js
CHANGED
|
@@ -267,7 +267,7 @@ function flattenCalloutChildren({ segments, }) {
|
|
|
267
267
|
return [
|
|
268
268
|
{
|
|
269
269
|
type: ComponentType.TextDisplay,
|
|
270
|
-
content: segment.text.trim(),
|
|
270
|
+
content: clampTextDisplayContent(segment.text.trim()),
|
|
271
271
|
},
|
|
272
272
|
];
|
|
273
273
|
}
|
|
@@ -279,15 +279,29 @@ function flattenCalloutChildren({ segments, }) {
|
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
281
|
}
|
|
282
|
+
// Splits callout children into chunks that fit both the 40-component budget
|
|
283
|
+
// (reserving 1 for the Container wrapper itself, including nested children
|
|
284
|
+
// like ActionRow buttons via childComponentCost) and the 4000-char text
|
|
285
|
+
// budget. Mirrors chunkRowsByComponentLimit's approach for table rows.
|
|
282
286
|
function chunkCalloutChildrenByComponentLimit({ children, }) {
|
|
283
287
|
const chunks = [];
|
|
284
288
|
let currentChunk = [];
|
|
289
|
+
let currentCost = 1; // reserve for the Container wrapper
|
|
290
|
+
let currentText = 0;
|
|
285
291
|
for (const child of children) {
|
|
286
|
-
|
|
292
|
+
const childCost = childComponentCost(child);
|
|
293
|
+
const childText = componentTextSize(child);
|
|
294
|
+
if (currentChunk.length > 0 &&
|
|
295
|
+
(currentCost + childCost > MAX_COMPONENTS ||
|
|
296
|
+
currentText + childText > MAX_TEXT_SIZE)) {
|
|
287
297
|
chunks.push(currentChunk);
|
|
288
298
|
currentChunk = [];
|
|
299
|
+
currentCost = 1;
|
|
300
|
+
currentText = 0;
|
|
289
301
|
}
|
|
290
302
|
currentChunk.push(child);
|
|
303
|
+
currentCost += childCost;
|
|
304
|
+
currentText += childText;
|
|
291
305
|
}
|
|
292
306
|
if (currentChunk.length > 0) {
|
|
293
307
|
chunks.push(currentChunk);
|
|
@@ -423,16 +437,34 @@ function buildTextRow({ headers, cells, }) {
|
|
|
423
437
|
const value = cell ? getRenderedCellText({ cell }) : '';
|
|
424
438
|
return `**${key}** ${value}`;
|
|
425
439
|
});
|
|
440
|
+
const content = clampTextDisplayContent(lines.join('\n'));
|
|
426
441
|
return {
|
|
427
442
|
components: [
|
|
428
443
|
{
|
|
429
444
|
type: ComponentType.TextDisplay,
|
|
430
|
-
content
|
|
445
|
+
content,
|
|
431
446
|
},
|
|
432
447
|
],
|
|
433
448
|
componentCost: 1,
|
|
449
|
+
textSize: content.length,
|
|
434
450
|
};
|
|
435
451
|
}
|
|
452
|
+
// Discord's TextDisplay component enforces the same 4000-char content limit
|
|
453
|
+
// as the overall message text budget. A single row with one huge cell value
|
|
454
|
+
// (e.g. a long stack trace) could otherwise exceed it on its own, which no
|
|
455
|
+
// amount of row-level chunking can fix. `maxLength` lets callers reserve
|
|
456
|
+
// budget for sibling content (e.g. button labels) that shares the same
|
|
457
|
+
// message-wide text limit.
|
|
458
|
+
function clampTextDisplayContent(content, maxLength = MAX_TEXT_SIZE) {
|
|
459
|
+
const safeMax = Math.max(0, maxLength);
|
|
460
|
+
if (content.length <= safeMax) {
|
|
461
|
+
return content;
|
|
462
|
+
}
|
|
463
|
+
if (safeMax === 0) {
|
|
464
|
+
return '';
|
|
465
|
+
}
|
|
466
|
+
return content.slice(0, safeMax - 1) + '…';
|
|
467
|
+
}
|
|
436
468
|
function buildButtonRow({ headers, cells, }) {
|
|
437
469
|
const buttonCells = cells.filter((cell) => {
|
|
438
470
|
return cell.type === 'button';
|
|
@@ -463,32 +495,48 @@ function buildButtonRow({ headers, cells, }) {
|
|
|
463
495
|
type: ComponentType.ActionRow,
|
|
464
496
|
components: buttons,
|
|
465
497
|
};
|
|
498
|
+
const buttonLabelSize = buttonCells.reduce((sum, cell) => {
|
|
499
|
+
return sum + (cell.type === 'button' ? cell.label.length : 0);
|
|
500
|
+
}, 0);
|
|
501
|
+
// Reserve budget for button labels first since they can't be truncated,
|
|
502
|
+
// then clamp the TextDisplay content to whatever remains of MAX_TEXT_SIZE.
|
|
503
|
+
const content = clampTextDisplayContent(lines.join('\n'), MAX_TEXT_SIZE - buttonLabelSize);
|
|
466
504
|
return {
|
|
467
505
|
components: [
|
|
468
506
|
{
|
|
469
507
|
type: ComponentType.TextDisplay,
|
|
470
|
-
content
|
|
508
|
+
content,
|
|
471
509
|
},
|
|
472
510
|
actionRow,
|
|
473
511
|
],
|
|
474
512
|
componentCost: 2 + buttons.length,
|
|
513
|
+
textSize: content.length + buttonLabelSize,
|
|
475
514
|
};
|
|
476
515
|
}
|
|
516
|
+
// Splits rows into chunks that fit both the 40-component budget and the
|
|
517
|
+
// 4000-char displayable text budget. Wide/long tables (many long cell values)
|
|
518
|
+
// can stay under the component count while still exceeding Discord's text
|
|
519
|
+
// limit, which previously caused sendMessage to fail with a 400 error.
|
|
477
520
|
function chunkRowsByComponentLimit({ rows, }) {
|
|
478
521
|
const chunks = [];
|
|
479
522
|
let currentChunk = [];
|
|
480
523
|
let currentCost = 1;
|
|
524
|
+
let currentText = 0;
|
|
481
525
|
for (const row of rows) {
|
|
482
526
|
const separatorCost = currentChunk.length > 0 ? 1 : 0;
|
|
483
527
|
const nextCost = currentCost + separatorCost + row.componentCost;
|
|
484
|
-
|
|
528
|
+
const nextText = currentText + row.textSize;
|
|
529
|
+
if (currentChunk.length > 0 &&
|
|
530
|
+
(nextCost > MAX_COMPONENTS || nextText > MAX_TEXT_SIZE)) {
|
|
485
531
|
chunks.push(currentChunk);
|
|
486
532
|
currentChunk = [row];
|
|
487
533
|
currentCost = 1 + row.componentCost;
|
|
534
|
+
currentText = row.textSize;
|
|
488
535
|
continue;
|
|
489
536
|
}
|
|
490
537
|
currentChunk.push(row);
|
|
491
538
|
currentCost = nextCost;
|
|
539
|
+
currentText = nextText;
|
|
492
540
|
}
|
|
493
541
|
if (currentChunk.length > 0) {
|
|
494
542
|
chunks.push(currentChunk);
|