Compare commits

...

12 Commits

23 changed files with 432 additions and 43 deletions
+6 -3
View File
@@ -1,5 +1,7 @@
{
"enabledMcpjsonServers": ["storkit"],
"enabledMcpjsonServers": [
"storkit"
],
"permissions": {
"allow": [
"Bash(./server/target/debug/storkit:*)",
@@ -67,7 +69,8 @@
"Bash(tail *)",
"Bash(wc *)",
"Bash(npx vite:*)",
"Bash(npm run dev:*)"
"Bash(npm run dev:*)",
"Bash(stat *)"
]
}
}
}
@@ -0,0 +1,27 @@
---
name: "Element tab-completion display name breaks bot command matching"
---
# Bug 447: Element tab-completion display name breaks bot command matching
## Description
When a user tab-completes a bot mention in Element, the Matrix client inserts the display name (e.g. `timmy ⚡️`) rather than the user ID (`@timmy`). If the display name contains emoji or special characters, the `strip_bot_mention` function in chat::util may fail to match it against the bot name, causing commands like `ambient on` to not be recognized.
## How to Reproduce
1. Set bot display_name to include emoji (e.g. `timmy ⚡️`) in bot.toml\n2. In Element, tab-complete the bot name to get `timmy ⚡️`\n3. Send `timmy ⚡️ ambient on`\n4. The bot does not respond — command not matched
## Actual Result
Bot ignores the command. The display name with emoji doesn't match during strip_bot_mention, so the command text is not correctly extracted.
## Expected Result
Bot should recognize commands regardless of whether the mention was tab-completed with the display name (including emoji) or typed manually as @localpart.
## Acceptance Criteria
- [ ] strip_bot_mention handles display names containing emoji and special characters
- [ ] strip_bot_mention handles Element's tab-completion format (display name followed by colon or comma)
- [ ] Commands work whether the user types @timmy, timmy, or tab-completes timmy ⚡️
@@ -0,0 +1,20 @@
---
name: "Send OAuth login link via chat when credentials are missing"
---
# Story 448: Send OAuth login link via chat when credentials are missing
## User Story
As a storkit user on Matrix or WhatsApp, I want the bot to send me a clickable OAuth authorize link when credentials are missing or expired, so that I can authenticate without terminal access or manually constructing the URL.
## Acceptance Criteria
- [ ] When storkit detects missing or expired credentials during a chat interaction, it sends the user a clickable /oauth/authorize link
- [ ] Works on Matrix and WhatsApp transports
- [ ] After successful OAuth callback, the user can immediately resume chatting without restarting storkit
- [ ] If credentials are already valid, no login link is sent
## Out of Scope
- TBD
Generated
+15 -15
View File
@@ -1915,9 +1915,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.92"
version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
dependencies = [
"cfg-if",
"futures-util",
@@ -4083,7 +4083,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storkit"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"async-stream",
"async-trait",
@@ -4915,9 +4915,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
dependencies = [
"cfg-if",
"once_cell",
@@ -4928,9 +4928,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.65"
version = "0.4.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4938,9 +4938,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -4948,9 +4948,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -4961,9 +4961,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
dependencies = [
"unicode-ident",
]
@@ -5048,9 +5048,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.92"
version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
dependencies = [
"js-sys",
"wasm-bindgen",
+1 -1
View File
@@ -21,7 +21,7 @@ rust-embed = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
sha2 = "0.10"
sha2 = "0.11.0"
serde_yaml = "0.9"
strip-ansi-escapes = "0.2"
tempfile = "3"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "living-spec-standalone",
"version": "0.8.3",
"version": "0.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "living-spec-standalone",
"version": "0.8.3",
"version": "0.8.4",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "living-spec-standalone",
"private": true,
"version": "0.8.3",
"version": "0.8.4",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -19,6 +19,7 @@ vi.mock("./api/client", () => {
setModelPreference: vi.fn(),
cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(),
getOAuthStatus: vi.fn(),
};
class ChatWebSocket {
connect() {}
@@ -65,6 +66,7 @@ describe("App", () => {
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.getOAuthStatus.mockResolvedValue({ authenticated: false, expired: false, expires_at: 0, has_refresh_token: false });
});
async function renderApp() {
+28 -1
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import type { OAuthStatus } from "./api/client";
import { api } from "./api/client";
import { Chat } from "./components/Chat";
import { SelectionScreen } from "./components/selection/SelectionScreen";
@@ -14,6 +15,27 @@ function App() {
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null);
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus | null>(
null,
);
React.useEffect(() => {
let active = true;
function fetchOAuthStatus() {
api
.getOAuthStatus()
.then((s) => {
if (active) setOauthStatus(s);
})
.catch(() => {});
}
fetchOAuthStatus();
const intervalId = window.setInterval(fetchOAuthStatus, 5000);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, []);
React.useEffect(() => {
api
@@ -182,10 +204,15 @@ function App() {
onCloseSuggestions={closeSuggestions}
completionError={completionError}
currentPartial={currentPartial}
oauthStatus={oauthStatus}
/>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
<Chat
projectPath={projectPath}
onCloseProject={closeProject}
oauthStatus={oauthStatus}
/>
</div>
)}
+11
View File
@@ -205,6 +205,13 @@ export interface CommandOutput {
exit_code: number;
}
export interface OAuthStatus {
authenticated: boolean;
expired: boolean;
expires_at: number;
has_refresh_token: boolean;
}
declare const __STORKIT_PORT__: string;
const DEFAULT_API_BASE = "/api";
@@ -402,6 +409,10 @@ export const api = {
deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId });
},
/** Fetch OAuth status from the server. */
getOAuthStatus() {
return requestJson<OAuthStatus>("/oauth/status", {}, "");
},
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>(
+12 -7
View File
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
import { agentsApi } from "../api/agents";
import type {
AnthropicModelInfo,
OAuthStatus,
PipelineState,
WizardStateData,
} from "../api/client";
@@ -164,9 +165,10 @@ const getContextWindowSize = (
interface ChatProps {
projectPath: string;
onCloseProject: () => void;
oauthStatus?: OAuthStatus | null;
}
export function Chat({ projectPath, onCloseProject }: ChatProps) {
export function Chat({ projectPath, onCloseProject, oauthStatus = null }: ChatProps) {
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
const [loading, setLoading] = useState(false);
const [model, setModel] = useState("claude-code-pty");
@@ -615,12 +617,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const sendMessage = async (messageText: string) => {
if (!messageText.trim()) return;
// /help — show available slash commands overlay
if (/^\/help\s*$/i.test(messageText)) {
setShowHelp(true);
return;
}
// /reset — clear session and message history without LLM
if (/^\/reset\s*$/i.test(messageText)) {
setMessages([]);
@@ -657,6 +653,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
"overview",
"rebuild",
"loc",
"help",
"ambient",
"htop",
"rmtree",
"timer",
"unblock",
"unreleased",
"setup",
]);
if (knownCommands.has(cmd)) {
@@ -940,6 +944,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
enableTools={enableTools}
onToggleTools={setEnableTools}
wsConnected={wsConnected}
oauthStatus={oauthStatus}
/>
{/* Two-column content area */}
+56
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import type { OAuthStatus } from "../api/client";
import { api } from "../api/client";
const { useState, useEffect } = React;
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
enableTools: boolean;
onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
oauthStatus?: OAuthStatus | null;
}
const getContextEmoji = (percentage: number): string => {
@@ -55,6 +57,7 @@ export function ChatHeader({
enableTools,
onToggleTools,
wsConnected,
oauthStatus = null,
}: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
const [showConfirm, setShowConfirm] = useState(false);
@@ -340,6 +343,59 @@ export function ChatHeader({
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
{oauthStatus !== null &&
(!oauthStatus.authenticated || oauthStatus.expired) && (
<button
type="button"
title="Authenticate with Claude via OAuth"
onClick={() => {
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
}}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#1a3a5c",
color: "#7eb8f7",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
whiteSpace: "nowrap",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#234d7a";
e.currentTarget.style.color = "#a8d4ff";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#1a3a5c";
e.currentTarget.style.color = "#7eb8f7";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#234d7a";
e.currentTarget.style.color = "#a8d4ff";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#1a3a5c";
e.currentTarget.style.color = "#7eb8f7";
}}
>
{oauthStatus.expired ? "Re-authenticate" : "Login with Claude"}
</button>
)}
{oauthStatus?.authenticated && !oauthStatus.expired && (
<span
title="Authenticated with Claude via OAuth"
style={{
fontSize: "0.8em",
color: "#4caf50",
whiteSpace: "nowrap",
}}
>
Claude
</span>
)}
<div
style={{
fontSize: "0.75em",
@@ -1,4 +1,5 @@
import type { KeyboardEvent } from "react";
import type { OAuthStatus } from "../../api/client";
import { ProjectPathInput } from "./ProjectPathInput.tsx";
import { RecentProjectsList } from "./RecentProjectsList.tsx";
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
onCloseSuggestions: () => void;
completionError: string | null;
currentPartial: string;
oauthStatus?: OAuthStatus | null;
}
export function SelectionScreen({
@@ -43,6 +45,7 @@ export function SelectionScreen({
onCloseSuggestions,
completionError,
currentPartial,
oauthStatus = null,
}: SelectionScreenProps) {
const resolvedHomeDir = homeDir
? homeDir.endsWith("/")
@@ -57,6 +60,37 @@ export function SelectionScreen({
<h1>Storkit</h1>
<p>Paste or complete a project path to start.</p>
{oauthStatus !== null && (
<div style={{ marginBottom: "1rem" }}>
{!oauthStatus.authenticated || oauthStatus.expired ? (
<button
type="button"
onClick={() => {
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
}}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #1a3a5c",
backgroundColor: "#1a3a5c",
color: "#7eb8f7",
cursor: "pointer",
fontSize: "0.9em",
}}
>
{oauthStatus.expired ? "Re-authenticate with Claude" : "Login with Claude"}
</button>
) : (
<span
title="Authenticated with Claude via OAuth"
style={{ color: "#4caf50", fontSize: "0.9em" }}
>
Authenticated with Claude
</span>
)}
</div>
)}
{knownProjects.length > 0 && (
<RecentProjectsList
projects={knownProjects}
+38
View File
@@ -50,10 +50,48 @@ export const SLASH_COMMANDS: SlashCommand[] = [
name: "/overview <number>",
description: "Show the implementation summary for a merged story.",
},
{
name: "/ambient",
description: "Toggle ambient mode: `/ambient on` or `/ambient off`.",
},
{
name: "/htop",
description:
"Show live system and agent process dashboard: `/htop`, `/htop 10m`, `/htop stop`.",
},
{
name: "/loc",
description:
"Show top source files by line count: `/loc` (top 10), `/loc <N>`, or `/loc <filepath>`.",
},
{
name: "/rmtree <number>",
description:
"Delete the worktree for a story without removing it from the pipeline.",
},
{
name: "/rebuild",
description: "Rebuild the server binary and restart.",
},
{
name: "/timer",
description:
"Schedule a deferred agent start: `/timer <story_id> <HH:MM>`, `/timer list`, `/timer cancel <story_id>`.",
},
{
name: "/unblock <number>",
description:
"Reset a blocked story: clears blocked flag and resets retry count.",
},
{
name: "/unreleased",
description: "Show stories merged to master since the last release tag.",
},
{
name: "/setup",
description:
"Show setup wizard progress; or `/setup confirm` / `/setup skip` / `/setup retry` to drive the wizard.",
},
{
name: "/reset",
description:
+14
View File
@@ -30,6 +30,20 @@ export default defineConfig(() => {
proxy.on("error", (_err) => {});
},
},
"/oauth": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {});
},
},
"/callback": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {});
},
},
},
watch: {
ignored: [
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "storkit"
version = "0.8.3"
version = "0.8.4"
edition = "2024"
build = "build.rs"
@@ -616,7 +616,13 @@ pub(super) async fn handle_message(
}
Err(e) => {
slog!("[matrix-bot] LLM error: {e}");
let err_msg = format!("Error processing your request: {e}");
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. [Click here to log in to Claude]({url})"
)
} else {
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
}
@@ -686,6 +692,24 @@ mod tests {
assert_eq!(prompt, "@bob:example.com: What's up?");
}
// -- OAuth login link formatting ----------------------------------------
#[test]
fn oauth_error_produces_login_link() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(msg.contains("[Click here to log in to Claude]"));
}
#[test]
fn non_oauth_error_not_formatted_as_link() {
let err = "Some unrelated error";
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
}
// -- bot_name / system prompt -------------------------------------------
#[test]
+25 -1
View File
@@ -383,7 +383,13 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
}
Err(e) => {
slog!("[whatsapp] LLM error: {e}");
let err_msg = format!("Error processing your request: {e}");
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. Log in to Claude here: {url}"
)
} else {
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
}
@@ -491,6 +497,24 @@ mod tests {
})
}
// ── OAuth login link formatting ───────────────────────────────────────
#[test]
fn whatsapp_oauth_error_produces_plain_text_url() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
}
#[test]
fn whatsapp_non_oauth_error_not_formatted_as_link() {
let err = "Some unrelated error occurred during processing";
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
}
// ── Allowlist tests ───────────────────────────────────────────────────
#[tokio::test]
+49 -5
View File
@@ -46,29 +46,44 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
/// - `@bot_localpart:server.com rest` → `rest`
/// - `@bot_localpart rest` → `rest`
/// - `DisplayName rest` → `rest`
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
return strip_mention_separator(rest);
}
// Try @localpart (e.g. "@timmy")
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
return strip_mention_separator(rest);
}
// Try display name (e.g. "Timmy")
// Try display name (e.g. "Timmy" or "timmy ⚡️")
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
return strip_mention_separator(rest);
}
trimmed
}
/// Strip an optional Element tab-completion separator (`:` or `,`) and
/// surrounding whitespace from the start of text that follows a bot mention.
///
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the
/// name. Without this strip the leading `:` would be treated as part of the
/// command name and no command would match.
fn strip_mention_separator(rest: &str) -> &str {
let rest = rest.trim_start();
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest);
rest.trim_start()
}
/// Returns `true` when `text` ends while inside an open fenced code block.
///
/// A fenced code block opens and closes on lines that start with ` ``` `
@@ -334,7 +349,36 @@ mod tests {
#[test]
fn strip_mention_comma_after_name() {
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_colon_separator_element_tab_completion() {
// Element tab-completes display names with a trailing ": "
let rest = strip_bot_mention(
"timmy ⚡️: ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_emoji_display_name_no_separator() {
// Display name with emoji, no separator
let rest = strip_bot_mention(
"timmy ⚡️ ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_colon_after_localpart() {
// Element may also produce "@timmy: help"
let rest = strip_bot_mention("@timmy: help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "help");
}
// -- drain_complete_paragraphs ------------------------------------------
+33
View File
@@ -161,10 +161,43 @@ pub async fn refresh_access_token() -> Result<(), String> {
Ok(())
}
/// Extract the OAuth login URL from an error message produced by the Claude Code provider.
///
/// The provider returns errors like:
/// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"`
///
/// Returns the URL portion when the error indicates missing or expired credentials,
/// `None` otherwise.
pub fn extract_login_url_from_error(err: &str) -> Option<&str> {
let marker = "Please log in: ";
let start = err.find(marker)?;
Some(err[start + marker.len()..].trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_login_url_from_oauth_error() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = extract_login_url_from_error(err);
assert_eq!(url, Some("http://localhost:3001/oauth/authorize"));
}
#[test]
fn extract_login_url_returns_none_for_unrelated_error() {
let err = "Some other error occurred";
assert!(extract_login_url_from_error(err).is_none());
}
#[test]
fn extract_login_url_with_different_port() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize";
let url = extract_login_url_from_error(err);
assert_eq!(url, Some("http://localhost:3002/oauth/authorize"));
}
#[test]
fn parse_credentials_file() {
let json = r#"{
+4 -2
View File
@@ -138,9 +138,11 @@ impl ClaudeCodeProvider {
on_token("\n*Refreshing authentication token...*\n");
continue;
}
Err(e) => {
Err(_e) => {
let port = crate::http::resolve_port();
let login_url = format!("http://localhost:{port}/oauth/authorize");
return Err(format!(
"OAuth session expired. Please run `claude login` to re-authenticate. ({e})"
"OAuth session expired or credentials missing. Please log in: {login_url}"
));
}
}
+28 -3
View File
@@ -123,7 +123,32 @@ pub async fn rebuild_and_restart(
workspace_root.display()
);
// 3. Build the server binary, matching the current build profile so the
// 3. Rebuild the frontend bundle so rust-embed picks up the latest assets.
let frontend_dir = workspace_root.join("frontend");
if frontend_dir.join("package.json").exists() {
slog!("[rebuild] Building frontend");
let fe_output = tokio::task::spawn_blocking({
let frontend_dir = frontend_dir.clone();
move || {
std::process::Command::new("npm")
.args(["run", "build"])
.current_dir(&frontend_dir)
.output()
}
})
.await
.map_err(|e| format!("Frontend build task panicked: {e}"))?
.map_err(|e| format!("Failed to run npm run build: {e}"))?;
if !fe_output.status.success() {
let stderr = String::from_utf8_lossy(&fe_output.stderr);
slog!("[rebuild] Frontend build failed:\n{stderr}");
return Err(format!("Frontend build failed:\n{stderr}"));
}
slog!("[rebuild] Frontend build succeeded");
}
// 4. Build the server binary, matching the current build profile so the
// re-exec via current_exe() picks up the new binary.
let build_args: Vec<&str> = if cfg!(debug_assertions) {
vec!["build", "-p", "storkit"]
@@ -152,14 +177,14 @@ pub async fn rebuild_and_restart(
slog!("[rebuild] Build succeeded, re-execing with new binary");
// 4. Send shutdown notification before replacing the process so that chat
// 5. Send shutdown notification before replacing the process so that chat
// participants know the bot is going offline. Best-effort only — we
// do not abort the rebuild if the send fails.
if let Some(n) = notifier {
n.notify(ShutdownReason::Rebuild).await;
}
// 5. Re-exec with the new binary.
// 6. Re-exec with the new binary.
// Use the cargo output path rather than current_exe() so that rebuilds
// inside Docker work correctly — the running binary may be installed at
// /usr/local/bin/storkit (read-only) while cargo writes the new binary