Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
flake.lock linguist-generated=true
8 changes: 5 additions & 3 deletions .github/workflows/update-nix-sources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Install Nix
uses: cachix/install-nix-action@v31
uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31

- name: Update nix sources
run: ./scripts/update-nix-sources.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Create pull request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
commit-message: "chore(nix): update release hashes"
title: "chore(nix): update release hashes"
Expand Down
9 changes: 7 additions & 2 deletions scripts/update-nix-sources.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ if ! command -v nix >/dev/null 2>&1; then
exit 1
fi

tag="$(curl -fsSL "$latest_api" | jq -r '.tag_name')"
curl_opts=(-fsSL)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
curl_opts+=(-H "Authorization: token $GITHUB_TOKEN")
fi

tag="$(curl "${curl_opts[@]}" "$latest_api" | jq -r '.tag_name')"
if [[ -z "$tag" || "$tag" == "null" ]]; then
echo "error: unable to resolve latest release tag" >&2
exit 1
fi

version="${tag#v}"
checksums_url="https://github.com/${repo}/releases/download/${tag}/checksums-sha256.txt"
checksums="$(curl -fsSL "$checksums_url")"
checksums="$(curl "${curl_opts[@]}" "$checksums_url")"

get_sri() {
local asset="$1"
Expand Down
14 changes: 0 additions & 14 deletions skills/agent-slack/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,6 @@ Run `agent-slack --help` (or `agent-slack <command> --help`) for the full option
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--ts <seconds>.<micros>` (required for channel targets)

- `agent-slack message edit <target> <text>`
- URL target edits that exact message.
- Channel target requires `--ts`.
- Options:
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--ts <seconds>.<micros>` (required for channel targets)

- `agent-slack message delete <target>`
- URL target deletes that exact message.
- Channel target requires `--ts`.
- Options:
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--ts <seconds>.<micros>` (required for channel targets)

- `agent-slack message react add <target> <emoji>`
- `agent-slack message react remove <target> <emoji>`
- Options (channel mode):
Expand Down
43 changes: 3 additions & 40 deletions src/auth/brave.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { queryReadonlySqlite } from "./firefox-profile.ts";
import { decryptChromiumCookieValue } from "./chromium-cookie.ts";
import { isRecord } from "../lib/object-type-guards.ts";

type BraveExtractedTeam = { url: string; name?: string; token: string };

Expand Down Expand Up @@ -49,10 +50,6 @@ function teamsScript(): string {
`;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function toBraveTeam(value: unknown): BraveExtractedTeam | null {
if (!isRecord(value)) {
return null;
Expand Down Expand Up @@ -117,40 +114,6 @@ function getSafeStoragePasswords(): string[] {
return passwords;
}

function decryptChromiumCookieValue(data: Buffer, password: string): string {
if (!data || data.length === 0) {
return "";
}

const salt = Buffer.from("saltysalt", "utf8");
const iv = Buffer.alloc(16, " ");
const key = pbkdf2Sync(password, salt, 1003, 16, "sha1");

const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true);
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
const marker = Buffer.from("xoxd-");
const idx = plain.indexOf(marker);
if (idx === -1) {
return plain.toString("utf8");
}

let end = idx;
while (end < plain.length) {
const b = plain[end]!;
if (b < 0x21 || b > 0x7e) {
break;
}
end++;
}
const rawToken = plain.subarray(idx, end).toString("utf8");
try {
return decodeURIComponent(rawToken);
} catch {
return rawToken;
}
}

async function extractCookieDFromBrave(): Promise<string> {
if (!existsSync(BRAVE_COOKIES_DB)) {
throw new Error(`Brave Cookies DB not found: ${BRAVE_COOKIES_DB}`);
Expand Down Expand Up @@ -185,7 +148,7 @@ async function extractCookieDFromBrave(): Promise<string> {

for (const password of passwords) {
try {
const decrypted = decryptChromiumCookieValue(data, password);
const decrypted = decryptChromiumCookieValue(data, password, 1003);
const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/);
if (match) {
return match[0]!;
Expand Down
43 changes: 43 additions & 0 deletions src/auth/chromium-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { pbkdf2Sync, createDecipheriv } from "node:crypto";

export function decryptChromiumCookieValue(
data: Buffer,
password: string,
iterations: number,
): string {
if (!data || data.length === 0) {
return "";
}

if (iterations < 1) {
throw new RangeError(`iterations must be >= 1, got ${iterations}`);
}

const salt = Buffer.from("saltysalt", "utf8");
const iv = Buffer.alloc(16, " ");
const key = pbkdf2Sync(password, salt, iterations, 16, "sha1");

const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true);
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
const marker = Buffer.from("xoxd-");
const idx = plain.indexOf(marker);
if (idx === -1) {
return plain.toString("utf8");
}

let end = idx;
while (end < plain.length) {
const b = plain[end]!;
if (b < 0x21 || b > 0x7e) {
break;
}
end++;
}
const rawToken = plain.subarray(idx, end).toString("utf8");
try {
return decodeURIComponent(rawToken);
} catch {
return rawToken;
}
}
115 changes: 18 additions & 97 deletions src/auth/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,61 +10,13 @@ import {
unlinkSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
import { createDecipheriv, randomUUID } from "node:crypto";
import { homedir, platform, tmpdir } from "node:os";
import { join } from "node:path";
import { findKeysContaining } from "../lib/leveldb-reader.js";

type SqliteRow = Record<string, unknown>;

function isMissingBunSqliteModule(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const err = error as { code?: unknown; message?: unknown };
const code = typeof err.code === "string" ? err.code : "";
const message = typeof err.message === "string" ? err.message : "";

if (code === "ERR_MODULE_NOT_FOUND" || code === "ERR_UNSUPPORTED_ESM_URL_SCHEME") {
return true;
}
if (!message.includes("bun:sqlite")) {
return false;
}
return (
message.includes("Cannot find module") ||
message.includes("Unknown builtin module") ||
message.includes("unsupported URL scheme") ||
message.includes("Only URLs with a scheme in")
);
}

/**
* Query a SQLite database in read-only mode.
* Uses bun:sqlite when running under Bun, falls back to node:sqlite (Node >= 22.5).
*/
async function queryReadonlySqlite(dbPath: string, sql: string): Promise<SqliteRow[]> {
try {
const { Database } = await import("bun:sqlite");
const db = new Database(dbPath, { readonly: true });
try {
return db.query(sql).all() as SqliteRow[];
} finally {
db.close();
}
} catch (error) {
if (!isMissingBunSqliteModule(error)) {
throw error;
}
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
return db.prepare(sql).all() as SqliteRow[];
} finally {
db.close();
}
}
}
import { isRecord } from "../lib/object-type-guards.ts";
import { queryReadonlySqlite } from "./firefox-profile.ts";
import { decryptChromiumCookieValue } from "./chromium-cookie.ts";

type DesktopTeam = { url: string; name?: string; token: string };

Expand Down Expand Up @@ -164,10 +116,6 @@ function getSlackPaths(): { leveldbDir: string; cookiesDb: string; baseDir: stri
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function toDesktopTeam(value: unknown): DesktopTeam | null {
if (!isRecord(value)) {
return null;
Expand Down Expand Up @@ -364,40 +312,6 @@ function getSafeStoragePasswords(prefix: string): string[] {
throw new Error("Could not read Safe Storage password from desktop keychain.");
}

function decryptChromiumCookieValue(data: Buffer, password: string): string {
if (!data || data.length === 0) {
return "";
}

const salt = Buffer.from("saltysalt", "utf8");
const iv = Buffer.alloc(16, " ");
const key = pbkdf2Sync(password, salt, IS_LINUX ? 1 : 1003, 16, "sha1");

const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true);
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
const marker = Buffer.from("xoxd-");
const idx = plain.indexOf(marker);
if (idx === -1) {
return plain.toString("utf8");
}

let end = idx;
while (end < plain.length) {
const b = plain[end]!;
if (b < 0x21 || b > 0x7e) {
break;
}
end++;
}
const rawToken = plain.subarray(idx, end).toString("utf8");
try {
return decodeURIComponent(rawToken);
} catch {
return rawToken;
}
}

/**
* Decrypt a Chromium cookie on Windows using DPAPI + AES-256-GCM.
*
Expand All @@ -413,18 +327,25 @@ function decryptCookieWindows(encrypted: Buffer, slackDataDir: string): string {
if (!existsSync(localStatePath)) {
throw new Error(`Local State file not found: ${localStatePath}`);
}
const localState = JSON.parse(readFileSync(localStatePath, "utf8"));
if (!localState.os_crypt?.encrypted_key) {
let localState: unknown;
try {
localState = JSON.parse(readFileSync(localStatePath, "utf8"));
} catch (error) {
throw new Error(`Failed to parse Local State file: ${localStatePath}`, { cause: error });
}
const osCrypt = isRecord(localState) ? localState.os_crypt : undefined;
if (!isRecord(osCrypt) || typeof osCrypt.encrypted_key !== "string") {
throw new Error("No os_crypt.encrypted_key in Local State");
}
const encKeyFull = Buffer.from(localState.os_crypt.encrypted_key, "base64");
const encKeyFull = Buffer.from(osCrypt.encrypted_key as string, "base64");
// Skip "DPAPI" prefix (5 bytes)
const encKeyBlob = encKeyFull.subarray(5);

// Decrypt AES key via Windows DPAPI using PowerShell
const encKeyFile = join(tmpdir(), `as-key-enc-${Date.now()}.bin`);
const decKeyFile = join(tmpdir(), `as-key-dec-${Date.now()}.bin`);
writeFileSync(encKeyFile, encKeyBlob);
const id = randomUUID();
const encKeyFile = join(tmpdir(), `as-key-enc-${id}.bin`);
const decKeyFile = join(tmpdir(), `as-key-dec-${id}.bin`);
writeFileSync(encKeyFile, encKeyBlob, { mode: 0o600 });
try {
// Escape single quotes for PowerShell single-quoted strings (' → '')
const psEncKeyFile = encKeyFile.replaceAll("'", "''");
Expand Down Expand Up @@ -542,7 +463,7 @@ async function extractCookieDFromSlackCookiesDb(

for (const password of passwords) {
try {
const decrypted = decryptChromiumCookieValue(data, password);
const decrypted = decryptChromiumCookieValue(data, password, IS_LINUX ? 1 : 1003);
const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/);
if (match) {
return match[0]!;
Expand Down
5 changes: 1 addition & 4 deletions src/lib/fs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { isRecord } from "./object-type-guards.ts";

export async function readJsonFile<T>(path: string): Promise<T | null> {
try {
Expand All @@ -21,7 +22,3 @@ export async function writeJsonFile(path: string, data: unknown): Promise<void>
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
4 changes: 4 additions & 0 deletions src/slack/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ export async function fetchChannelHistory(
break;
}

if (!resp.has_more) {
break;
}

const last = messages.at(-1);
const nextLatest = isRecord(last) ? getString(last.ts) : undefined;
if (!nextLatest || nextLatest === cursorLatest) {
Expand Down
4 changes: 1 addition & 3 deletions src/slack/render-rich-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
* Rich-text block rendering helpers extracted from render.ts.
*/

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
import { isRecord } from "../lib/object-type-guards.ts";

function getString(value: unknown): string {
return typeof value === "string" ? value : "";
Expand Down