Merge branch 'main' into perplexity-search

This commit is contained in:
Kesku 2026-01-25 19:17:27 -08:00 committed by GitHub
commit 824eea4b50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 250 additions and 102 deletions

205
.github/labeler.yml vendored
View File

@ -1,109 +1,172 @@
"channel: bluebubbles": "channel: bluebubbles":
- "extensions/bluebubbles/**" - changed-files:
- "docs/channels/bluebubbles.md" - any-glob-to-any-file:
- "extensions/bluebubbles/**"
- "docs/channels/bluebubbles.md"
"channel: discord": "channel: discord":
- "src/discord/**" - changed-files:
- "extensions/discord/**" - any-glob-to-any-file:
- "docs/channels/discord.md" - "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: googlechat": "channel: googlechat":
- "extensions/googlechat/**" - changed-files:
- "docs/channels/googlechat.md" - any-glob-to-any-file:
- "extensions/googlechat/**"
- "docs/channels/googlechat.md"
"channel: imessage": "channel: imessage":
- "src/imessage/**" - changed-files:
- "extensions/imessage/**" - any-glob-to-any-file:
- "docs/channels/imessage.md" - "src/imessage/**"
- "extensions/imessage/**"
- "docs/channels/imessage.md"
"channel: line": "channel: line":
- "extensions/line/**" - changed-files:
- any-glob-to-any-file:
- "extensions/line/**"
"channel: matrix": "channel: matrix":
- "extensions/matrix/**" - changed-files:
- "docs/channels/matrix.md" - any-glob-to-any-file:
- "extensions/matrix/**"
- "docs/channels/matrix.md"
"channel: mattermost": "channel: mattermost":
- "extensions/mattermost/**" - changed-files:
- "docs/channels/mattermost.md" - any-glob-to-any-file:
- "extensions/mattermost/**"
- "docs/channels/mattermost.md"
"channel: msteams": "channel: msteams":
- "extensions/msteams/**" - changed-files:
- "docs/channels/msteams.md" - any-glob-to-any-file:
- "extensions/msteams/**"
- "docs/channels/msteams.md"
"channel: nextcloud-talk": "channel: nextcloud-talk":
- "extensions/nextcloud-talk/**" - changed-files:
- "docs/channels/nextcloud-talk.md" - any-glob-to-any-file:
- "extensions/nextcloud-talk/**"
- "docs/channels/nextcloud-talk.md"
"channel: nostr": "channel: nostr":
- "extensions/nostr/**" - changed-files:
- "docs/channels/nostr.md" - any-glob-to-any-file:
- "extensions/nostr/**"
- "docs/channels/nostr.md"
"channel: signal": "channel: signal":
- "src/signal/**" - changed-files:
- "extensions/signal/**" - any-glob-to-any-file:
- "docs/channels/signal.md" - "src/signal/**"
- "extensions/signal/**"
- "docs/channels/signal.md"
"channel: slack": "channel: slack":
- "src/slack/**" - changed-files:
- "extensions/slack/**" - any-glob-to-any-file:
- "docs/channels/slack.md" - "src/slack/**"
- "extensions/slack/**"
- "docs/channels/slack.md"
"channel: telegram": "channel: telegram":
- "src/telegram/**" - changed-files:
- "extensions/telegram/**" - any-glob-to-any-file:
- "docs/channels/telegram.md" - "src/telegram/**"
- "extensions/telegram/**"
- "docs/channels/telegram.md"
"channel: tlon": "channel: tlon":
- "extensions/tlon/**" - changed-files:
- "docs/channels/tlon.md" - any-glob-to-any-file:
- "extensions/tlon/**"
- "docs/channels/tlon.md"
"channel: voice-call": "channel: voice-call":
- "extensions/voice-call/**" - changed-files:
- any-glob-to-any-file:
- "extensions/voice-call/**"
"channel: whatsapp-web": "channel: whatsapp-web":
- "src/web/**" - changed-files:
- "extensions/whatsapp/**" - any-glob-to-any-file:
- "docs/channels/whatsapp.md" - "src/web/**"
- "extensions/whatsapp/**"
- "docs/channels/whatsapp.md"
"channel: zalo": "channel: zalo":
- "extensions/zalo/**" - changed-files:
- "docs/channels/zalo.md" - any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zalouser": "channel: zalouser":
- "extensions/zalouser/**" - changed-files:
- "docs/channels/zalouser.md" - any-glob-to-any-file:
- "extensions/zalouser/**"
- "docs/channels/zalouser.md"
"app: android": "app: android":
- "apps/android/**" - changed-files:
- "docs/platforms/android.md" - any-glob-to-any-file:
- "apps/android/**"
- "docs/platforms/android.md"
"app: ios": "app: ios":
- "apps/ios/**" - changed-files:
- "docs/platforms/ios.md" - any-glob-to-any-file:
- "apps/ios/**"
- "docs/platforms/ios.md"
"app: macos": "app: macos":
- "apps/macos/**" - changed-files:
- "docs/platforms/macos.md" - any-glob-to-any-file:
- "docs/platforms/mac/**" - "apps/macos/**"
- "docs/platforms/macos.md"
- "docs/platforms/mac/**"
"app: web-ui": "app: web-ui":
- "ui/**" - changed-files:
- "src/gateway/control-ui.ts" - any-glob-to-any-file:
- "src/gateway/control-ui-shared.ts" - "ui/**"
- "src/infra/control-ui-assets.ts" - "src/gateway/control-ui.ts"
- "src/gateway/control-ui-shared.ts"
"cli": - "src/infra/control-ui-assets.ts"
- "src/cli/**"
- "src/commands/**"
- "src/tui/**"
"gateway": "gateway":
- "src/gateway/**" - changed-files:
- "src/daemon/**" - any-glob-to-any-file:
- "docs/gateway/**" - "src/gateway/**"
- "src/daemon/**"
- "docs/gateway/**"
"docs": "docs":
- "docs/**" - changed-files:
- "docs.acp.md" - any-glob-to-any-file:
- "docs/**"
- "docs.acp.md"
"extensions: copilot-proxy": "extensions: copilot-proxy":
- "extensions/copilot-proxy/**" - changed-files:
- any-glob-to-any-file:
- "extensions/copilot-proxy/**"
"extensions: diagnostics-otel": "extensions: diagnostics-otel":
- "extensions/diagnostics-otel/**" - changed-files:
- any-glob-to-any-file:
- "extensions/diagnostics-otel/**"
"extensions: google-antigravity-auth": "extensions: google-antigravity-auth":
- "extensions/google-antigravity-auth/**" - changed-files:
- any-glob-to-any-file:
- "extensions/google-antigravity-auth/**"
"extensions: google-gemini-cli-auth": "extensions: google-gemini-cli-auth":
- "extensions/google-gemini-cli-auth/**" - changed-files:
- any-glob-to-any-file:
- "extensions/google-gemini-cli-auth/**"
"extensions: llm-task": "extensions: llm-task":
- "extensions/llm-task/**" - changed-files:
- any-glob-to-any-file:
- "extensions/llm-task/**"
"extensions: lobster": "extensions: lobster":
- "extensions/lobster/**" - changed-files:
- any-glob-to-any-file:
- "extensions/lobster/**"
"extensions: memory-core": "extensions: memory-core":
- "extensions/memory-core/**" - changed-files:
- any-glob-to-any-file:
- "extensions/memory-core/**"
"extensions: memory-lancedb": "extensions: memory-lancedb":
- "extensions/memory-lancedb/**" - changed-files:
- any-glob-to-any-file:
- "extensions/memory-lancedb/**"
"extensions: open-prose": "extensions: open-prose":
- "extensions/open-prose/**" - changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: qwen-portal-auth": "extensions: qwen-portal-auth":
- "extensions/qwen-portal-auth/**" - changed-files:
- any-glob-to-any-file:
- "extensions/qwen-portal-auth/**"

