fix: surface upstream gateway timeout for remote OpenClaw/Tailscale connections (#94)
* surface gateway timeout for tailscale * talescale fix #2 - attempt 1 * luke findings fix#1 * add narrow log for clientId * prod safe proxy log * fix log visibility * LAN connection & subagent SOUL|IDENTITY fixes * Initialize missing files for subagent SOUL|IDENTITY * surface missing files in UI * capturing agent - runtime,identity,session * plugin-install fix * fix: recover agent workspace for marketplace installs * fix: recover agent workspace and identity name from file provenance * fix: tolerate webchat session patch blocks during permission updates
This commit is contained in:
@@ -313,6 +313,16 @@ export const GatewayConnectScreen = ({
|
||||
metadata scaffold are now in place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">Opening Claw3D from another machine?</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
Start Studio with <span className="font-mono text-foreground">HOST=0.0.0.0</span> (or a
|
||||
specific LAN/Tailscale host) and set
|
||||
<span className="font-mono text-foreground"> STUDIO_ACCESS_TOKEN</span> before exposing it
|
||||
beyond localhost. Gateway settings are stored on the Studio host, but OpenClaw device approval
|
||||
remains per browser/device.
|
||||
</p>
|
||||
</div>
|
||||
{localGatewayDefaults ? (
|
||||
<div className="ui-input rounded-md px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -7,10 +7,14 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
|
||||
import {
|
||||
AGENT_FILE_META,
|
||||
AGENT_FILE_PLACEHOLDERS,
|
||||
PERSONALITY_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
} from "@/lib/agents/agentFiles";
|
||||
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
|
||||
import {
|
||||
createEmptyPersonalityDraft,
|
||||
parsePersonalityFiles,
|
||||
serializePersonalityFiles,
|
||||
} from "@/lib/agents/personalityBuilder";
|
||||
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
||||
|
||||
export type AgentBrainPanelProps = {
|
||||
@@ -36,6 +40,30 @@ const AgentBrainPanelSection = ({
|
||||
</section>
|
||||
);
|
||||
|
||||
const AgentFileProvenance = ({
|
||||
path,
|
||||
workspace,
|
||||
}: {
|
||||
path: string | null;
|
||||
workspace: string | null;
|
||||
}) => {
|
||||
if (!path && !workspace) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-black/20 px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{workspace ? (
|
||||
<div>
|
||||
Workspace: <span className="font-mono text-foreground">{workspace}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{path ? (
|
||||
<div>
|
||||
File: <span className="font-mono text-foreground">{path}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentBrainPanel = ({
|
||||
client,
|
||||
agents,
|
||||
@@ -61,9 +89,14 @@ export const AgentBrainPanel = ({
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
initializeAgentFiles,
|
||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const missingPersonalityFiles = useMemo(
|
||||
() => PERSONALITY_FILE_NAMES.filter((name) => !agentFiles[name].exists),
|
||||
[agentFiles]
|
||||
);
|
||||
|
||||
const setIdentityField = useCallback(
|
||||
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
||||
@@ -101,6 +134,19 @@ export const AgentBrainPanel = ({
|
||||
selectedAgent,
|
||||
]);
|
||||
|
||||
const handleInitializeMissingFiles = useCallback(async () => {
|
||||
if (!selectedAgent) return;
|
||||
setSaveError(null);
|
||||
const nextDraft = createEmptyPersonalityDraft();
|
||||
nextDraft.identity.name = selectedAgent.name.trim();
|
||||
nextDraft.identity.creature = selectedAgent.role?.trim() ?? "";
|
||||
const serialized = serializePersonalityFiles(nextDraft);
|
||||
const missingEntries = Object.fromEntries(
|
||||
missingPersonalityFiles.map((name) => [name, serialized[name]])
|
||||
) as Partial<Record<AgentFileName, string>>;
|
||||
await initializeAgentFiles(missingEntries);
|
||||
}, [initializeAgentFiles, missingPersonalityFiles, selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(agentFilesDirty);
|
||||
}, [agentFilesDirty, onUnsavedChangesChange]);
|
||||
@@ -112,21 +158,36 @@ export const AgentBrainPanel = ({
|
||||
}, [onUnsavedChangesChange]);
|
||||
|
||||
const renderMarkdownEditor = useCallback(
|
||||
(name: Exclude<AgentFileName, "IDENTITY.md">) => (
|
||||
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
||||
<textarea
|
||||
aria-label={AGENT_FILE_META[name].title}
|
||||
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={agentFiles[name].content}
|
||||
placeholder={AGENT_FILE_PLACEHOLDERS[name]}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent(name, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
),
|
||||
(name: Exclude<AgentFileName, "IDENTITY.md">) => {
|
||||
const file = agentFiles[name];
|
||||
const trimmedContent = file.content.trim();
|
||||
const statusCopy = !file.exists
|
||||
? `This agent does not have a custom ${name} yet. Saving here will create the real workspace file.`
|
||||
: !trimmedContent
|
||||
? `This agent's ${name} exists, but it is currently empty.`
|
||||
: null;
|
||||
return (
|
||||
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
||||
{statusCopy ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{statusCopy}
|
||||
</div>
|
||||
) : null}
|
||||
<AgentFileProvenance path={file.path} workspace={file.workspace} />
|
||||
<textarea
|
||||
aria-label={AGENT_FILE_META[name].title}
|
||||
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={file.content}
|
||||
placeholder={!file.exists ? `No ${name} yet.` : ""}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent(name, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
);
|
||||
},
|
||||
[agentFiles, agentFilesLoading, agentFilesSaving, setAgentFileContent],
|
||||
);
|
||||
|
||||
@@ -141,6 +202,10 @@ export const AgentBrainPanel = ({
|
||||
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
|
||||
when you save.
|
||||
</div>
|
||||
<AgentFileProvenance
|
||||
path={agentFiles["IDENTITY.md"].path}
|
||||
workspace={agentFiles["IDENTITY.md"].workspace}
|
||||
/>
|
||||
<AgentIdentityFields
|
||||
values={draft.identity}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
@@ -191,6 +256,18 @@ export const AgentBrainPanel = ({
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
||||
{missingPersonalityFiles.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 text-xs"
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onClick={() => {
|
||||
void handleInitializeMissingFiles();
|
||||
}}
|
||||
>
|
||||
Initialize missing files
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
readGatewayAgentFile,
|
||||
writeGatewayAgentFile,
|
||||
writeGatewayAgentFiles,
|
||||
} from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
AGENT_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
@@ -21,6 +25,7 @@ export type UseAgentFilesEditorResult = {
|
||||
agentFilesError: string | null;
|
||||
setAgentFileContent: (name: AgentFileName, value: string) => void;
|
||||
saveAgentFiles: () => Promise<boolean>;
|
||||
initializeAgentFiles: (files: Partial<Record<AgentFileName, string>>) => Promise<boolean>;
|
||||
discardAgentFileChanges: () => void;
|
||||
};
|
||||
|
||||
@@ -67,7 +72,13 @@ export const useAgentFilesEditor = (params: {
|
||||
const results = await Promise.all(
|
||||
AGENT_FILE_NAMES.map(async (name) => {
|
||||
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name });
|
||||
return { name, content: file.content, exists: file.exists };
|
||||
return {
|
||||
name,
|
||||
content: file.content,
|
||||
exists: file.exists,
|
||||
path: file.path,
|
||||
workspace: file.workspace,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,6 +88,8 @@ export const useAgentFilesEditor = (params: {
|
||||
nextState[file.name] = {
|
||||
content: file.content ?? "",
|
||||
exists: Boolean(file.exists),
|
||||
path: file.path ?? null,
|
||||
workspace: file.workspace ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +136,8 @@ export const useAgentFilesEditor = (params: {
|
||||
nextState[name] = {
|
||||
content: agentFiles[name].content,
|
||||
exists: true,
|
||||
path: agentFiles[name].path,
|
||||
workspace: agentFiles[name].workspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +154,55 @@ export const useAgentFilesEditor = (params: {
|
||||
}
|
||||
}, [agentFiles, agentId, client]);
|
||||
|
||||
const initializeAgentFiles = useCallback(
|
||||
async (files: Partial<Record<AgentFileName, string>>) => {
|
||||
setAgentFilesSaving(true);
|
||||
setAgentFilesError(null);
|
||||
|
||||
try {
|
||||
const trimmedAgentId = agentId?.trim();
|
||||
if (!trimmedAgentId) {
|
||||
setAgentFilesError("Agent ID is missing for this agent.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
setAgentFilesError("Gateway client is not available.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await writeGatewayAgentFiles({
|
||||
client,
|
||||
agentId: trimmedAgentId,
|
||||
files,
|
||||
});
|
||||
|
||||
const nextState = cloneAgentFilesState(savedAgentFilesRef.current);
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
if (!isAgentFileName(name) || typeof content !== "string") continue;
|
||||
nextState[name] = {
|
||||
content,
|
||||
exists: true,
|
||||
path: nextState[name].path,
|
||||
workspace: nextState[name].workspace,
|
||||
};
|
||||
}
|
||||
|
||||
savedAgentFilesRef.current = nextState;
|
||||
setAgentFiles(nextState);
|
||||
setAgentFilesDirty(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to initialize agent files.";
|
||||
setAgentFilesError(message);
|
||||
return false;
|
||||
} finally {
|
||||
setAgentFilesSaving(false);
|
||||
}
|
||||
},
|
||||
[agentId, client, cloneAgentFilesState]
|
||||
);
|
||||
|
||||
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
|
||||
if (!isAgentFileName(name)) return;
|
||||
|
||||
@@ -167,6 +231,7 @@ export const useAgentFilesEditor = (params: {
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
initializeAgentFiles,
|
||||
discardAgentFileChanges,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,6 +63,19 @@ type ExecApprovalsSnapshot = {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const parseIdentityNameFromContent = (content: string): string | null => {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (/^##\s+/.test(trimmed)) break;
|
||||
const normalized = trimmed.replace(/^[-*]\s*/, "");
|
||||
const match = /^name\s*:\s*(.+)$/i.exec(normalized);
|
||||
if (!match) continue;
|
||||
const value = match[1]?.trim().replace(/^[*_]+|[*_]+$/g, "").trim() ?? "";
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveAgentsListFromHelloSnapshot = (snapshot: unknown): AgentsListResult | null => {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const health = isRecord(snapshot.health) ? snapshot.health : null;
|
||||
@@ -175,8 +188,41 @@ export async function hydrateAgentFleetFromGateway(params: {
|
||||
}
|
||||
agentsResult = {
|
||||
...agentsResult,
|
||||
agents: agentsResult.agents.filter(
|
||||
(agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name)
|
||||
agents: await Promise.all(
|
||||
agentsResult.agents.map(async (agent) => {
|
||||
const identityName =
|
||||
typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
if (identityName) {
|
||||
return agent;
|
||||
}
|
||||
try {
|
||||
const result = (await params.client.call("agents.files.get", {
|
||||
agentId: agent.id,
|
||||
name: "IDENTITY.md",
|
||||
})) as { file?: { missing?: unknown; content?: unknown } };
|
||||
const file = result?.file;
|
||||
const record =
|
||||
file && typeof file === "object" ? (file as Record<string, unknown>) : null;
|
||||
if (record?.missing === true || typeof record?.content !== "string") {
|
||||
return agent;
|
||||
}
|
||||
const recoveredName = parseIdentityNameFromContent(record.content);
|
||||
if (!recoveredName) {
|
||||
return agent;
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
identity: {
|
||||
...(agent.identity ?? {}),
|
||||
name: recoveredName,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return agent;
|
||||
}
|
||||
})
|
||||
).then((agents) =>
|
||||
agents.filter((agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name))
|
||||
),
|
||||
};
|
||||
const mainKey = agentsResult.mainKey?.trim() || "main";
|
||||
|
||||
@@ -119,13 +119,23 @@ const normalizeExecAsk = (raw: string | null | undefined): ExecAsk | undefined =
|
||||
};
|
||||
|
||||
const resolveAgentName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
if (fromList) return fromList;
|
||||
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
if (fromIdentity) return fromIdentity;
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
if (fromList) return fromList;
|
||||
return agent.id;
|
||||
};
|
||||
|
||||
const resolveRuntimeName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
return fromList || null;
|
||||
};
|
||||
|
||||
const resolveIdentityName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
return fromIdentity || null;
|
||||
};
|
||||
|
||||
const resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => {
|
||||
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
|
||||
if (typeof candidate !== "string") return null;
|
||||
@@ -215,7 +225,11 @@ export const deriveHydrateAgentFleetResult = (
|
||||
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
|
||||
const avatarUrl = resolveAgentAvatarUrl(agent);
|
||||
const name = resolveAgentName(agent);
|
||||
const runtimeName = resolveRuntimeName(agent);
|
||||
const identityName = resolveIdentityName(agent);
|
||||
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
||||
const sessionDisplayName =
|
||||
typeof mainSession?.displayName === "string" ? mainSession.displayName.trim() || null : null;
|
||||
const modelProvider =
|
||||
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
|
||||
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
|
||||
@@ -252,6 +266,9 @@ export const deriveHydrateAgentFleetResult = (
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name,
|
||||
runtimeName,
|
||||
identityName,
|
||||
sessionDisplayName,
|
||||
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
|
||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||
avatarSeed,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
readGatewayAgentExecApprovals,
|
||||
upsertGatewayAgentExecApprovals,
|
||||
@@ -318,6 +321,32 @@ const upsertExecApprovalsPolicyForRole = async (params: {
|
||||
});
|
||||
};
|
||||
|
||||
const syncExecutionRoleSessionSettings = async (params: {
|
||||
client: GatewayClient;
|
||||
sessionKey: string;
|
||||
role: ExecutionRoleId;
|
||||
sandboxMode?: string | null;
|
||||
}) => {
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role: params.role,
|
||||
sandboxMode: params.sandboxMode ?? "",
|
||||
});
|
||||
try {
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isWebchatSessionMutationBlockedError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export async function updateAgentPermissionsViaStudio(params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
@@ -354,16 +383,11 @@ export async function updateAgentPermissionsViaStudio(params: {
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
await syncExecutionRoleSessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
|
||||
if (params.loadAgents) {
|
||||
@@ -403,16 +427,11 @@ export async function updateExecutionRoleViaStudio(params: {
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role: params.role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
await syncExecutionRoleSessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
role: params.role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
|
||||
await params.loadAgents();
|
||||
|
||||
@@ -26,6 +26,9 @@ export type FocusFilter = "all" | "running" | "approvals";
|
||||
export type AgentStoreSeed = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
runtimeName?: string | null;
|
||||
identityName?: string | null;
|
||||
sessionDisplayName?: string | null;
|
||||
role?: string | null;
|
||||
sessionKey: string;
|
||||
avatarSeed?: string | null;
|
||||
|
||||
@@ -502,7 +502,12 @@ export function SkillsMarketplacePanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={primaryAction.run}
|
||||
disabled={marketplace.busySkillKey === entry.skill.skillKey}
|
||||
disabled={
|
||||
marketplace.busySkillKey === entry.skill.skillKey ||
|
||||
(packageOnly && !marketplace.selectedAgentId) ||
|
||||
(primaryAction.label === "Open settings" &&
|
||||
!marketplace.selectedAgentId)
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<PrimaryIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -286,12 +286,14 @@ export const useOfficeSkillsMarketplace = ({
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedSkillsDir: report.managedSkillsDir,
|
||||
agentId: selectedAgent?.agentId ?? undefined,
|
||||
agentName: selectedAgent?.name ?? undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, runSkillMutation]
|
||||
[client, runSkillMutation, selectedAgent]
|
||||
);
|
||||
|
||||
const handleInstallPackagedSkillAndEnable = useCallback(
|
||||
@@ -340,6 +342,9 @@ export const useOfficeSkillsMarketplace = ({
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: initialReport.workspaceDir,
|
||||
managedSkillsDir: initialReport.managedSkillsDir,
|
||||
agentId: targetAgentId,
|
||||
agentName:
|
||||
agents.find((agent) => agent.agentId === targetAgentId)?.name ?? undefined,
|
||||
},
|
||||
});
|
||||
params.onProgress?.({
|
||||
|
||||
@@ -72,5 +72,5 @@ export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
|
||||
|
||||
export const createAgentFilesState = () =>
|
||||
Object.fromEntries(
|
||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
|
||||
) as Record<AgentFileName, { content: string; exists: boolean }>;
|
||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false, path: null, workspace: null }])
|
||||
) as Record<AgentFileName, { content: string; exists: boolean; path: string | null; workspace: string | null }>;
|
||||
|
||||
@@ -28,7 +28,10 @@ export type PersonalityBuilderDraft = {
|
||||
memory: string;
|
||||
};
|
||||
|
||||
type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>;
|
||||
type AgentFilesInput = Record<
|
||||
AgentFileName,
|
||||
{ content: string; exists: boolean; path: string | null; workspace: string | null }
|
||||
>;
|
||||
|
||||
export const createEmptyPersonalityDraft = (): PersonalityBuilderDraft => ({
|
||||
identity: {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from "@/lib/studio/coordinator";
|
||||
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
|
||||
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
|
||||
@@ -117,10 +118,24 @@ const DEFAULT_UPSTREAM_GATEWAY_URL =
|
||||
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
|
||||
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
|
||||
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
|
||||
const OPENCLAW_CONTROL_UI_CLIENT_ID = "openclaw-control-ui";
|
||||
const OPENCLAW_WEBCHAT_UI_CLIENT_ID = "webchat-ui";
|
||||
|
||||
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
|
||||
adapterType !== "custom";
|
||||
|
||||
export const resolveGatewayClientName = (
|
||||
adapterType: StudioGatewayAdapterType,
|
||||
gatewayUrl: string
|
||||
) => {
|
||||
if (adapterType !== "openclaw") {
|
||||
return OPENCLAW_CONTROL_UI_CLIENT_ID;
|
||||
}
|
||||
return isLocalGatewayUrl(gatewayUrl)
|
||||
? OPENCLAW_CONTROL_UI_CLIENT_ID
|
||||
: OPENCLAW_WEBCHAT_UI_CLIENT_ID;
|
||||
};
|
||||
|
||||
export const resolveInitialGatewayAutoConnectDelayMs = (
|
||||
adapterType: StudioGatewayAdapterType
|
||||
): number => {
|
||||
@@ -526,6 +541,15 @@ const doctorFixHint =
|
||||
const protocolMismatchHint =
|
||||
"This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo.";
|
||||
|
||||
const tailscaleGatewayHint =
|
||||
"If this is a remote OpenClaw/Tailscale gateway, confirm the Studio host can reach the `wss://...` address and approve the first device pairing on the gateway host with `openclaw devices approve --latest`.";
|
||||
|
||||
const pairingRequiredHint =
|
||||
"This gateway is asking for first-time device approval. Run `openclaw devices approve --latest` on the gateway host, then restart Claw3D and reconnect from this browser.";
|
||||
|
||||
const requiresDeviceIdentityHint =
|
||||
"This gateway rejected the client as a control UI without device identity. For remote OpenClaw/Tailscale connections, update to the latest Claw3D build and approve the device pairing on the gateway host.";
|
||||
|
||||
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
|
||||
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
||||
const message = error.message.trim();
|
||||
@@ -541,6 +565,18 @@ const formatGatewayError = (error: unknown) => {
|
||||
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
|
||||
}
|
||||
if (error.code === "studio.upstream_timeout") {
|
||||
return `Gateway error (${error.code}): ${error.message} ${tailscaleGatewayHint}`;
|
||||
}
|
||||
if (error.code === "studio.upstream_rejected") {
|
||||
const lower = error.message.toLowerCase();
|
||||
if (lower.includes("pairing required")) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${pairingRequiredHint}`;
|
||||
}
|
||||
if (lower.includes("device identity")) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${requiresDeviceIdentityHint}`;
|
||||
}
|
||||
}
|
||||
return `Gateway error (${error.code}): ${error.message}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
@@ -608,6 +644,8 @@ const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
|
||||
"studio.gateway_url_invalid",
|
||||
"studio.settings_load_failed",
|
||||
"studio.upstream_error",
|
||||
"studio.upstream_timeout",
|
||||
"studio.upstream_rejected",
|
||||
]);
|
||||
|
||||
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
|
||||
@@ -899,7 +937,7 @@ export const useGatewayConnection = (
|
||||
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
||||
token,
|
||||
authScopeKey: gatewayUrl,
|
||||
clientName: "openclaw-control-ui",
|
||||
clientName: resolveGatewayClientName(selectedAdapterType, gatewayUrl),
|
||||
disableDeviceAuth: selectedAdapterType !== "openclaw",
|
||||
});
|
||||
lastError = null;
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type AgentsFilesGetResponse = {
|
||||
file?: { missing?: unknown; content?: unknown };
|
||||
workspace?: unknown;
|
||||
file?: { missing?: unknown; content?: unknown; path?: unknown };
|
||||
};
|
||||
|
||||
const resolveAgentId = (value: string) => {
|
||||
@@ -17,7 +18,7 @@ export const readGatewayAgentFile = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
name: AgentFileName;
|
||||
}): Promise<{ exists: boolean; content: string }> => {
|
||||
}): Promise<{ exists: boolean; content: string; path: string | null; workspace: string | null }> => {
|
||||
const agentId = resolveAgentId(params.agentId);
|
||||
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
|
||||
agentId,
|
||||
@@ -28,7 +29,11 @@ export const readGatewayAgentFile = async (params: {
|
||||
const missing = fileRecord?.missing === true;
|
||||
const content =
|
||||
fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : "";
|
||||
return { exists: !missing, content };
|
||||
const path =
|
||||
fileRecord && typeof fileRecord.path === "string" ? fileRecord.path : null;
|
||||
const workspace =
|
||||
typeof response?.workspace === "string" ? response.workspace : null;
|
||||
return { exists: !missing, content, path, workspace };
|
||||
};
|
||||
|
||||
export const writeGatewayAgentFile = async (params: {
|
||||
|
||||
@@ -581,6 +581,15 @@ export class GatewayBrowserClient {
|
||||
locale: navigator.language,
|
||||
};
|
||||
|
||||
gatewayBrowserDebugLog("connect-params", {
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
disableDeviceAuth: this.opts.disableDeviceAuth,
|
||||
isSecureContext,
|
||||
hasToken: Boolean(authToken),
|
||||
hasDeviceIdentity: Boolean(deviceIdentity),
|
||||
});
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
gatewayBrowserDebugLog("hello-ok", {
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { getPackagedSkillById } from "@/lib/skills/catalog";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
import type { PackagedSkillInstallRequest, PackagedSkillInstallResult } from "@/lib/skills/types";
|
||||
import {
|
||||
resolveWorkspaceFromAgentFiles,
|
||||
type PackagedSkillInstallRequest,
|
||||
type PackagedSkillInstallResult,
|
||||
} from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
const trimmed = value.trim();
|
||||
@@ -15,6 +19,35 @@ const normalizeRequired = (value: string, field: string): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeOptional = (value: string | undefined | null): string => value?.trim() ?? "";
|
||||
|
||||
const getPathLeaf = (value: string): string => {
|
||||
const normalized = value.replace(/[\\/]+$/, "");
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? "";
|
||||
};
|
||||
|
||||
const isRootWorkspace = (workspaceDir: string) => {
|
||||
const leaf = getPathLeaf(workspaceDir).toLowerCase();
|
||||
return leaf === "workspace";
|
||||
};
|
||||
|
||||
const validateWorkspaceInstallTarget = (params: {
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
}) => {
|
||||
if (isRootWorkspace(params.workspaceDir)) {
|
||||
const targetLabel =
|
||||
normalizeOptional(params.agentName) ||
|
||||
normalizeOptional(params.agentId) ||
|
||||
"the selected agent";
|
||||
throw new Error(
|
||||
`Cannot install a packaged skill because the workspace reported for ${targetLabel} resolves to the gateway root workspace (${params.workspaceDir}). Re-select the agent and refresh the marketplace before installing.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||
|
||||
const buildInstallerMessage = (params: {
|
||||
@@ -70,7 +103,21 @@ export const installPackagedSkillViaGatewayAgent = async (params: {
|
||||
throw new Error("Gateway-native packaged install currently supports workspace skills only.");
|
||||
}
|
||||
|
||||
const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
let workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
if (isRootWorkspace(workspaceDir) && normalizeOptional(params.request.agentId)) {
|
||||
const recoveredWorkspace = await resolveWorkspaceFromAgentFiles(
|
||||
params.client,
|
||||
normalizeOptional(params.request.agentId)
|
||||
);
|
||||
if (recoveredWorkspace) {
|
||||
workspaceDir = recoveredWorkspace;
|
||||
}
|
||||
}
|
||||
validateWorkspaceInstallTarget({
|
||||
workspaceDir,
|
||||
agentId: params.request.agentId,
|
||||
agentName: params.request.agentName,
|
||||
});
|
||||
const files = readPackagedSkillFiles(packagedSkill.packageId);
|
||||
const installerName = `Skill Installer ${Date.now()}`;
|
||||
|
||||
|
||||
+61
-2
@@ -1,4 +1,5 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
|
||||
export type SkillStatusConfigCheck = {
|
||||
path: string;
|
||||
@@ -95,6 +96,8 @@ export type PackagedSkillInstallRequest = {
|
||||
source: RemovableSkillSource;
|
||||
workspaceDir: string;
|
||||
managedSkillsDir: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
};
|
||||
|
||||
export type PackagedSkillInstallResult = {
|
||||
@@ -120,13 +123,69 @@ const resolveRequiredValue = (value: string, message: string): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const isLikelyRootWorkspace = (workspaceDir: string): boolean => {
|
||||
const normalized = workspaceDir.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) return false;
|
||||
return /[\\/]workspace$/i.test(normalized);
|
||||
};
|
||||
|
||||
const resolveWorkspaceDirFromPath = (filePath: string | null | undefined): string | null => {
|
||||
const normalized = filePath?.trim().replace(/[\\/]+$/, "") ?? "";
|
||||
if (!normalized) return null;
|
||||
const index = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||
if (index <= 0) return null;
|
||||
const candidate = normalized.slice(0, index).trim();
|
||||
if (!candidate || isLikelyRootWorkspace(candidate)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export const resolveWorkspaceFromAgentFiles = async (
|
||||
client: GatewayClient,
|
||||
agentId: string
|
||||
): Promise<string | null> => {
|
||||
for (const name of ["IDENTITY.md", "SOUL.md", "AGENTS.md"] as const) {
|
||||
try {
|
||||
const file = await readGatewayAgentFile({ client, agentId, name });
|
||||
const workspace = file.workspace?.trim() ?? "";
|
||||
if (workspace && !isLikelyRootWorkspace(workspace)) {
|
||||
return workspace;
|
||||
}
|
||||
const derivedFromPath = resolveWorkspaceDirFromPath(file.path);
|
||||
if (derivedFromPath) {
|
||||
return derivedFromPath;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort provenance recovery only.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const loadAgentSkillStatus = async (
|
||||
client: GatewayClient,
|
||||
agentId: string
|
||||
): Promise<SkillStatusReport> => {
|
||||
return client.call<SkillStatusReport>("skills.status", {
|
||||
agentId: resolveAgentId(agentId),
|
||||
const resolvedAgentId = resolveAgentId(agentId);
|
||||
const report = await client.call<SkillStatusReport>("skills.status", {
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
const workspaceDir = report.workspaceDir?.trim() ?? "";
|
||||
if (!workspaceDir || !isLikelyRootWorkspace(workspaceDir)) {
|
||||
return report;
|
||||
}
|
||||
const recoveredWorkspace = await resolveWorkspaceFromAgentFiles(
|
||||
client,
|
||||
resolvedAgentId
|
||||
);
|
||||
if (!recoveredWorkspace) {
|
||||
return report;
|
||||
}
|
||||
return {
|
||||
...report,
|
||||
workspaceDir: recoveredWorkspace,
|
||||
};
|
||||
};
|
||||
|
||||
export const installSkill = async (
|
||||
|
||||
Reference in New Issue
Block a user