open files for discord bot
Release rolling / release (push) Successful in 9m36s

This commit is contained in:
2026-08-19 18:28:52 -04:00
parent be5c11cec0
commit 03ba640b57
10 changed files with 2253 additions and 117 deletions
+3 -2
View File
@@ -335,6 +335,7 @@ discord-bot --help
| `/edit` | `path` | Modal editor for `~/` and `/tmp` (≤20000 chars, 5 fields). Save writes via VFS | | `/edit` | `path` | Modal editor for `~/` and `/tmp` (≤20000 chars, 5 fields). Save writes via VFS |
| `/create` | `path` | Create a **new** file: path autocomplete, select menu, or custom path, then a contents modal. Refuses to overwrite (offers `/edit`) | | `/create` | `path` | Create a **new** file: path autocomplete, select menu, or custom path, then a contents modal. Refuses to overwrite (offers `/edit`) |
| `/upload` | `file` **(required)**, optional `path` | Attach a file (Discord CDN). The bot then runs guest **`wget -O dest URL`** into the **`/r` cwd** (or `path` if given: a directory gets `path/filename`, a file path is `-O`). Writable trees only (`~/`, `/tmp`). 60s timeout. If `BARE_OS_HTTP_ALLOWLIST` is set, include `cdn.discordapp.com` and `media.discordapp.net` | | `/upload` | `file` **(required)**, optional `path` | Attach a file (Discord CDN). The bot then runs guest **`wget -O dest URL`** into the **`/r` cwd** (or `path` if given: a directory gets `path/filename`, a file path is `-O`). Writable trees only (`~/`, `/tmp`). 60s timeout. If `BARE_OS_HTTP_ALLOWLIST` is set, include `cdn.discordapp.com` and `media.discordapp.net` |
| `/open-file` | `path` **(required)** | Read a VFS file and post it as a Discord **attachment** (uploaded to the Discord CDN). The reply is **not** idle-deleted, so the CDN URL stays. Same read path rules as `/fs` (`~/`, `/tmp`, `/home`, `/proc`, `/etc`, `/var/log`, `/run`, `/share`, `/mnt`; no `..`; token files blocked). Relative names resolve against the `/r` cwd. Max **10MiB**. Directories and empty files are refused. |
| `/hdms` | `list` `help` `health` `hints` `show` `create` `add` `remove` `invite` `pair` | Full Hyperdrive manager (`ctx.runHdms` / `/bin/hdms`). Default **list** is an interactive HUD: pick a drive, show, browse `/mnt/<label>`, invite (pair / RW), remove (confirm). **create** / **add** / **pair** open modals when args are omitted. **invite** prints the Autopass z32. **pair** defers (can wait up to the CLI timeout). **health** / **hints** read `/proc/bare_os/hdms_*.json` (no secrets). Requires **unlocked** identity. Writer secrets in `show` are redacted. `/mnt/…` is now allowed for `/fs` and `/files` (VFS still enforces RO mounts) | | `/hdms` | `list` `help` `health` `hints` `show` `create` `add` `remove` `invite` `pair` | Full Hyperdrive manager (`ctx.runHdms` / `/bin/hdms`). Default **list** is an interactive HUD: pick a drive, show, browse `/mnt/<label>`, invite (pair / RW), remove (confirm). **create** / **add** / **pair** open modals when args are omitted. **invite** prints the Autopass z32. **pair** defers (can wait up to the CLI timeout). **health** / **hints** read `/proc/bare_os/hdms_*.json` (no secrets). Requires **unlocked** identity. Writer secrets in `show` are redacted. `/mnt/…` is now allowed for `/fs` and `/files` (VFS still enforces RO mounts) |
| `/holesail` | `list` `help` `status` `path` `logs` `show` `url` `add` `edit` `remove` `start` `stop` `restart` `enable` `disable` `service` | Full Holesail manager (`ctx.bareOsRunHolesailCli` / `/bin/holesail`). Default **list** is an interactive HUD: pick a tunnel, show, start/stop, enable/disable, edit (modal), remove (confirm). **Add server** / **Add client** open modals. No selection exposes **start/stop/restart** for the **`bare-holesail`** unit (`systemctl`). **show** / **url** print the shareable **`hs://`** key; **seed** material is never shown (64-hex redacted). **service** `start` `stop` `restart` `status` the initd unit. Live tunnel ops defer. | | `/holesail` | `list` `help` `status` `path` `logs` `show` `url` `add` `edit` `remove` `start` `stop` `restart` `enable` `disable` `service` | Full Holesail manager (`ctx.bareOsRunHolesailCli` / `/bin/holesail`). Default **list** is an interactive HUD: pick a tunnel, show, start/stop, enable/disable, edit (modal), remove (confirm). **Add server** / **Add client** open modals. No selection exposes **start/stop/restart** for the **`bare-holesail`** unit (`systemctl`). **show** / **url** print the shareable **`hs://`** key; **seed** material is never shown (64-hex redacted). **service** `start` `stop` `restart` `status` the initd unit. Live tunnel ops defer. |
| `/agent` | `list` `help` `status` `config` `models` `ask` `skills` `todos` `plan` `hooks` `history` `recap` `undo` `rewind` `compact` `export` `remember` `reset` `stop` | Full guest `/bin/agent` harness from Discord when **configured**. Ready means: `~/.agent/config.json` exists **and** (QVAC model + `ctx.bareOsQvacAvailable`, or REST + `rest_api_key` + `rest_base_url`). Default **list** is an HUD: Ask (modal), Stop, Reset, Models, Recap, Compact, plus an **Inspect / session** menu (skills, todos, plan, history, hooks, undo, rewind, export, remember). **ask** `prompt` (required) runs one turn via `execLine` with `BARE_OS_AGENT_DISCORD=1` (Discord-flavored Markdown, shared `~/.agent/history.json`, up to 13 minutes, live progress from `progress.txt`). Optional ask flags: `new`, `plan`, `auto`, `compact`, `max_turns`, `model`. Inspect subs call the same CLI as TTY (`agent skills`, `agent remember …`, …). Unconfigured HUD explains `agent --setup`. API keys never appear in Discord. Whitelist still applies. **Direct Messages:** a whitelisted user can send a normal DM to the bot (no slash) and it runs the same agent turn. Text `help` / `reset` / `stop` / `ping` are shortcuts. Empty whitelist or an unknown user gets Access denied; guild channel text is **not** an agent chat. **Message channel:** the first allowed DM opens `~/.discord/channel.json`. The agent can then **reach out first** with `discord_send_message` (live `ctx.bareOsDiscordSendDm`, else `~/.discord/outbox.json` drained every 1.5s). Inbound copies: `~/.discord/inbox.json`. `emit_host_notification` also fans out to the open DM. | | `/agent` | `list` `help` `status` `config` `models` `ask` `skills` `todos` `plan` `hooks` `history` `recap` `undo` `rewind` `compact` `export` `remember` `reset` `stop` | Full guest `/bin/agent` harness from Discord when **configured**. Ready means: `~/.agent/config.json` exists **and** (QVAC model + `ctx.bareOsQvacAvailable`, or REST + `rest_api_key` + `rest_base_url`). Default **list** is an HUD: Ask (modal), Stop, Reset, Models, Recap, Compact, plus an **Inspect / session** menu (skills, todos, plan, history, hooks, undo, rewind, export, remember). **ask** `prompt` (required) runs one turn via `execLine` with `BARE_OS_AGENT_DISCORD=1` (Discord-flavored Markdown, shared `~/.agent/history.json`, up to 13 minutes, live progress from `progress.txt`). Optional ask flags: `new`, `plan`, `auto`, `compact`, `max_turns`, `model`. Inspect subs call the same CLI as TTY (`agent skills`, `agent remember …`, …). Unconfigured HUD explains `agent --setup`. API keys never appear in Discord. Whitelist still applies. **Direct Messages:** a whitelisted user can send a normal DM to the bot (no slash) and it runs the same agent turn. Text `help` / `reset` / `stop` / `ping` are shortcuts. Empty whitelist or an unknown user gets Access denied; guild channel text is **not** an agent chat. **Message channel:** the first allowed DM opens `~/.discord/channel.json`. The agent can then **reach out first** with `discord_send_message` (live `ctx.bareOsDiscordSendDm`, else `~/.discord/outbox.json` drained every 1.5s). Inbound copies: `~/.discord/inbox.json`. `emit_host_notification` also fans out to the open DM. |
@@ -352,7 +353,7 @@ discord-bot --help
Channel text `ping` still replies `pong` only when Message Content Intent is on. Channel text `ping` still replies `pong` only when Message Content Intent is on.
Posted embeds expire after **2 minutes of inactivity**. A click or select from the operator who posted the message resets the timer. Denied clicks (off-whitelist or another user) do not. When the timer fires the bot **deletes** the message (or strips its components and marks the embed expired if delete is denied) and drops the matching in-memory edit / files / settings session. Posted embeds expire after **2 minutes of inactivity**. A click or select from the operator who posted the message resets the timer. Denied clicks (off-whitelist or another user) do not. When the timer fires the bot **deletes** the message (or strips its components and marks the embed expired if delete is denied) and drops the matching in-memory edit / files / settings session. **`/open-file` attachments are kept** — that reply is not armed for idle delete so the file remains on the Discord CDN.
### Settings editor (`/settings`) ### Settings editor (`/settings`)
@@ -435,7 +436,7 @@ A file named `*.disabled` or a folder with `"enabled": false` is skipped.
### Command names ### Command names
- `a-z`, `0-9`, hyphen; 132 characters; must start with a letter or digit. - `a-z`, `0-9`, hyphen; 132 characters; must start with a letter or digit.
- **Reserved** (cannot override): `bare`, `sys`, `svc`, `fs`, `net`, `man`, `say`, `run`, `journal`, `edit`, `create`, `files`, `browse`, `settings`, `panel`, `ping`, `plugins`. - **Reserved** (cannot override): `bare`, `sys`, `svc`, `fs`, `net`, `man`, `say`, `run`, `journal`, `edit`, `create`, `files`, `browse`, `settings`, `panel`, `ping`, `plugins`, `upload`, `open-file`, `hdms`, `holesail`, `agent`, `status`.
- At most **40** plugins. Each source file is capped at **64 KiB**. - At most **40** plugins. Each source file is capped at **64 KiB**.
### JSON plugin SDK ### JSON plugin SDK
+983 -52
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -59,6 +59,8 @@ var BARE_OS_DISCORD_SH_BINS = null
var BARE_OS_DISCORD_SH_BINS_AT = 0 var BARE_OS_DISCORD_SH_BINS_AT = 0
var BARE_OS_DISCORD_SH_EXEC_MS = 20000 var BARE_OS_DISCORD_SH_EXEC_MS = 20000
var BARE_OS_DISCORD_UPLOAD_MS = 60000 var BARE_OS_DISCORD_UPLOAD_MS = 60000
/** Discord's default bot attachment cap (10 MiB). */
var BARE_OS_DISCORD_OPEN_FILE_MAX = 10 * 1024 * 1024
var BARE_OS_DISCORD_SH_BUILTINS = [ var BARE_OS_DISCORD_SH_BUILTINS = [
'alias', 'alias',
'unalias', 'unalias',
@@ -773,8 +775,11 @@ function discordCmdReplyPayload(result) {
payload.components = discordUniqueComponents(result.components) payload.components = discordUniqueComponents(result.components)
if (!payload.components.length) delete payload.components if (!payload.components.length) delete payload.components
} }
if (result && result.attachments && result.attachments.length) {
payload.files = result.attachments
}
if (result && result.text) payload.content = discordCmdClip(result.text) if (result && result.text) payload.content = discordCmdClip(result.text)
if (!payload.content && !payload.embeds) payload.content = '(no output)' if (!payload.content && !payload.embeds && !payload.files) payload.content = '(no output)'
if (result && result.ephemeral) payload.flags = BARE_OS_DISCORD_FLAG_EPHEMERAL if (result && result.ephemeral) payload.flags = BARE_OS_DISCORD_FLAG_EPHEMERAL
delete payload.fetchReply delete payload.fetchReply
delete payload.ephemeral delete payload.ephemeral
@@ -2829,7 +2834,10 @@ async function discordCmdHandleBare(ctx, sub) {
discordField('/svc', 'Services — list, start, stop, restart, logs'), discordField('/svc', 'Services — list, start, stop, restart, logs'),
discordField('/fs', 'Read-only VFS — ls, cat, stat, head'), discordField('/fs', 'Read-only VFS — ls, cat, stat, head'),
discordField('/net', 'Swarm — peers, summary'), discordField('/net', 'Swarm — peers, summary'),
discordField('/files /edit /create /upload', 'Browse, edit, or wget an attachment into `~/` `/tmp`'), discordField(
'/files /edit /create /upload /open-file',
'Browse, edit, wget in, or attach a VFS file out (CDN, kept)'
),
discordField('/settings', 'Live session knobs (theme, shell, Discord, agent, aliases)'), discordField('/settings', 'Live session knobs (theme, shell, Discord, agent, aliases)'),
discordField('/r /journal /say /man', 'Full shell (cwd + autocomplete), logs, speak, man pages'), discordField('/r /journal /say /man', 'Full shell (cwd + autocomplete), logs, speak, man pages'),
discordField('/plugins', 'List, reload, enable/disable ~/.discord/plugins'), discordField('/plugins', 'List, reload, enable/disable ~/.discord/plugins'),
@@ -3931,6 +3939,118 @@ async function discordCmdHandleUpload(ctx, interaction, destRaw) {
) )
} }
function discordOpenFileByteLength(buf) {
if (buf == null) return 0
if (typeof buf.byteLength === 'number') return buf.byteLength
if (typeof buf.length === 'number') return buf.length
if (typeof buf === 'string') {
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
return Buffer.byteLength(buf)
}
return buf.length
}
return 0
}
function discordOpenFileAsAttachment(ctx, buf, name) {
let data = buf
if (typeof data === 'string') {
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') data = ctx.b4a.from(data)
else if (typeof Buffer !== 'undefined') data = Buffer.from(data)
else {
const u8 = new Uint8Array(data.length)
for (let i = 0; i < data.length; i++) u8[i] = data.charCodeAt(i) & 0xff
data = u8
}
}
return { attachment: data, name: name }
}
function discordOpenFileResolvePath(ctx, interaction, rawPath) {
const raw = String(rawPath || '').trim()
if (!raw) return ''
if (raw.charAt(0) === '/' || raw.charAt(0) === '~' || raw === '.') return raw
const rec = discordShGet(interaction, ctx)
return discordJoinPath(rec.cwd || '~', raw)
}
async function discordCmdHandleOpenFile(ctx, interaction, rawPath) {
const given = String(rawPath || '').trim()
if (!given) {
return {
text: 'Give `/open-file` a VFS path (e.g. `~/notes.txt`). The bot attaches it to this channel (Discord CDN; not auto-deleted).',
ephemeral: true
}
}
const resolved = discordOpenFileResolvePath(ctx, interaction, given)
const p = discordCmdPathOk(resolved)
if (!p) {
return {
text: 'path not allowed (no `..`; stay under /proc /etc /var/log /run /home ~ /share /tmp /mnt)',
ephemeral: true
}
}
if (!ctx.vfs || typeof ctx.vfs.readFile !== 'function') {
return { text: 'vfs.readFile is unavailable in this session', ephemeral: true }
}
const st = await discordFmStat(ctx, p)
if (discordIsDirStat(st)) {
return { text: '`' + p + '` is a directory. Pick a file.', ephemeral: true }
}
if (!st && !(await discordFileExists(ctx, p))) {
return { text: 'No such file: `' + p + '`', ephemeral: true }
}
if (st && st.size != null && Number(st.size) > BARE_OS_DISCORD_OPEN_FILE_MAX) {
return {
text:
'`' +
p +
'` is ' +
discordPrettyBytes(st.size) +
' (max ' +
discordPrettyBytes(BARE_OS_DISCORD_OPEN_FILE_MAX) +
' for Discord attachments).',
ephemeral: true
}
}
let buf
try {
buf = await ctx.vfs.readFile(p)
} catch (err) {
return {
text: 'Could not read `' + p + '`: ' + ((err && err.message) || err),
ephemeral: true
}
}
if (buf == null) {
return { text: '`' + p + '` is empty or missing.', ephemeral: true }
}
const bytes = discordOpenFileByteLength(buf)
if (bytes > BARE_OS_DISCORD_OPEN_FILE_MAX) {
return {
text:
'`' +
p +
'` is ' +
discordPrettyBytes(bytes) +
' (max ' +
discordPrettyBytes(BARE_OS_DISCORD_OPEN_FILE_MAX) +
' for Discord attachments).',
ephemeral: true
}
}
if (bytes === 0) {
return { text: '`' + p + '` is empty (0 bytes). Nothing to attach.', ephemeral: true }
}
const name = discordUploadSafeName(discordBaseName(p) || 'file.bin')
const size = st && st.size != null ? Number(st.size) : bytes
return {
text: '`' + p + '` · ' + discordPrettyBytes(size),
attachments: [discordOpenFileAsAttachment(ctx, buf, name)],
keep: true
}
}
var BARE_OS_DISCORD_HDMS_SESSIONS = Object.create(null) var BARE_OS_DISCORD_HDMS_SESSIONS = Object.create(null)
function discordHdmsKey(interaction) { function discordHdmsKey(interaction) {
@@ -6505,9 +6625,11 @@ var BARE_OS_DISCORD_PLUGIN_RESERVED = {
ping: 1, ping: 1,
plugins: 1, plugins: 1,
upload: 1, upload: 1,
'open-file': 1,
hdms: 1, hdms: 1,
holesail: 1, holesail: 1,
agent: 1 agent: 1,
status: 1
} }
var BARE_OS_DISCORD_PLUGINS = [] var BARE_OS_DISCORD_PLUGINS = []
var BARE_OS_DISCORD_PLUGINS_READY = false var BARE_OS_DISCORD_PLUGINS_READY = false
@@ -7075,6 +7197,9 @@ function discordBuildSlashCommands(dj, ctx) {
const upload = new B2() const upload = new B2()
.setName('upload') .setName('upload')
.setDescription('Upload an attachment; wget saves it in the /r cwd or path') .setDescription('Upload an attachment; wget saves it in the /r cwd or path')
const openFile = new B2()
.setName('open-file')
.setDescription('Send a VFS file as a Discord attachment (kept, not auto-deleted)')
const hdms = new B2() const hdms = new B2()
.setName('hdms') .setName('hdms')
.setDescription('Hyperdrive management (list, create, add, invite, pair)') .setDescription('Hyperdrive management (list, create, add, invite, pair)')
@@ -7291,6 +7416,7 @@ function discordBuildSlashCommands(dj, ctx) {
discordCmdOpt(files, 'path', 'Directory to open', false, true) discordCmdOpt(files, 'path', 'Directory to open', false, true)
discordCmdAtt(upload, 'file', 'File to upload (Discord CDN, then wget into the VFS)', true) discordCmdAtt(upload, 'file', 'File to upload (Discord CDN, then wget into the VFS)', true)
discordCmdOpt(upload, 'path', 'VFS dest (file or dir; default: /r cwd + name)', false, true) discordCmdOpt(upload, 'path', 'VFS dest (file or dir; default: /r cwd + name)', false, true)
discordCmdOpt(openFile, 'path', 'VFS file to attach (uploaded to the Discord CDN)', true, true)
discordCmdOpt(status, 'activity', 'playing, watching, listening, competing, custom, streaming', false, false, { discordCmdOpt(status, 'activity', 'playing, watching, listening, competing, custom, streaming', false, false, {
choices: [ choices: [
['Playing', 'playing'], ['Playing', 'playing'],
@@ -7313,7 +7439,7 @@ function discordBuildSlashCommands(dj, ctx) {
['Invisible', 'invisible'] ['Invisible', 'invisible']
] ]
}) })
const stock = [bare, sys, svc, fsCmd, net, man, say, run, journal, edit, create, files, settings, plugins, panel, ping, status, upload, hdms, holesail, agent].map( const stock = [bare, sys, svc, fsCmd, net, man, say, run, journal, edit, create, files, settings, plugins, panel, ping, status, upload, openFile, hdms, holesail, agent].map(
function (c) { function (c) {
return discordStampUserInstallCommand(c.toJSON(), ctx) return discordStampUserInstallCommand(c.toJSON(), ctx)
} }
@@ -9158,7 +9284,7 @@ async function discordSendResult(ctx, interaction, result) {
if (result && result.settings) { if (result && result.settings) {
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction)) return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
} }
if (result && result.files !== undefined) { if (result && result.files !== undefined && !result.attachments && !Array.isArray(result.files)) {
const rec = discordFmGet(interaction, ctx) const rec = discordFmGet(interaction, ctx)
const want = String(result.files || '').trim() const want = String(result.files || '').trim()
if (want) { if (want) {
@@ -9380,7 +9506,9 @@ async function discordSendResult(ctx, interaction, result) {
return return
} }
try { try {
await discordIdleWatch(interaction, payload, sent) const persist =
(result && result.keep) || (payload.files && payload.files.length)
if (!persist) await discordIdleWatch(interaction, payload, sent)
} catch { } catch {
/* tracking must not break the bot */ /* tracking must not break the bot */
} }
@@ -10450,6 +10578,8 @@ async function discordDispatchInteraction(ctx, interaction) {
result = await discordPluginsAdmin(ctx, interaction, sub, opt) result = await discordPluginsAdmin(ctx, interaction, sub, opt)
} else if (name === 'upload') { } else if (name === 'upload') {
result = await discordCmdHandleUpload(ctx, interaction, opt('path')) result = await discordCmdHandleUpload(ctx, interaction, opt('path'))
} else if (name === 'open-file') {
result = await discordCmdHandleOpenFile(ctx, interaction, opt('path'))
} else if (name === 'hdms') { } else if (name === 'hdms') {
result = await discordCmdHandleHdms(ctx, interaction, sub, opt) result = await discordCmdHandleHdms(ctx, interaction, sub, opt)
} else if (name === 'holesail') { } else if (name === 'holesail') {
@@ -10545,6 +10675,8 @@ var bareOsDiscordCommands = {
parseHolesailList: discordParseHolesailList, parseHolesailList: discordParseHolesailList,
parseHolesailShow: discordParseHolesailShow, parseHolesailShow: discordParseHolesailShow,
pathOk: discordCmdPathOk, pathOk: discordCmdPathOk,
openFileMax: BARE_OS_DISCORD_OPEN_FILE_MAX,
handleOpenFile: discordCmdHandleOpenFile,
result: discordResult, result: discordResult,
packEmbed: discordPackEmbed, packEmbed: discordPackEmbed,
textPages: discordTextPages, textPages: discordTextPages,
@@ -329,6 +329,7 @@ test('discord command catalog builds Bare OS slash commands', async (t) => {
t.ok(names.indexOf('settings') >= 0) t.ok(names.indexOf('settings') >= 0)
t.ok(names.indexOf('plugins') >= 0) t.ok(names.indexOf('plugins') >= 0)
t.ok(names.indexOf('upload') >= 0) t.ok(names.indexOf('upload') >= 0)
t.ok(names.indexOf('open-file') >= 0)
t.ok(names.indexOf('hdms') >= 0) t.ok(names.indexOf('hdms') >= 0)
t.ok(names.indexOf('holesail') >= 0) t.ok(names.indexOf('holesail') >= 0)
t.ok(names.indexOf('agent') >= 0) t.ok(names.indexOf('agent') >= 0)
@@ -343,6 +344,9 @@ test('discord command catalog builds Bare OS slash commands', async (t) => {
const uploadCmd = body.find((c) => c.name === 'upload') const uploadCmd = body.find((c) => c.name === 'upload')
const fileOpt = uploadCmd && (uploadCmd.options || []).find((o) => o.name === 'file') const fileOpt = uploadCmd && (uploadCmd.options || []).find((o) => o.name === 'file')
t.ok(fileOpt && fileOpt.type === 11 && fileOpt.required, '/upload file attachment is required') t.ok(fileOpt && fileOpt.type === 11 && fileOpt.required, '/upload file attachment is required')
const openFileCmd = body.find((c) => c.name === 'open-file')
const openPath = openFileCmd && (openFileCmd.options || []).find((o) => o.name === 'path')
t.ok(openPath && openPath.type === 3 && openPath.required, '/open-file path is required')
t.ok(body.length >= 8) t.ok(body.length >= 8)
const replies = [] const replies = []
@@ -360,6 +364,7 @@ test('discord command catalog builds Bare OS slash commands', async (t) => {
await cmds.dispatchInteraction({ env: {}, vfs: {}, console: {} }, interaction) await cmds.dispatchInteraction({ env: {}, vfs: {}, console: {} }, interaction)
const helpBlob = JSON.stringify(replies[0] || {}) const helpBlob = JSON.stringify(replies[0] || {})
t.ok(/\/bare/.test(helpBlob), 'help mentions /bare') t.ok(/\/bare/.test(helpBlob), 'help mentions /bare')
t.ok(/open-file/.test(helpBlob), 'help mentions /open-file')
assertUniqueCustomIds(t, replies[0], 'help') assertUniqueCustomIds(t, replies[0], 'help')
}) })
@@ -1272,6 +1277,142 @@ test('Discord /upload wget-saves an attachment into the /run cwd', async (t) =>
t.is(lines.length, 3, 'denied upload does not run wget') t.is(lines.length, 3, 'denied upload does not run wget')
}) })
test('Discord /open-file attaches a VFS file and does not idle-delete it', async (t) => {
const req = createRequire(fileURLToPath(import.meta.url))
const cmds = req('./lib/services/bare-os-discord-commands-guest.cjs')
t.ok(cmds.openFileMax >= 10 * 1024 * 1024, 'Discord default attachment cap')
const packed = cmds.replyPayload({
text: 'note',
attachments: [{ attachment: Buffer.from('a'), name: 'a.bin' }],
keep: true
})
t.is(packed.content, 'note')
t.is(packed.files[0].name, 'a.bin')
t.absent(packed.keep, 'keep is not sent to Discord')
const env = { USER: 'alice', HOME: '/home/alice', PWD: '/home/alice' }
const files = {
'/home/alice/hello.txt': 'hello from vfs',
'/home/alice/rel.bin': 'rel-body',
'/tmp/photo.png': Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2]).toString('binary'),
'/tmp/empty.txt': '',
'/tmp/as-string.txt': 'string-body'
}
let readCount = 0
const ctx = {
env,
vfs: {
env,
stat: async (p) => {
if (p === '/tmp' || p === '/home/alice') return { isDirectory: true, type: 'directory' }
if (p === '/tmp/empty.txt') return { isFile: true, type: 'file', size: 0 }
if (p === '/tmp/huge.bin') return { isFile: true, type: 'file', size: cmds.openFileMax + 1 }
if (Object.prototype.hasOwnProperty.call(files, p)) {
return { isFile: true, type: 'file', size: Buffer.from(files[p], 'binary').length }
}
const e = new Error('ENOENT')
e.code = 'ENOENT'
throw e
},
readFile: async (p) => {
readCount++
if (p === '/tmp/as-string.txt') return files[p]
if (!Object.prototype.hasOwnProperty.call(files, p)) {
const e = new Error('ENOENT')
e.code = 'ENOENT'
throw e
}
return Buffer.from(files[p], 'binary')
}
},
b4a: {
toString: (b) => Buffer.from(b).toString('utf8'),
from: (s) => Buffer.from(String(s))
},
console: { log() {}, error() {} }
}
function ix(path, replies) {
return {
isChatInputCommand: () => true,
isAutocomplete: () => false,
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => false,
commandName: 'open-file',
user: { id: 'of1' },
options: {
getSubcommand: () => '',
getString: (k) => (k === 'path' ? path : '')
},
reply: async (p) => {
replies.push(p)
return { id: 'msg-open-' + replies.length }
}
}
}
const missing = []
await cmds.dispatchInteraction(ctx, ix('', missing))
t.ok(/Give `\/open-file`/.test(JSON.stringify(missing[0] || {})), 'requires a path')
t.ok(missing[0] && missing[0].flags, 'missing path is ephemeral')
t.absent(missing[0] && missing[0].files)
const liveBefore = Object.keys(cmds.idleLive()).length
const views = []
await cmds.dispatchInteraction(ctx, ix('/home/alice/hello.txt', views))
t.ok(views[0] && Array.isArray(views[0].files) && views[0].files.length === 1, 'posts an attachment')
t.is(views[0].files[0].name, 'hello.txt')
t.is(Buffer.from(views[0].files[0].attachment).toString('utf8'), 'hello from vfs')
t.ok(/hello\.txt/.test(String(views[0].content || '')))
t.absent(views[0].embeds, 'no expire-able embed')
t.absent(views[0].components, 'no expire-able components')
t.is(Object.keys(cmds.idleLive()).length, liveBefore, 'does not arm idle delete')
const rel = []
await cmds.dispatchInteraction(ctx, ix('rel.bin', rel))
t.is(rel[0] && rel[0].files && rel[0].files[0] && rel[0].files[0].name, 'rel.bin')
t.is(Buffer.from(rel[0].files[0].attachment).toString('utf8'), 'rel-body')
const bin = []
await cmds.dispatchInteraction(ctx, ix('/tmp/photo.png', bin))
t.is(bin[0].files[0].name, 'photo.png')
t.ok(Buffer.from(bin[0].files[0].attachment)[0] === 0x89, 'binary bytes preserved')
const asString = []
await cmds.dispatchInteraction(ctx, ix('/tmp/as-string.txt', asString))
t.ok(typeof asString[0].files[0].attachment !== 'string', 'string reads become attachment bytes')
t.is(Buffer.from(asString[0].files[0].attachment).toString('utf8'), 'string-body')
const denied = []
const readsBeforeDeny = readCount
await cmds.dispatchInteraction(ctx, ix('~/.discord/.env', denied))
t.ok(/not allowed/.test(JSON.stringify(denied[0] || {})), 'blocks token files')
t.absent(denied[0] && denied[0].files)
t.is(readCount, readsBeforeDeny, 'denied path does not read the file')
const dir = []
await cmds.dispatchInteraction(ctx, ix('/tmp', dir))
t.ok(/directory/.test(JSON.stringify(dir[0] || {})), 'refuses directories')
t.absent(dir[0] && dir[0].files)
const missingFile = []
await cmds.dispatchInteraction(ctx, ix('/tmp/nope.txt', missingFile))
t.ok(/No such file/.test(JSON.stringify(missingFile[0] || {})))
const empty = []
await cmds.dispatchInteraction(ctx, ix('/tmp/empty.txt', empty))
t.ok(/empty/.test(JSON.stringify(empty[0] || {})))
t.absent(empty[0] && empty[0].files)
const huge = []
const readsBeforeHuge = readCount
await cmds.dispatchInteraction(ctx, ix('/tmp/huge.bin', huge))
t.ok(/max/.test(JSON.stringify(huge[0] || {})), 'rejects oversize before upload')
t.absent(huge[0] && huge[0].files)
t.is(readCount, readsBeforeHuge, 'oversize does not read the file')
})
test('Discord /hdms manages extra Hyperdrives', async (t) => { test('Discord /hdms manages extra Hyperdrives', async (t) => {
const req = createRequire(fileURLToPath(import.meta.url)) const req = createRequire(fileURLToPath(import.meta.url))
const cmds = req('./lib/services/bare-os-discord-commands-guest.cjs') const cmds = req('./lib/services/bare-os-discord-commands-guest.cjs')
+1 -1
View File
@@ -3,7 +3,7 @@
## Unreleased ## Unreleased
- **`agent`**: Production ReAct harness. Default **QVAC** (local); optional OpenAI-compatible **REST** (`agent --setup` stores only the chosen backend). Full guest admin by default (**denylist**, not allowlist). Production flags **`--plan`**, **`--auto`**, **`--new`**, **`--compact`**, **`--max-turns`**, **`--model`**, **`--system`**. Inspect (no model): **`skills`**, **`todos`**, **`plan`**, **`undo`**, **`hooks`**, **`history`**, **`rewind`**, **`export`**, **`remember`**, **`recap`**. Grok-port tools include **`grep`** (`output_mode`), **`apply_patch`**, **`find_symbol`**, **`copy_path`**, **`diff_files`**, **`read_many`**, **`fuzzy_find`**, **`wait_for`**, **`undo_last_edit`**, **`git_log`/`show`/`blame`**, **`create_skill`**, session rewind/export, walk-up project skills (`.grok/skills`, `.agents/skills`, …). Discord **`/agent`** is the same session (shared `~/.agent`). - **`agent`**: Production ReAct harness. Default **QVAC** (local); optional OpenAI-compatible **REST** (`agent --setup` stores only the chosen backend). Full guest admin by default (**denylist**, not allowlist). Production flags **`--plan`**, **`--auto`**, **`--new`**, **`--compact`**, **`--max-turns`**, **`--model`**, **`--system`**. Inspect (no model): **`skills`**, **`todos`**, **`plan`**, **`undo`**, **`hooks`**, **`history`**, **`rewind`**, **`export`**, **`remember`**, **`recap`**. Grok-port tools include **`grep`** (`output_mode`), **`apply_patch`**, **`find_symbol`**, **`copy_path`**, **`diff_files`**, **`read_many`**, **`fuzzy_find`**, **`wait_for`**, **`undo_last_edit`**, **`git_log`/`show`/`blame`**, **`create_skill`**, session rewind/export, walk-up project skills (`.grok/skills`, `.agents/skills`, …). Discord **`/agent`** is the same session (shared `~/.agent`).
- **`discord-bot`**: Full **`/agent`** surface (ask + inspect + session + models + settings). User-installable profile app; **`DISCORD_ID_WHITELIST`** always enforced (loaded from `~/.discord/.env` / `~/.env`). **`/holesail`** and **`/hdms`** interactive managers. See [developer-guide ch.21](../../developer-guide/21-discord-bots.md). - **`discord-bot`**: Full **`/agent`** surface (ask + inspect + session + models + settings). User-installable profile app; **`DISCORD_ID_WHITELIST`** always enforced (loaded from `~/.discord/.env` / `~/.env`). **`/holesail`** and **`/hdms`** interactive managers. **`/open-file`** posts a VFS file as a Discord attachment (CDN upload; not idle-deleted). See [developer-guide ch.21](../../developer-guide/21-discord-bots.md).
- **`summon`**: Text web browser (`ctx.tui`, cell buffer). HTML/CSS engine plus page JS via **`ctx.bare.bareVm`** (`bare-vm` / `bare-realm`) or a Function sandbox. **`--no-js`** / **`J`**. HTTP via **`ctx.httpFetch`**. See [summon.md](../../docs/reference/summon.md). - **`summon`**: Text web browser (`ctx.tui`, cell buffer). HTML/CSS engine plus page JS via **`ctx.bare.bareVm`** (`bare-vm` / `bare-realm`) or a Function sandbox. **`--no-js`** / **`J`**. HTTP via **`ctx.httpFetch`**. See [summon.md](../../docs/reference/summon.md).
- **`irc`**: Room browser (`/rooms`, Ctrl+L), featured Libera channels, `LIST` filter, self-JOIN switches buffer. Full slash table (`/query`/`/msg` print usage when args are missing). Random `osXXXXX` nick when USER looks like a container id. TLS prefers **`ctx.bareOsTlsConnect`** and falls back to **`ctx.bare.bareTls`**. - **`irc`**: Room browser (`/rooms`, Ctrl+L), featured Libera channels, `LIST` filter, self-JOIN switches buffer. Full slash table (`/query`/`/msg` print usage when args are missing). Random `osXXXXX` nick when USER looks like a container id. TLS prefers **`ctx.bareOsTlsConnect`** and falls back to **`ctx.bare.bareTls`**.
@@ -5,7 +5,7 @@
"synopsis": [ "synopsis": [
"discord-bot [--env PATH] [--token TOKEN] [--guild ID] [--check] [--debug] [--message-content] [--login-timeout MS] [--no-user-install]" "discord-bot [--env PATH] [--token TOKEN] [--guild ID] [--check] [--debug] [--message-content] [--login-timeout MS] [--no-user-install]"
], ],
"description": "Bare OS Discord bot via ctx.bare.discordJS. Commands run as the unlocked session user (not guest). /r requires cmd and is a non-interactive shell (cwd, history, autocomplete). /upload attaches a file and wget-saves it into the /r cwd or a given path. /hdms manages extra Hyperdrives (list, create, add, invite, pair, health) via ctx.runHdms. /holesail manages persisted Holesail tunnels and the bare-holesail unit (list, add, edit, start/stop, enable/disable, service, url) via ctx.bareOsRunHolesailCli; show never prints seed material. /agent is the full guest /bin/agent harness when ~/.agent/config.json is ready (QVAC + host bridge, or REST + API key + base URL): ask (required prompt; new/plan/auto/compact/max_turns/model), status, config, models, skills, todos, plan, hooks, history, recap, undo, rewind, compact, export, remember, reset, stop. History, skills, todos, and MEMORY.md are shared with the TTY; API keys are never shown. Whitelisted users can Direct Message the bot to talk to the agent without a slash command (same ~/.agent session; help / reset / stop as text). The first allowed DM opens a persistent message channel (~/.discord/channel.json). The agent can then reach out anytime with discord_send_message (live send via ctx.bareOsDiscordSendDm, or queue ~/.discord/outbox.json until the bot drains it). Inbound copies land in ~/.discord/inbox.json. Empty DISCORD_ID_WHITELIST denies every DM, inbound and outbound. /status sets the bot presence shown in the member list (playing, watching, listening, competing, custom, streaming plus online/idle/dnd/invisible); persisted as DISCORD_STATUS / DISCORD_ACTIVITY_TYPE / DISCORD_ACTIVITY_NAME in ~/.discord/.env and also under /settings Discord. /plugins loads JSON or JS from ~/.discord/plugins (see developer-guide ch.21). Also /settings, /files, /panel, /edit, /create, /bare, /sys, /svc, /fs, /net, /man, /say, /journal, /ping. Embeds paginate when large and expire after 2 minutes idle. Token from --token, DISCORD_TOKEN, or ~/.discord/.env. User-installable (Add App to your Discord profile) is on by default so slash commands work in DMs and any server; DISCORD_ID_WHITELIST is always enforced on those surfaces (empty list denies user-install / DM use). DirectMessages intent is requested so DM chat works without the privileged Message Content Intent. The initd unit bare-os-discord is listed by systemctl only when ~/.discord/.env exists.", "description": "Bare OS Discord bot via ctx.bare.discordJS. Commands run as the unlocked session user (not guest). /r requires cmd and is a non-interactive shell (cwd, history, autocomplete). /upload attaches a file and wget-saves it into the /r cwd or a given path. /open-file path reads a VFS file and posts it as a Discord attachment (uploaded to the Discord CDN; the message is not auto-deleted after idle). /hdms manages extra Hyperdrives (list, create, add, invite, pair, health) via ctx.runHdms. /holesail manages persisted Holesail tunnels and the bare-holesail unit (list, add, edit, start/stop, enable/disable, service, url) via ctx.bareOsRunHolesailCli; show never prints seed material. /agent is the full guest /bin/agent harness when ~/.agent/config.json is ready (QVAC + host bridge, or REST + API key + base URL): ask (required prompt; new/plan/auto/compact/max_turns/model), status, config, models, skills, todos, plan, hooks, history, recap, undo, rewind, compact, export, remember, reset, stop. History, skills, todos, and MEMORY.md are shared with the TTY; API keys are never shown. Whitelisted users can Direct Message the bot to talk to the agent without a slash command (same ~/.agent session; help / reset / stop as text). The first allowed DM opens a persistent message channel (~/.discord/channel.json). The agent can then reach out anytime with discord_send_message (live send via ctx.bareOsDiscordSendDm, or queue ~/.discord/outbox.json until the bot drains it). Inbound copies land in ~/.discord/inbox.json. Empty DISCORD_ID_WHITELIST denies every DM, inbound and outbound. /status sets the bot presence shown in the member list (playing, watching, listening, competing, custom, streaming plus online/idle/dnd/invisible); persisted as DISCORD_STATUS / DISCORD_ACTIVITY_TYPE / DISCORD_ACTIVITY_NAME in ~/.discord/.env and also under /settings Discord. /plugins loads JSON or JS from ~/.discord/plugins (see developer-guide ch.21). Also /settings, /files, /open-file, /panel, /edit, /create, /bare, /sys, /svc, /fs, /net, /man, /say, /journal, /ping. Embeds paginate when large and expire after 2 minutes idle; /open-file attachments stay. Token from --token, DISCORD_TOKEN, or ~/.discord/.env. User-installable (Add App to your Discord profile) is on by default so slash commands work in DMs and any server; DISCORD_ID_WHITELIST is always enforced on those surfaces (empty list denies user-install / DM use). DirectMessages intent is requested so DM chat works without the privileged Message Content Intent. The initd unit bare-os-discord is listed by systemctl only when ~/.discord/.env exists.",
"options": [ "options": [
{ {
"flag": "--env PATH", "flag": "--env PATH",
@@ -166,7 +166,7 @@ function discordUsage(argv0) {
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' + 'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' +
'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' + 'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' +
'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' + 'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' +
'Slash commands: /panel /files /edit /create /upload /hdms /holesail /agent /status /bare /sys /svc /fs /net /man /say /r /journal /ping.\n' + 'Slash commands: /panel /files /edit /create /upload /open-file /hdms /holesail /agent /status /bare /sys /svc /fs /net /man /say /r /journal /ping.\n' +
'Commands run as the unlocked session user. Options use Discord autocomplete.\n' + 'Commands run as the unlocked session user. Options use Discord autocomplete.\n' +
'Whitelisted users can DM the bot to talk to /bin/agent (same ~/.agent session).\n' + 'Whitelisted users can DM the bot to talk to /bin/agent (same ~/.agent session).\n' +
'/status sets playing / watching / listening / competing in the member list.\n' + '/status sets playing / watching / listening / competing in the member list.\n' +
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long