View File

@ -12,6 +12,12 @@ jobs:
label: label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/labeler@v5 - uses: actions/labeler@v5
with: with:
configuration-path: .github/labeler.yml configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}

View File

@ -13,6 +13,12 @@ Status: unreleased.
- Docs: add Render deployment guide. (#1975) Thanks @anurag. - Docs: add Render deployment guide. (#1975) Thanks @anurag.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi. - CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn. - macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
## 2026.1.24-3 ## 2026.1.24-3

View File

@ -281,6 +281,7 @@ async function loginAntigravity(params: {
openUrl: (url: string) => Promise<void>; openUrl: (url: string) => Promise<void>;
prompt: (message: string) => Promise<string>; prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>; note: (message: string, title?: string) => Promise<void>;
log: (message: string) => void;
progress: { update: (msg: string) => void; stop: (msg?: string) => void }; progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{ }): Promise<{
access: string; access: string;
@ -314,6 +315,11 @@ async function loginAntigravity(params: {
].join("\n"), ].join("\n"),
"Google Antigravity OAuth", "Google Antigravity OAuth",
); );
// Output raw URL below the box for easy copying (fixes #1772)
params.log("");
params.log("Copy this URL:");
params.log(authUrl);
params.log("");
} }
if (!needsManual) { if (!needsManual) {
@ -382,6 +388,7 @@ const antigravityPlugin = {
openUrl: ctx.openUrl, openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })), prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note, note: ctx.prompter.note,
log: (message) => ctx.runtime.log(message),
progress: spin, progress: spin,
}); });

View File

@ -369,12 +369,13 @@ export async function runAgentTurnWithFallback(params: {
// Use pipeline if available (block streaming enabled), otherwise send directly // Use pipeline if available (block streaming enabled), otherwise send directly
if (params.blockStreamingEnabled && params.blockReplyPipeline) { if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload); params.blockReplyPipeline.enqueue(blockPayload);
} else { } else if (params.blockStreamingEnabled) {
// Send directly when flushing before tool execution (no streaming). // Send directly when flushing before tool execution (no pipeline but streaming enabled).
// Track sent key to avoid duplicate in final payloads. // Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload)); directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload); await params.opts?.onBlockReply?.(blockPayload);
} }
// When streaming is disabled entirely, blocks are accumulated in final text instead.
} }
: undefined, : undefined,
onBlockReplyFlush: onBlockReplyFlush:

View File

@ -337,12 +337,56 @@ async function pageTargetId(page: Page): Promise<string | null> {
} }
} }
async function findPageByTargetId(browser: Browser, targetId: string): Promise<Page | null> { async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
): Promise<Page | null> {
const pages = await getAllPages(browser); const pages = await getAllPages(browser);
// First, try the standard CDP session approach
for (const page of pages) { for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null); const tid = await pageTargetId(page).catch(() => null);
if (tid && tid === targetId) return page; if (tid && tid === targetId) return page;
} }
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
// fall back to URL-based matching using the /json/list endpoint
if (cdpUrl) {
try {
const baseUrl = cdpUrl
.replace(/\/+$/, "")
.replace(/^ws:/, "http:")
.replace(/\/cdp$/, "");
const response = await fetch(`${baseUrl}/json/list`);
if (response.ok) {
const targets = (await response.json()) as Array<{
id: string;
url: string;
title?: string;
}>;
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}
}
}
}
} catch {
// Ignore fetch errors and fall through to return null
}
}
return null; return null;
} }
@ -355,7 +399,7 @@ export async function getPageForTargetId(opts: {
if (!pages.length) throw new Error("No pages available in the connected browser."); if (!pages.length) throw new Error("No pages available in the connected browser.");
const first = pages[0]; const first = pages[0];
if (!opts.targetId) return first; if (!opts.targetId) return first;
const found = await findPageByTargetId(browser, opts.targetId); const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) { if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget), // Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright // which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
@ -496,7 +540,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
targetId: string; targetId: string;
}): Promise<void> { }): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl); const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) { if (!page) {
throw new Error("tab not found"); throw new Error("tab not found");
} }
@ -512,7 +556,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
targetId: string; targetId: string;
}): Promise<void> { }): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl); const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) { if (!page) {
throw new Error("tab not found"); throw new Error("tab not found");
} }

View File

@ -13,11 +13,9 @@ const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) { function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false }); const mediaUrl = readStringParam(params, "media", { trim: false });
const content = const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
readStringParam(params, "message", { const caption = readStringParam(params, "caption", { allowEmpty: true });
required: !mediaUrl, const content = message || caption || "";
allowEmpty: true,
}) ?? "";
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
const buttons = params.buttons; const buttons = params.buttons;

View File

@ -129,9 +129,10 @@ export async function checkGitUpdateStatus(params: {
).catch(() => null); ).catch(() => null);
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null; const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], { const dirtyRes = await runCommandWithTimeout(
timeoutMs, ["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"],
}).catch(() => null); { timeoutMs },
).catch(() => null);
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null; const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const fetchOk = params.fetch const fetchOk = params.fetch

View File

@ -44,7 +44,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
}); });
const result = await runGatewayUpdate({ const result = await runGatewayUpdate({
@ -69,7 +69,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { [`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main", stdout: "origin/main",
}, },
@ -103,7 +103,7 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({ const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" }, [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
stdout: `${stableTag}\n${betaTag}\n`, stdout: `${stableTag}\n${betaTag}\n`,
@ -112,6 +112,7 @@ describe("runGatewayUpdate", () => {
"pnpm install": { stdout: "" }, "pnpm install": { stdout: "" },
"pnpm build": { stdout: "" }, "pnpm build": { stdout: "" },
"pnpm ui:build": { stdout: "" }, "pnpm ui:build": { stdout: "" },
[`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
"pnpm clawdbot doctor --non-interactive": { stdout: "" }, "pnpm clawdbot doctor --non-interactive": { stdout: "" },
}); });

View File

@ -346,10 +346,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const channel: UpdateChannel = opts.channel ?? "dev"; const channel: UpdateChannel = opts.channel ?? "dev";
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null; const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH; const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8; gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
const statusCheck = await runStep( const statusCheck = await runStep(
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot), step(
"clean check",
["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"],
gitRoot,
),
); );
steps.push(statusCheck); steps.push(statusCheck);
const hasUncommittedChanges = const hasUncommittedChanges =
@ -654,6 +658,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
); );
steps.push(uiBuildStep); steps.push(uiBuildStep);
// Restore dist/control-ui/ to committed state to prevent dirty repo after update
// (ui:build regenerates assets with new hashes, which would block future updates)
const restoreUiStep = await runStep(
step(
"restore control-ui",
["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
gitRoot,
),
);
steps.push(restoreUiStep);
const doctorStep = await runStep( const doctorStep = await runStep(
step( step(
"clawdbot doctor", "clawdbot doctor",

View File

@ -11,6 +11,12 @@ export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main"; export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default"; export const DEFAULT_ACCOUNT_ID = "default";
// Pre-compiled regex
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
function normalizeToken(value: string | undefined | null): string { function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase(); return (value ?? "").trim().toLowerCase();
} }
@ -52,14 +58,14 @@ export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID; if (!trimmed) return DEFAULT_AGENT_ID;
// Keep it path-safe + shell-friendly. // Keep it path-safe + shell-friendly.
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
// Best-effort fallback: collapse invalid characters to "-" // Best-effort fallback: collapse invalid characters to "-"
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID .slice(0, 64) || DEFAULT_AGENT_ID
); );
} }
@ -67,13 +73,13 @@ export function normalizeAgentId(value: string | undefined | null): string {
export function sanitizeAgentId(value: string | undefined | null): string { export function sanitizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID; if (!trimmed) return DEFAULT_AGENT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID .slice(0, 64) || DEFAULT_AGENT_ID
); );
} }
@ -81,13 +87,13 @@ export function sanitizeAgentId(value: string | undefined | null): string {
export function normalizeAccountId(value: string | undefined | null): string { export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID; if (!trimmed) return DEFAULT_ACCOUNT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-") .replace(INVALID_CHARS_RE, "-")
.replace(/^-+/, "") .replace(LEADING_DASH_RE, "")
.replace(/-+$/, "") .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID .slice(0, 64) || DEFAULT_ACCOUNT_ID
); );
} }