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:
@@ -208,6 +208,17 @@ Alternative with SSH:
|
|||||||
3. Set `STUDIO_ACCESS_TOKEN` if Studio binds to a public host.
|
3. Set `STUDIO_ACCESS_TOKEN` if Studio binds to a public host.
|
||||||
4. Configure the gateway URL and token inside Studio.
|
4. Configure the gateway URL and token inside Studio.
|
||||||
|
|
||||||
|
### Studio on LAN or Tailscale for other devices
|
||||||
|
|
||||||
|
1. Start Studio with `HOST=0.0.0.0` (or a specific LAN/Tailscale host).
|
||||||
|
2. Set `STUDIO_ACCESS_TOKEN` before exposing Studio beyond localhost.
|
||||||
|
3. Open Claw3D from the LAN/Tailscale address instead of `localhost`.
|
||||||
|
4. If you are connecting to a remote OpenClaw gateway, remember device approval is per browser/device. A new browser may still require:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw devices approve --latest
|
||||||
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Next.js App Router, React, and TypeScript for the main web application.
|
- Next.js App Router, React, and TypeScript for the main web application.
|
||||||
|
|||||||
+91
-8
@@ -1,6 +1,8 @@
|
|||||||
const { Buffer } = require("node:buffer");
|
const { Buffer } = require("node:buffer");
|
||||||
const { WebSocket, WebSocketServer } = require("ws");
|
const { WebSocket, WebSocketServer } = require("ws");
|
||||||
|
|
||||||
|
const DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
/** Maximum frame payload size (256 KB). */
|
/** Maximum frame payload size (256 KB). */
|
||||||
const MAX_FRAME_SIZE = 256 * 1024;
|
const MAX_FRAME_SIZE = 256 * 1024;
|
||||||
|
|
||||||
@@ -29,11 +31,17 @@ const safeJsonParse = (raw) => {
|
|||||||
/** Per-connection frame rate limiter. */
|
/** Per-connection frame rate limiter. */
|
||||||
const createFrameRateLimiter = (maxPerSecond = MAX_FRAMES_PER_SECOND) => {
|
const createFrameRateLimiter = (maxPerSecond = MAX_FRAMES_PER_SECOND) => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const interval = setInterval(() => { count = 0; }, 1000);
|
const interval = setInterval(() => {
|
||||||
|
count = 0;
|
||||||
|
}, 1000);
|
||||||
interval.unref();
|
interval.unref();
|
||||||
return {
|
return {
|
||||||
check() { return ++count <= maxPerSecond; },
|
check() {
|
||||||
destroy() { clearInterval(interval); },
|
return ++count <= maxPerSecond;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
clearInterval(interval);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +133,7 @@ function createGatewayProxy(options) {
|
|||||||
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
||||||
log = () => {},
|
log = () => {},
|
||||||
logError = (msg, err) => console.error(msg, err),
|
logError = (msg, err) => console.error(msg, err),
|
||||||
|
upstreamHandshakeTimeoutMs = DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS,
|
||||||
} = options || {};
|
} = options || {};
|
||||||
|
|
||||||
const { verifyClient } = options || {};
|
const { verifyClient } = options || {};
|
||||||
@@ -147,10 +156,16 @@ function createGatewayProxy(options) {
|
|||||||
let pendingUpstreamSetupError = null;
|
let pendingUpstreamSetupError = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const frameRateLimiter = createFrameRateLimiter();
|
const frameRateLimiter = createFrameRateLimiter();
|
||||||
|
let upstreamHandshakeTimeoutId = null;
|
||||||
|
|
||||||
const closeBoth = (code, reason) => {
|
const closeBoth = (code, reason) => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
frameRateLimiter.destroy();
|
frameRateLimiter.destroy();
|
||||||
|
if (upstreamHandshakeTimeoutId !== null) {
|
||||||
|
clearTimeout(upstreamHandshakeTimeoutId);
|
||||||
|
upstreamHandshakeTimeoutId = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
browserWs.close(code, reason);
|
browserWs.close(code, reason);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -251,9 +266,30 @@ function createGatewayProxy(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
|
upstreamWs = new WebSocket(upstreamUrl, {
|
||||||
|
origin: upstreamOrigin,
|
||||||
|
handshakeTimeout: upstreamHandshakeTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamHandshakeTimeoutId = setTimeout(() => {
|
||||||
|
const timeoutError = {
|
||||||
|
code: "studio.upstream_timeout",
|
||||||
|
message: "Timed out connecting Studio to the upstream gateway WebSocket.",
|
||||||
|
};
|
||||||
|
pendingUpstreamSetupError = timeoutError;
|
||||||
|
try {
|
||||||
|
upstreamWs?.terminate();
|
||||||
|
} catch {}
|
||||||
|
if (connectRequestId) {
|
||||||
|
sendConnectError(timeoutError.code, timeoutError.message);
|
||||||
|
}
|
||||||
|
}, upstreamHandshakeTimeoutMs);
|
||||||
|
|
||||||
upstreamWs.on("open", () => {
|
upstreamWs.on("open", () => {
|
||||||
|
if (upstreamHandshakeTimeoutId !== null) {
|
||||||
|
clearTimeout(upstreamHandshakeTimeoutId);
|
||||||
|
upstreamHandshakeTimeoutId = null;
|
||||||
|
}
|
||||||
upstreamReady = true;
|
upstreamReady = true;
|
||||||
maybeForwardPendingConnect();
|
maybeForwardPendingConnect();
|
||||||
});
|
});
|
||||||
@@ -271,22 +307,60 @@ function createGatewayProxy(options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamWs.on("close", (ev) => {
|
upstreamWs.on("close", (code, reasonBuffer) => {
|
||||||
const reason = typeof ev?.reason === "string" ? ev.reason : "";
|
if (upstreamHandshakeTimeoutId !== null) {
|
||||||
|
clearTimeout(upstreamHandshakeTimeoutId);
|
||||||
|
upstreamHandshakeTimeoutId = null;
|
||||||
|
}
|
||||||
|
const reason =
|
||||||
|
typeof reasonBuffer === "string"
|
||||||
|
? reasonBuffer
|
||||||
|
: Buffer.isBuffer(reasonBuffer)
|
||||||
|
? reasonBuffer.toString()
|
||||||
|
: "";
|
||||||
|
if (!connectRequestId) {
|
||||||
|
pendingUpstreamSetupError ||= {
|
||||||
|
code: "studio.upstream_closed",
|
||||||
|
message: `Upstream gateway closed (${code}): ${reason}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!connectResponseSent && connectRequestId) {
|
if (!connectResponseSent && connectRequestId) {
|
||||||
|
connectResponseSent = true;
|
||||||
sendToBrowser(
|
sendToBrowser(
|
||||||
buildErrorResponse(
|
buildErrorResponse(
|
||||||
connectRequestId,
|
connectRequestId,
|
||||||
"studio.upstream_closed",
|
code === 1008 ? "studio.upstream_rejected" : "studio.upstream_closed",
|
||||||
`Upstream gateway closed (${ev.code}): ${reason}`
|
code === 1008
|
||||||
|
? `Upstream gateway rejected connect (${code}): ${reason || "no reason provided"}`
|
||||||
|
: `Upstream gateway closed (${code}): ${reason}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
closeBoth(1012, "upstream closed");
|
closeBoth(1012, "upstream closed");
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamWs.on("error", (err) => {
|
upstreamWs.on("error", (err) => {
|
||||||
|
if (upstreamHandshakeTimeoutId !== null) {
|
||||||
|
clearTimeout(upstreamHandshakeTimeoutId);
|
||||||
|
upstreamHandshakeTimeoutId = null;
|
||||||
|
}
|
||||||
logError("Upstream gateway WebSocket error.", err);
|
logError("Upstream gateway WebSocket error.", err);
|
||||||
|
if (!connectRequestId) {
|
||||||
|
pendingUpstreamSetupError ||= {
|
||||||
|
code: "studio.upstream_error",
|
||||||
|
message: "Failed to connect to upstream gateway WebSocket.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pendingUpstreamSetupError?.code === "studio.upstream_timeout" &&
|
||||||
|
pendingUpstreamSetupError?.message
|
||||||
|
) {
|
||||||
|
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendConnectError(
|
sendConnectError(
|
||||||
"studio.upstream_error",
|
"studio.upstream_error",
|
||||||
"Failed to connect to upstream gateway WebSocket."
|
"Failed to connect to upstream gateway WebSocket."
|
||||||
@@ -331,6 +405,15 @@ function createGatewayProxy(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
connectRequestId = id;
|
connectRequestId = id;
|
||||||
|
const params = isObject(parsed.params) ? parsed.params : null;
|
||||||
|
const client = params && isObject(params.client) ? params.client : null;
|
||||||
|
log(
|
||||||
|
`[gateway-proxy] connect frame client.id=${
|
||||||
|
typeof client?.id === "string" ? client.id : "n/a"
|
||||||
|
} client.mode=${
|
||||||
|
typeof client?.mode === "string" ? client.mode : "n/a"
|
||||||
|
} hasToken=${hasNonEmptyToken(params)} hasDevice=${hasCompleteDeviceAuth(params)}`
|
||||||
|
);
|
||||||
if (pendingUpstreamSetupError) {
|
if (pendingUpstreamSetupError) {
|
||||||
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ async function main() {
|
|||||||
const settings = loadUpstreamGatewaySettings(process.env);
|
const settings = loadUpstreamGatewaySettings(process.env);
|
||||||
return { url: settings.url, token: settings.token, adapterType: settings.adapterType };
|
return { url: settings.url, token: settings.token, adapterType: settings.adapterType };
|
||||||
},
|
},
|
||||||
|
log: (message) => console.info(message),
|
||||||
|
logError: (message, error) => console.error(message, error),
|
||||||
allowWs: (req) => {
|
allowWs: (req) => {
|
||||||
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -313,6 +313,16 @@ export const GatewayConnectScreen = ({
|
|||||||
metadata scaffold are now in place.
|
metadata scaffold are now in place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{localGatewayDefaults ? (
|
||||||
<div className="ui-input rounded-md px-3 py-3">
|
<div className="ui-input rounded-md px-3 py-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
|||||||
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
|
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
|
||||||
import {
|
import {
|
||||||
AGENT_FILE_META,
|
AGENT_FILE_META,
|
||||||
AGENT_FILE_PLACEHOLDERS,
|
PERSONALITY_FILE_NAMES,
|
||||||
type AgentFileName,
|
type AgentFileName,
|
||||||
} from "@/lib/agents/agentFiles";
|
} 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";
|
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
||||||
|
|
||||||
export type AgentBrainPanelProps = {
|
export type AgentBrainPanelProps = {
|
||||||
@@ -36,6 +40,30 @@ const AgentBrainPanelSection = ({
|
|||||||
</section>
|
</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 = ({
|
export const AgentBrainPanel = ({
|
||||||
client,
|
client,
|
||||||
agents,
|
agents,
|
||||||
@@ -61,9 +89,14 @@ export const AgentBrainPanel = ({
|
|||||||
agentFilesError,
|
agentFilesError,
|
||||||
setAgentFileContent,
|
setAgentFileContent,
|
||||||
saveAgentFiles,
|
saveAgentFiles,
|
||||||
|
initializeAgentFiles,
|
||||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||||
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const missingPersonalityFiles = useMemo(
|
||||||
|
() => PERSONALITY_FILE_NAMES.filter((name) => !agentFiles[name].exists),
|
||||||
|
[agentFiles]
|
||||||
|
);
|
||||||
|
|
||||||
const setIdentityField = useCallback(
|
const setIdentityField = useCallback(
|
||||||
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
||||||
@@ -101,6 +134,19 @@ export const AgentBrainPanel = ({
|
|||||||
selectedAgent,
|
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(() => {
|
useEffect(() => {
|
||||||
onUnsavedChangesChange?.(agentFilesDirty);
|
onUnsavedChangesChange?.(agentFilesDirty);
|
||||||
}, [agentFilesDirty, onUnsavedChangesChange]);
|
}, [agentFilesDirty, onUnsavedChangesChange]);
|
||||||
@@ -112,21 +158,36 @@ export const AgentBrainPanel = ({
|
|||||||
}, [onUnsavedChangesChange]);
|
}, [onUnsavedChangesChange]);
|
||||||
|
|
||||||
const renderMarkdownEditor = useCallback(
|
const renderMarkdownEditor = useCallback(
|
||||||
(name: Exclude<AgentFileName, "IDENTITY.md">) => (
|
(name: Exclude<AgentFileName, "IDENTITY.md">) => {
|
||||||
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
const file = agentFiles[name];
|
||||||
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
const trimmedContent = file.content.trim();
|
||||||
<textarea
|
const statusCopy = !file.exists
|
||||||
aria-label={AGENT_FILE_META[name].title}
|
? `This agent does not have a custom ${name} yet. Saving here will create the real workspace file.`
|
||||||
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"
|
: !trimmedContent
|
||||||
value={agentFiles[name].content}
|
? `This agent's ${name} exists, but it is currently empty.`
|
||||||
placeholder={AGENT_FILE_PLACEHOLDERS[name]}
|
: null;
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
return (
|
||||||
onChange={(event) => {
|
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||||
setAgentFileContent(name, event.target.value);
|
<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">
|
||||||
</AgentBrainPanelSection>
|
{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],
|
[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
|
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
|
||||||
when you save.
|
when you save.
|
||||||
</div>
|
</div>
|
||||||
|
<AgentFileProvenance
|
||||||
|
path={agentFiles["IDENTITY.md"].path}
|
||||||
|
workspace={agentFiles["IDENTITY.md"].workspace}
|
||||||
|
/>
|
||||||
<AgentIdentityFields
|
<AgentIdentityFields
|
||||||
values={draft.identity}
|
values={draft.identity}
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
@@ -191,6 +256,18 @@ export const AgentBrainPanel = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ui-btn-ghost px-3 py-2 text-xs"
|
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
import {
|
||||||
|
readGatewayAgentFile,
|
||||||
|
writeGatewayAgentFile,
|
||||||
|
writeGatewayAgentFiles,
|
||||||
|
} from "@/lib/gateway/agentFiles";
|
||||||
import {
|
import {
|
||||||
AGENT_FILE_NAMES,
|
AGENT_FILE_NAMES,
|
||||||
type AgentFileName,
|
type AgentFileName,
|
||||||
@@ -21,6 +25,7 @@ export type UseAgentFilesEditorResult = {
|
|||||||
agentFilesError: string | null;
|
agentFilesError: string | null;
|
||||||
setAgentFileContent: (name: AgentFileName, value: string) => void;
|
setAgentFileContent: (name: AgentFileName, value: string) => void;
|
||||||
saveAgentFiles: () => Promise<boolean>;
|
saveAgentFiles: () => Promise<boolean>;
|
||||||
|
initializeAgentFiles: (files: Partial<Record<AgentFileName, string>>) => Promise<boolean>;
|
||||||
discardAgentFileChanges: () => void;
|
discardAgentFileChanges: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +72,13 @@ export const useAgentFilesEditor = (params: {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
AGENT_FILE_NAMES.map(async (name) => {
|
AGENT_FILE_NAMES.map(async (name) => {
|
||||||
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, 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] = {
|
nextState[file.name] = {
|
||||||
content: file.content ?? "",
|
content: file.content ?? "",
|
||||||
exists: Boolean(file.exists),
|
exists: Boolean(file.exists),
|
||||||
|
path: file.path ?? null,
|
||||||
|
workspace: file.workspace ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +136,8 @@ export const useAgentFilesEditor = (params: {
|
|||||||
nextState[name] = {
|
nextState[name] = {
|
||||||
content: agentFiles[name].content,
|
content: agentFiles[name].content,
|
||||||
exists: true,
|
exists: true,
|
||||||
|
path: agentFiles[name].path,
|
||||||
|
workspace: agentFiles[name].workspace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +154,55 @@ export const useAgentFilesEditor = (params: {
|
|||||||
}
|
}
|
||||||
}, [agentFiles, agentId, client]);
|
}, [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) => {
|
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
|
||||||
if (!isAgentFileName(name)) return;
|
if (!isAgentFileName(name)) return;
|
||||||
|
|
||||||
@@ -167,6 +231,7 @@ export const useAgentFilesEditor = (params: {
|
|||||||
agentFilesError,
|
agentFilesError,
|
||||||
setAgentFileContent,
|
setAgentFileContent,
|
||||||
saveAgentFiles,
|
saveAgentFiles,
|
||||||
|
initializeAgentFiles,
|
||||||
discardAgentFileChanges,
|
discardAgentFileChanges,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ type ExecApprovalsSnapshot = {
|
|||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
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 => {
|
const resolveAgentsListFromHelloSnapshot = (snapshot: unknown): AgentsListResult | null => {
|
||||||
if (!isRecord(snapshot)) return null;
|
if (!isRecord(snapshot)) return null;
|
||||||
const health = isRecord(snapshot.health) ? snapshot.health : null;
|
const health = isRecord(snapshot.health) ? snapshot.health : null;
|
||||||
@@ -175,8 +188,41 @@ export async function hydrateAgentFleetFromGateway(params: {
|
|||||||
}
|
}
|
||||||
agentsResult = {
|
agentsResult = {
|
||||||
...agentsResult,
|
...agentsResult,
|
||||||
agents: agentsResult.agents.filter(
|
agents: await Promise.all(
|
||||||
(agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name)
|
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";
|
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 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() : "";
|
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||||
if (fromIdentity) return fromIdentity;
|
if (fromIdentity) return fromIdentity;
|
||||||
|
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||||
|
if (fromList) return fromList;
|
||||||
return agent.id;
|
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 resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => {
|
||||||
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
|
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
|
||||||
if (typeof candidate !== "string") return null;
|
if (typeof candidate !== "string") return null;
|
||||||
@@ -215,7 +225,11 @@ export const deriveHydrateAgentFleetResult = (
|
|||||||
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
|
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
|
||||||
const avatarUrl = resolveAgentAvatarUrl(agent);
|
const avatarUrl = resolveAgentAvatarUrl(agent);
|
||||||
const name = resolveAgentName(agent);
|
const name = resolveAgentName(agent);
|
||||||
|
const runtimeName = resolveRuntimeName(agent);
|
||||||
|
const identityName = resolveIdentityName(agent);
|
||||||
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
||||||
|
const sessionDisplayName =
|
||||||
|
typeof mainSession?.displayName === "string" ? mainSession.displayName.trim() || null : null;
|
||||||
const modelProvider =
|
const modelProvider =
|
||||||
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
|
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
|
||||||
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
|
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
|
||||||
@@ -252,6 +266,9 @@ export const deriveHydrateAgentFleetResult = (
|
|||||||
return {
|
return {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
name,
|
name,
|
||||||
|
runtimeName,
|
||||||
|
identityName,
|
||||||
|
sessionDisplayName,
|
||||||
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
|
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
|
||||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||||
avatarSeed,
|
avatarSeed,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
import {
|
||||||
|
isWebchatSessionMutationBlockedError,
|
||||||
|
syncGatewaySessionSettings,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
import {
|
import {
|
||||||
readGatewayAgentExecApprovals,
|
readGatewayAgentExecApprovals,
|
||||||
upsertGatewayAgentExecApprovals,
|
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: {
|
export async function updateAgentPermissionsViaStudio(params: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -354,16 +383,11 @@ export async function updateAgentPermissionsViaStudio(params: {
|
|||||||
overrides: toolOverrides,
|
overrides: toolOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const execSettings = resolveSessionExecSettingsForRole({
|
await syncExecutionRoleSessionSettings({
|
||||||
role,
|
|
||||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
|
||||||
});
|
|
||||||
await syncGatewaySessionSettings({
|
|
||||||
client: params.client,
|
client: params.client,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
execHost: execSettings.execHost,
|
role,
|
||||||
execSecurity: execSettings.execSecurity,
|
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||||
execAsk: execSettings.execAsk,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.loadAgents) {
|
if (params.loadAgents) {
|
||||||
@@ -403,16 +427,11 @@ export async function updateExecutionRoleViaStudio(params: {
|
|||||||
overrides: toolOverrides,
|
overrides: toolOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const execSettings = resolveSessionExecSettingsForRole({
|
await syncExecutionRoleSessionSettings({
|
||||||
role: params.role,
|
|
||||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
|
||||||
});
|
|
||||||
await syncGatewaySessionSettings({
|
|
||||||
client: params.client,
|
client: params.client,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
execHost: execSettings.execHost,
|
role: params.role,
|
||||||
execSecurity: execSettings.execSecurity,
|
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||||
execAsk: execSettings.execAsk,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await params.loadAgents();
|
await params.loadAgents();
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export type FocusFilter = "all" | "running" | "approvals";
|
|||||||
export type AgentStoreSeed = {
|
export type AgentStoreSeed = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
runtimeName?: string | null;
|
||||||
|
identityName?: string | null;
|
||||||
|
sessionDisplayName?: string | null;
|
||||||
role?: string | null;
|
role?: string | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
avatarSeed?: string | null;
|
avatarSeed?: string | null;
|
||||||
|
|||||||
@@ -502,7 +502,12 @@ export function SkillsMarketplacePanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={primaryAction.run}
|
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"
|
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" />
|
<PrimaryIcon className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -286,12 +286,14 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
source: packagedSkill.installSource,
|
source: packagedSkill.installSource,
|
||||||
workspaceDir: report.workspaceDir,
|
workspaceDir: report.workspaceDir,
|
||||||
managedSkillsDir: report.managedSkillsDir,
|
managedSkillsDir: report.managedSkillsDir,
|
||||||
|
agentId: selectedAgent?.agentId ?? undefined,
|
||||||
|
agentName: selectedAgent?.name ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[client, runSkillMutation]
|
[client, runSkillMutation, selectedAgent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInstallPackagedSkillAndEnable = useCallback(
|
const handleInstallPackagedSkillAndEnable = useCallback(
|
||||||
@@ -340,6 +342,9 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
source: packagedSkill.installSource,
|
source: packagedSkill.installSource,
|
||||||
workspaceDir: initialReport.workspaceDir,
|
workspaceDir: initialReport.workspaceDir,
|
||||||
managedSkillsDir: initialReport.managedSkillsDir,
|
managedSkillsDir: initialReport.managedSkillsDir,
|
||||||
|
agentId: targetAgentId,
|
||||||
|
agentName:
|
||||||
|
agents.find((agent) => agent.agentId === targetAgentId)?.name ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
params.onProgress?.({
|
params.onProgress?.({
|
||||||
|
|||||||
@@ -72,5 +72,5 @@ export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
|
|||||||
|
|
||||||
export const createAgentFilesState = () =>
|
export const createAgentFilesState = () =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
|
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false, path: null, workspace: null }])
|
||||||
) as Record<AgentFileName, { content: string; exists: boolean }>;
|
) as Record<AgentFileName, { content: string; exists: boolean; path: string | null; workspace: string | null }>;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export type PersonalityBuilderDraft = {
|
|||||||
memory: string;
|
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 => ({
|
export const createEmptyPersonalityDraft = (): PersonalityBuilderDraft => ({
|
||||||
identity: {
|
identity: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from "@/lib/studio/coordinator";
|
} from "@/lib/studio/coordinator";
|
||||||
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
|
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
|
||||||
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
||||||
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||||
|
|
||||||
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
|
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 DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
|
||||||
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
|
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
|
||||||
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
|
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) =>
|
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
|
||||||
adapterType !== "custom";
|
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 = (
|
export const resolveInitialGatewayAutoConnectDelayMs = (
|
||||||
adapterType: StudioGatewayAdapterType
|
adapterType: StudioGatewayAdapterType
|
||||||
): number => {
|
): number => {
|
||||||
@@ -526,6 +541,15 @@ const doctorFixHint =
|
|||||||
const protocolMismatchHint =
|
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.";
|
"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) => {
|
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
|
||||||
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
||||||
const message = error.message.trim();
|
const message = error.message.trim();
|
||||||
@@ -541,6 +565,18 @@ const formatGatewayError = (error: unknown) => {
|
|||||||
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
||||||
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
|
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}`;
|
return `Gateway error (${error.code}): ${error.message}`;
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -608,6 +644,8 @@ const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
|
|||||||
"studio.gateway_url_invalid",
|
"studio.gateway_url_invalid",
|
||||||
"studio.settings_load_failed",
|
"studio.settings_load_failed",
|
||||||
"studio.upstream_error",
|
"studio.upstream_error",
|
||||||
|
"studio.upstream_timeout",
|
||||||
|
"studio.upstream_rejected",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
|
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
|
||||||
@@ -899,7 +937,7 @@ export const useGatewayConnection = (
|
|||||||
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
||||||
token,
|
token,
|
||||||
authScopeKey: gatewayUrl,
|
authScopeKey: gatewayUrl,
|
||||||
clientName: "openclaw-control-ui",
|
clientName: resolveGatewayClientName(selectedAdapterType, gatewayUrl),
|
||||||
disableDeviceAuth: selectedAdapterType !== "openclaw",
|
disableDeviceAuth: selectedAdapterType !== "openclaw",
|
||||||
});
|
});
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { AgentFileName } from "@/lib/agents/agentFiles";
|
|||||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
|
||||||
type AgentsFilesGetResponse = {
|
type AgentsFilesGetResponse = {
|
||||||
file?: { missing?: unknown; content?: unknown };
|
workspace?: unknown;
|
||||||
|
file?: { missing?: unknown; content?: unknown; path?: unknown };
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveAgentId = (value: string) => {
|
const resolveAgentId = (value: string) => {
|
||||||
@@ -17,7 +18,7 @@ export const readGatewayAgentFile = async (params: {
|
|||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
name: AgentFileName;
|
name: AgentFileName;
|
||||||
}): Promise<{ exists: boolean; content: string }> => {
|
}): Promise<{ exists: boolean; content: string; path: string | null; workspace: string | null }> => {
|
||||||
const agentId = resolveAgentId(params.agentId);
|
const agentId = resolveAgentId(params.agentId);
|
||||||
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
|
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
|
||||||
agentId,
|
agentId,
|
||||||
@@ -28,7 +29,11 @@ export const readGatewayAgentFile = async (params: {
|
|||||||
const missing = fileRecord?.missing === true;
|
const missing = fileRecord?.missing === true;
|
||||||
const content =
|
const content =
|
||||||
fileRecord && typeof fileRecord.content === "string" ? fileRecord.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: {
|
export const writeGatewayAgentFile = async (params: {
|
||||||
|
|||||||
@@ -581,6 +581,15 @@ export class GatewayBrowserClient {
|
|||||||
locale: navigator.language,
|
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)
|
void this.request<GatewayHelloOk>("connect", params)
|
||||||
.then((hello) => {
|
.then((hello) => {
|
||||||
gatewayBrowserDebugLog("hello-ok", {
|
gatewayBrowserDebugLog("hello-ok", {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
} from "@/lib/gateway/agentConfig";
|
} from "@/lib/gateway/agentConfig";
|
||||||
import { getPackagedSkillById } from "@/lib/skills/catalog";
|
import { getPackagedSkillById } from "@/lib/skills/catalog";
|
||||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
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 normalizeRequired = (value: string, field: string): string => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -15,6 +19,35 @@ const normalizeRequired = (value: string, field: string): string => {
|
|||||||
return trimmed;
|
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 escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||||
|
|
||||||
const buildInstallerMessage = (params: {
|
const buildInstallerMessage = (params: {
|
||||||
@@ -70,7 +103,21 @@ export const installPackagedSkillViaGatewayAgent = async (params: {
|
|||||||
throw new Error("Gateway-native packaged install currently supports workspace skills only.");
|
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 files = readPackagedSkillFiles(packagedSkill.packageId);
|
||||||
const installerName = `Skill Installer ${Date.now()}`;
|
const installerName = `Skill Installer ${Date.now()}`;
|
||||||
|
|
||||||
|
|||||||
+61
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { readGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||||
|
|
||||||
export type SkillStatusConfigCheck = {
|
export type SkillStatusConfigCheck = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -95,6 +96,8 @@ export type PackagedSkillInstallRequest = {
|
|||||||
source: RemovableSkillSource;
|
source: RemovableSkillSource;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
managedSkillsDir: string;
|
managedSkillsDir: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PackagedSkillInstallResult = {
|
export type PackagedSkillInstallResult = {
|
||||||
@@ -120,13 +123,69 @@ const resolveRequiredValue = (value: string, message: string): string => {
|
|||||||
return trimmed;
|
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 (
|
export const loadAgentSkillStatus = async (
|
||||||
client: GatewayClient,
|
client: GatewayClient,
|
||||||
agentId: string
|
agentId: string
|
||||||
): Promise<SkillStatusReport> => {
|
): Promise<SkillStatusReport> => {
|
||||||
return client.call<SkillStatusReport>("skills.status", {
|
const resolvedAgentId = resolveAgentId(agentId);
|
||||||
agentId: 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 (
|
export const installSkill = async (
|
||||||
|
|||||||
@@ -67,10 +67,12 @@ const createMockClient = () => {
|
|||||||
const agentId = typeof record.agentId === "string" ? record.agentId : "";
|
const agentId = typeof record.agentId === "string" ? record.agentId : "";
|
||||||
const name = typeof record.name === "string" ? record.name : "";
|
const name = typeof record.name === "string" ? record.name : "";
|
||||||
const content = filesByAgent[agentId]?.[name];
|
const content = filesByAgent[agentId]?.[name];
|
||||||
|
const workspace = `/workspace/${agentId}`;
|
||||||
|
const path = `${workspace}/${name}`;
|
||||||
if (typeof content !== "string") {
|
if (typeof content !== "string") {
|
||||||
return { file: { name, missing: true } };
|
return { workspace, file: { name, path, missing: true } };
|
||||||
}
|
}
|
||||||
return { file: { name, missing: false, content } };
|
return { workspace, file: { name, path, missing: false, content } };
|
||||||
}
|
}
|
||||||
if (method === "agents.files.set") {
|
if (method === "agents.files.set") {
|
||||||
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
|
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
|
||||||
@@ -117,6 +119,8 @@ describe("AgentBrainPanel", () => {
|
|||||||
expect(screen.getByRole("heading", { name: "AGENTS.md" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "AGENTS.md" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { name: "USER.md" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "USER.md" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { name: "IDENTITY.md" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "IDENTITY.md" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Workspace:").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("/workspace/agent-1").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByLabelText("AGENTS.md")).toHaveValue("alpha agents");
|
expect(screen.getByLabelText("AGENTS.md")).toHaveValue("alpha agents");
|
||||||
expect(screen.getByLabelText("SOUL.md")).toHaveValue(
|
expect(screen.getByLabelText("SOUL.md")).toHaveValue(
|
||||||
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful."
|
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful."
|
||||||
@@ -217,4 +221,52 @@ describe("AgentBrainPanel", () => {
|
|||||||
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows_missing_file_state_instead_of_generic_placeholder_content", async () => {
|
||||||
|
const { client } = createMockClient();
|
||||||
|
const agents = [createAgent("agent-2", "Beta", "session-2")];
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(AgentBrainPanel, {
|
||||||
|
client,
|
||||||
|
agents,
|
||||||
|
selectedAgentId: "agent-2",
|
||||||
|
activeSection: "SOUL.md",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("This agent does not have a custom SOUL.md yet. Saving here will create the real workspace file.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("SOUL.md")).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("SOUL.md")).toHaveAttribute("placeholder", "No SOUL.md yet.");
|
||||||
|
expect(screen.getByText("/workspace/agent-2/SOUL.md")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can_initialize_missing_personality_files_for_an_agent", async () => {
|
||||||
|
const { client, filesByAgent } = createMockClient();
|
||||||
|
const agents = [createAgent("agent-2", "Beta", "session-2")];
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(AgentBrainPanel, {
|
||||||
|
client,
|
||||||
|
agents,
|
||||||
|
selectedAgentId: "agent-2",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Initialize missing files" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Initialize missing files" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(filesByAgent["agent-2"]["SOUL.md"]).toContain("# SOUL.md - Who You Are");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filesByAgent["agent-2"]["IDENTITY.md"]).toContain("- Name: Beta");
|
||||||
|
expect(filesByAgent["agent-2"]["USER.md"]).toContain("# USER.md - About Your Human");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
{
|
{
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
name: "One",
|
name: "One",
|
||||||
identity: { avatarUrl: "https://example.com/one.png" },
|
identity: { name: "Main Persona", avatarUrl: "https://example.com/one.png" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent-2",
|
id: "agent-2",
|
||||||
@@ -55,6 +55,27 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (method === "agents.files.get") {
|
||||||
|
const record = params as Record<string, unknown>;
|
||||||
|
if (record.agentId === "agent-2" && record.name === "IDENTITY.md") {
|
||||||
|
return {
|
||||||
|
workspace: "/tmp/workspace-agent-2",
|
||||||
|
file: {
|
||||||
|
missing: false,
|
||||||
|
content: "# IDENTITY.md - Who Am I?\n\n- Name: GLaDOS\n",
|
||||||
|
path: "/tmp/workspace-agent-2/IDENTITY.md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace: "/tmp/workspace-agent-1",
|
||||||
|
file: {
|
||||||
|
missing: false,
|
||||||
|
content: "# IDENTITY.md - Who Am I?\n\n- Name: Main Persona\n",
|
||||||
|
path: "/tmp/workspace-agent-1/IDENTITY.md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (method === "exec.approvals.get") {
|
if (method === "exec.approvals.get") {
|
||||||
return {
|
return {
|
||||||
file: {
|
file: {
|
||||||
@@ -126,10 +147,11 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
expect(result.seeds[0]).toEqual(
|
expect(result.seeds[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
name: "One",
|
name: "Main Persona",
|
||||||
|
runtimeName: "One",
|
||||||
|
identityName: "Main Persona",
|
||||||
|
sessionDisplayName: "Main",
|
||||||
sessionKey: "agent:agent-1:main",
|
sessionKey: "agent:agent-1:main",
|
||||||
avatarSeed: "persisted-seed",
|
|
||||||
avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
|
|
||||||
avatarUrl: "https://example.com/one.png",
|
avatarUrl: "https://example.com/one.png",
|
||||||
model: "openai/gpt-4.1",
|
model: "openai/gpt-4.1",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
@@ -141,6 +163,9 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
expect(result.seeds[1]).toEqual(
|
expect(result.seeds[1]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
agentId: "agent-2",
|
agentId: "agent-2",
|
||||||
|
name: "GLaDOS",
|
||||||
|
runtimeName: "Two",
|
||||||
|
identityName: "GLaDOS",
|
||||||
sessionExecHost: "gateway",
|
sessionExecHost: "gateway",
|
||||||
sessionExecSecurity: "full",
|
sessionExecSecurity: "full",
|
||||||
sessionExecAsk: "off",
|
sessionExecAsk: "off",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isPermissionsCustom,
|
isPermissionsCustom,
|
||||||
@@ -8,9 +8,64 @@ import {
|
|||||||
resolveRoleForCommandMode,
|
resolveRoleForCommandMode,
|
||||||
resolveToolGroupOverrides,
|
resolveToolGroupOverrides,
|
||||||
resolveToolGroupStateFromConfigEntry,
|
resolveToolGroupStateFromConfigEntry,
|
||||||
|
updateAgentPermissionsViaStudio,
|
||||||
|
updateExecutionRoleViaStudio,
|
||||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||||
|
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
|
||||||
|
import {
|
||||||
|
readGatewayAgentExecApprovals,
|
||||||
|
upsertGatewayAgentExecApprovals,
|
||||||
|
} from "@/lib/gateway/execApprovals";
|
||||||
|
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||||
|
|
||||||
|
vi.mock("@/lib/gateway/GatewayClient", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/gateway/GatewayClient")>(
|
||||||
|
"@/lib/gateway/GatewayClient"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
syncGatewaySessionSettings: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/gateway/agentConfig", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
|
||||||
|
"@/lib/gateway/agentConfig"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
updateGatewayAgentOverrides: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/gateway/execApprovals", () => ({
|
||||||
|
readGatewayAgentExecApprovals: vi.fn(async () => null),
|
||||||
|
upsertGatewayAgentExecApprovals: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createWebchatBlockedPatchError = () =>
|
||||||
|
new GatewayResponseError({
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
|
||||||
|
});
|
||||||
|
|
||||||
describe("agentPermissionsOperation", () => {
|
describe("agentPermissionsOperation", () => {
|
||||||
|
const mockedSyncGatewaySessionSettings = vi.mocked(syncGatewaySessionSettings);
|
||||||
|
const mockedUpdateGatewayAgentOverrides = vi.mocked(updateGatewayAgentOverrides);
|
||||||
|
const mockedReadGatewayAgentExecApprovals = vi.mocked(readGatewayAgentExecApprovals);
|
||||||
|
const mockedUpsertGatewayAgentExecApprovals = vi.mocked(upsertGatewayAgentExecApprovals);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSyncGatewaySessionSettings.mockReset();
|
||||||
|
mockedUpdateGatewayAgentOverrides.mockClear();
|
||||||
|
mockedReadGatewayAgentExecApprovals.mockReset();
|
||||||
|
mockedUpsertGatewayAgentExecApprovals.mockReset();
|
||||||
|
mockedReadGatewayAgentExecApprovals.mockResolvedValue(null);
|
||||||
|
mockedUpsertGatewayAgentExecApprovals.mockResolvedValue(undefined);
|
||||||
|
mockedUpdateGatewayAgentOverrides.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps command mode and preset role in both directions", () => {
|
it("maps command mode and preset role in both directions", () => {
|
||||||
expect(resolveRoleForCommandMode("off")).toBe("conservative");
|
expect(resolveRoleForCommandMode("off")).toBe("conservative");
|
||||||
expect(resolveRoleForCommandMode("ask")).toBe("collaborative");
|
expect(resolveRoleForCommandMode("ask")).toBe("collaborative");
|
||||||
@@ -111,4 +166,84 @@ describe("agentPermissionsOperation", () => {
|
|||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not fail permission updates when webchat blocks sessions.patch after config writes", async () => {
|
||||||
|
mockedSyncGatewaySessionSettings.mockRejectedValue(createWebchatBlockedPatchError());
|
||||||
|
const client = {
|
||||||
|
call: vi.fn(async (method: string) => {
|
||||||
|
if (method === "config.get") {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
sandbox: { mode: "workspace-write" },
|
||||||
|
tools: { allow: ["group:web"], deny: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method: ${method}`);
|
||||||
|
}),
|
||||||
|
} as never;
|
||||||
|
const loadAgents = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateAgentPermissionsViaStudio({
|
||||||
|
client,
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionKey: "session-1",
|
||||||
|
draft: {
|
||||||
|
commandMode: "ask",
|
||||||
|
webAccess: true,
|
||||||
|
fileTools: false,
|
||||||
|
},
|
||||||
|
loadAgents,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mockedUpsertGatewayAgentExecApprovals).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSyncGatewaySessionSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadAgents).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail execution-role updates when webchat blocks sessions.patch after config writes", async () => {
|
||||||
|
mockedSyncGatewaySessionSettings.mockRejectedValue(createWebchatBlockedPatchError());
|
||||||
|
const client = {
|
||||||
|
call: vi.fn(async (method: string) => {
|
||||||
|
if (method === "config.get") {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
sandbox: { mode: "workspace-write" },
|
||||||
|
tools: { allow: ["group:web"], deny: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method: ${method}`);
|
||||||
|
}),
|
||||||
|
} as never;
|
||||||
|
const loadAgents = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateExecutionRoleViaStudio({
|
||||||
|
client,
|
||||||
|
agentId: "agent-1",
|
||||||
|
sessionKey: "session-1",
|
||||||
|
role: "autonomous",
|
||||||
|
loadAgents,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mockedUpsertGatewayAgentExecApprovals).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSyncGatewaySessionSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadAgents).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,5 +33,37 @@ describe("resolveGatewayAutoRetryDelayMs", () => {
|
|||||||
|
|
||||||
expect(delay).toBeNull();
|
expect(delay).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not retry when the upstream websocket handshake times out", () => {
|
||||||
|
const delay = resolveGatewayAutoRetryDelayMs({
|
||||||
|
status: "disconnected",
|
||||||
|
didAutoConnect: true,
|
||||||
|
hasConnectedOnce: true,
|
||||||
|
wasManualDisconnect: false,
|
||||||
|
gatewayUrl: "wss://remote.example",
|
||||||
|
errorMessage:
|
||||||
|
"Gateway error (studio.upstream_timeout): Timed out connecting Studio to the upstream gateway WebSocket.",
|
||||||
|
connectErrorCode: "studio.upstream_timeout",
|
||||||
|
attempt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delay).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when the upstream gateway explicitly rejects pairing", () => {
|
||||||
|
const delay = resolveGatewayAutoRetryDelayMs({
|
||||||
|
status: "disconnected",
|
||||||
|
didAutoConnect: true,
|
||||||
|
hasConnectedOnce: true,
|
||||||
|
wasManualDisconnect: false,
|
||||||
|
gatewayUrl: "wss://remote.example",
|
||||||
|
errorMessage:
|
||||||
|
"Gateway error (studio.upstream_rejected): Upstream gateway rejected connect (1008): pairing required.",
|
||||||
|
connectErrorCode: "studio.upstream_rejected",
|
||||||
|
attempt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delay).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -643,4 +643,73 @@ describe("createGatewayProxy", () => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces upstream pairing rejection before browser close", async () => {
|
||||||
|
const upstream = new WebSocketServer({ port: 0 });
|
||||||
|
const address = upstream.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("expected upstream server to have a port");
|
||||||
|
}
|
||||||
|
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
upstream.on("connection", (ws) => {
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
const parsed = JSON.parse(String(raw));
|
||||||
|
if (parsed?.method === "connect") {
|
||||||
|
ws.close(1008, "pairing required");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||||
|
|
||||||
|
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||||
|
const proxy = createGatewayProxy({
|
||||||
|
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }),
|
||||||
|
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||||
|
logError: () => {},
|
||||||
|
});
|
||||||
|
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||||
|
const proxyAddr = proxyHttp.address();
|
||||||
|
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||||
|
throw new Error("expected proxy server to have a port");
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||||
|
try {
|
||||||
|
await waitForEvent(browser, "open");
|
||||||
|
browser.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: "connect-pairing-required",
|
||||||
|
method: "connect",
|
||||||
|
params: { auth: {} },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||||
|
const response = JSON.parse(String(rawMessage ?? ""));
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
type: "res",
|
||||||
|
id: "connect-pairing-required",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "studio.upstream_rejected",
|
||||||
|
message: "Upstream gateway rejected connect (1008): pairing required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
for (const client of upstream.clients) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
closeWebSocket(browser),
|
||||||
|
closeWebSocketServer(upstream),
|
||||||
|
closeHttpServer(proxyHttp),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ describe("personalityBuilder", () => {
|
|||||||
const files = createFiles();
|
const files = createFiles();
|
||||||
files["IDENTITY.md"] = {
|
files["IDENTITY.md"] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
content: `# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** 🦊\n- **Avatar:** avatars/nova.png\n`,
|
content:
|
||||||
|
"# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** fox\n- **Avatar:** avatars/nova.png\n",
|
||||||
|
path: null,
|
||||||
|
workspace: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draft = parsePersonalityFiles(files);
|
const draft = parsePersonalityFiles(files);
|
||||||
@@ -23,7 +26,7 @@ describe("personalityBuilder", () => {
|
|||||||
name: "Nova",
|
name: "Nova",
|
||||||
creature: "fox spirit",
|
creature: "fox spirit",
|
||||||
vibe: "calm + direct",
|
vibe: "calm + direct",
|
||||||
emoji: "🦊",
|
emoji: "fox",
|
||||||
avatar: "avatars/nova.png",
|
avatar: "avatars/nova.png",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -32,7 +35,10 @@ describe("personalityBuilder", () => {
|
|||||||
const files = createFiles();
|
const files = createFiles();
|
||||||
files["USER.md"] = {
|
files["USER.md"] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
content: `# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building Claw3D.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n`,
|
content:
|
||||||
|
"# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building Claw3D.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n",
|
||||||
|
path: null,
|
||||||
|
workspace: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draft = parsePersonalityFiles(files);
|
const draft = parsePersonalityFiles(files);
|
||||||
@@ -51,7 +57,10 @@ describe("personalityBuilder", () => {
|
|||||||
const files = createFiles();
|
const files = createFiles();
|
||||||
files["SOUL.md"] = {
|
files["SOUL.md"] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
content: `# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n`,
|
content:
|
||||||
|
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n",
|
||||||
|
path: null,
|
||||||
|
workspace: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draft = parsePersonalityFiles(files);
|
const draft = parsePersonalityFiles(files);
|
||||||
@@ -69,12 +78,16 @@ describe("personalityBuilder", () => {
|
|||||||
files["IDENTITY.md"] = {
|
files["IDENTITY.md"] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
content:
|
content:
|
||||||
"# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature — pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n",
|
"# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature - pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n",
|
||||||
|
path: null,
|
||||||
|
workspace: null,
|
||||||
};
|
};
|
||||||
files["USER.md"] = {
|
files["USER.md"] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
content:
|
content:
|
||||||
"# USER.md - About Your Human\n\n- **Name:**\n- **What to call them:**\n- **Pronouns:** _(optional)_\n- **Timezone:**\n- **Notes:**\n\n## Context\n\n_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_\n",
|
"# USER.md - About Your Human\n\n- **Name:**\n- **What to call them:**\n- **Pronouns:** _(optional)_\n- **Timezone:**\n- **Notes:**\n\n## Context\n\n_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_\n",
|
||||||
|
path: null,
|
||||||
|
workspace: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draft = parsePersonalityFiles(files);
|
const draft = parsePersonalityFiles(files);
|
||||||
@@ -102,7 +115,7 @@ describe("personalityBuilder", () => {
|
|||||||
name: "Nova",
|
name: "Nova",
|
||||||
creature: "fox spirit",
|
creature: "fox spirit",
|
||||||
vibe: "calm + direct",
|
vibe: "calm + direct",
|
||||||
emoji: "🦊",
|
emoji: "fox",
|
||||||
avatar: "avatars/nova.png",
|
avatar: "avatars/nova.png",
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
@@ -134,7 +147,7 @@ describe("personalityBuilder", () => {
|
|||||||
"- Name: Nova",
|
"- Name: Nova",
|
||||||
"- Creature: fox spirit",
|
"- Creature: fox spirit",
|
||||||
"- Vibe: calm + direct",
|
"- Vibe: calm + direct",
|
||||||
"- Emoji: 🦊",
|
"- Emoji: fox",
|
||||||
"- Avatar: avatars/nova.png",
|
"- Avatar: avatars/nova.png",
|
||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|||||||
@@ -20,6 +20,73 @@ describe("skills gateway client", () => {
|
|||||||
expect(result).toBe(report);
|
expect(result).toBe(report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repairs root workspace reports using agent file provenance", async () => {
|
||||||
|
const client = {
|
||||||
|
call: vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
if (method === "skills.status") {
|
||||||
|
return {
|
||||||
|
workspaceDir: "/home/pi/.openclaw/workspace",
|
||||||
|
managedSkillsDir: "/home/pi/.openclaw/skills",
|
||||||
|
skills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "agents.files.get") {
|
||||||
|
expect(params).toEqual({
|
||||||
|
agentId: "main",
|
||||||
|
name: "IDENTITY.md",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workspace: "/home/pi/.openclaw/workspace-main",
|
||||||
|
file: {
|
||||||
|
missing: false,
|
||||||
|
content: "# IDENTITY",
|
||||||
|
path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method: ${method}`);
|
||||||
|
}),
|
||||||
|
} as unknown as GatewayClient;
|
||||||
|
|
||||||
|
const result = await loadAgentSkillStatus(client, "main");
|
||||||
|
|
||||||
|
expect(result.workspaceDir).toBe("/home/pi/.openclaw/workspace-main");
|
||||||
|
expect(client.call).toHaveBeenNthCalledWith(1, "skills.status", { agentId: "main" });
|
||||||
|
expect(client.call).toHaveBeenNthCalledWith(2, "agents.files.get", {
|
||||||
|
agentId: "main",
|
||||||
|
name: "IDENTITY.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives workspace from file path when agents.files.get reports the root workspace", async () => {
|
||||||
|
const client = {
|
||||||
|
call: vi.fn(async (method: string) => {
|
||||||
|
if (method === "skills.status") {
|
||||||
|
return {
|
||||||
|
workspaceDir: "/home/pi/.openclaw/workspace",
|
||||||
|
managedSkillsDir: "/home/pi/.openclaw/skills",
|
||||||
|
skills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "agents.files.get") {
|
||||||
|
return {
|
||||||
|
workspace: "/home/pi/.openclaw/workspace",
|
||||||
|
file: {
|
||||||
|
missing: false,
|
||||||
|
content: "# IDENTITY",
|
||||||
|
path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method: ${method}`);
|
||||||
|
}),
|
||||||
|
} as unknown as GatewayClient;
|
||||||
|
|
||||||
|
const result = await loadAgentSkillStatus(client, "main");
|
||||||
|
|
||||||
|
expect(result.workspaceDir).toBe("/home/pi/.openclaw/workspace-main");
|
||||||
|
});
|
||||||
|
|
||||||
it("fails fast when agent id is empty", async () => {
|
it("fails fast when agent id is empty", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
call: vi.fn(),
|
call: vi.fn(),
|
||||||
|
|||||||
@@ -124,4 +124,92 @@ describe("skills install gateway", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects installs when the gateway reports the global root workspace", async () => {
|
||||||
|
const call = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPackagedSkillViaGatewayAgent({
|
||||||
|
client: { call } as unknown as GatewayClient,
|
||||||
|
request: {
|
||||||
|
packageId: "todo-board",
|
||||||
|
source: "openclaw-workspace",
|
||||||
|
workspaceDir: "/home/pi/.openclaw/workspace",
|
||||||
|
managedSkillsDir: "/home/pi/.openclaw/skills",
|
||||||
|
agentId: "soundclaw",
|
||||||
|
agentName: "soundclaw",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/gateway root workspace/i);
|
||||||
|
|
||||||
|
expect(call).toHaveBeenCalledTimes(3);
|
||||||
|
expect(call).toHaveBeenNthCalledWith(1, "agents.files.get", {
|
||||||
|
agentId: "soundclaw",
|
||||||
|
name: "IDENTITY.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs the workspace from agent file provenance before creating the installer agent", async () => {
|
||||||
|
const call = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
if (method === "agents.files.get") {
|
||||||
|
expect(params).toEqual({ agentId: "main", name: "IDENTITY.md" });
|
||||||
|
return {
|
||||||
|
workspace: "/home/pi/.openclaw/workspace",
|
||||||
|
file: {
|
||||||
|
missing: false,
|
||||||
|
content: "# IDENTITY",
|
||||||
|
path: "/home/pi/.openclaw/workspace-main/IDENTITY.md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "agents.create") {
|
||||||
|
return { agentId: "installer-3" };
|
||||||
|
}
|
||||||
|
if (method === "config.get") {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hash: "hash-3",
|
||||||
|
config: {
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "installer-3", tools: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "config.set") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (method === "config.patch") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (method === "agents.list") {
|
||||||
|
return { mainKey: "main" };
|
||||||
|
}
|
||||||
|
if (method === "chat.send") {
|
||||||
|
return { runId: "run-3", status: "started" };
|
||||||
|
}
|
||||||
|
if (method === "agent.wait") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method: ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await installPackagedSkillViaGatewayAgent({
|
||||||
|
client: { call } as unknown as GatewayClient,
|
||||||
|
request: {
|
||||||
|
packageId: "todo-board",
|
||||||
|
source: "openclaw-workspace",
|
||||||
|
workspaceDir: "/home/pi/.openclaw/workspace",
|
||||||
|
managedSkillsDir: "/home/pi/.openclaw/skills",
|
||||||
|
agentId: "main",
|
||||||
|
agentName: "main",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.installedPath).toBe("/home/pi/.openclaw/workspace-main/skills/todo-board");
|
||||||
|
expect(call).toHaveBeenCalledWith("agents.create", {
|
||||||
|
name: expect.stringContaining("Skill Installer"),
|
||||||
|
workspace: "/home/pi/.openclaw/workspace-main",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,16 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
|
||||||
const captured: { url: string | null; token: unknown; authScopeKey: unknown } = {
|
const captured: {
|
||||||
|
url: string | null;
|
||||||
|
token: unknown;
|
||||||
|
authScopeKey: unknown;
|
||||||
|
clientName: unknown;
|
||||||
|
} = {
|
||||||
url: null,
|
url: null,
|
||||||
token: null,
|
token: null,
|
||||||
authScopeKey: null,
|
authScopeKey: null,
|
||||||
|
clientName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||||
@@ -35,6 +41,7 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
|
|||||||
captured.url = typeof opts.url === "string" ? opts.url : null;
|
captured.url = typeof opts.url === "string" ? opts.url : null;
|
||||||
captured.token = "token" in opts ? opts.token : null;
|
captured.token = "token" in opts ? opts.token : null;
|
||||||
captured.authScopeKey = "authScopeKey" in opts ? opts.authScopeKey : null;
|
captured.authScopeKey = "authScopeKey" in opts ? opts.authScopeKey : null;
|
||||||
|
captured.clientName = "clientName" in opts ? opts.clientName : null;
|
||||||
this.opts = {
|
this.opts = {
|
||||||
onHello: typeof opts.onHello === "function" ? (opts.onHello as (hello: unknown) => void) : undefined,
|
onHello: typeof opts.onHello === "function" ? (opts.onHello as (hello: unknown) => void) : undefined,
|
||||||
onEvent: typeof opts.onEvent === "function" ? (opts.onEvent as (event: unknown) => void) : undefined,
|
onEvent: typeof opts.onEvent === "function" ? (opts.onEvent as (event: unknown) => void) : undefined,
|
||||||
@@ -186,6 +193,99 @@ describe("useGatewayConnection", () => {
|
|||||||
});
|
});
|
||||||
expect(captured.token).toBe("");
|
expect(captured.token).toBe("");
|
||||||
expect(captured.authScopeKey).toBe("wss://remote.example");
|
expect(captured.authScopeKey).toBe("wss://remote.example");
|
||||||
|
expect(captured.clientName).toBe("openclaw-control-ui");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses_webchat_identity_for_remote_openclaw_connections", async () => {
|
||||||
|
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettings: async () => null,
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "wss://pi5.myth-coho.ts.net",
|
||||||
|
token: "shared-token",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "wss://pi5.myth-coho.ts.net",
|
||||||
|
token: "shared-token",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
useGatewayConnection(coordinator);
|
||||||
|
return createElement("div", null, "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
|
||||||
|
});
|
||||||
|
expect(captured.authScopeKey).toBe("wss://pi5.myth-coho.ts.net");
|
||||||
|
expect(captured.clientName).toBe("webchat-ui");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps_control_ui_identity_for_local_openclaw_connections", async () => {
|
||||||
|
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettings: async () => null,
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "shared-token",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "shared-token",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
useGatewayConnection(coordinator);
|
||||||
|
return createElement("div", null, "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
|
||||||
|
});
|
||||||
|
expect(captured.authScopeKey).toBe("ws://localhost:18789");
|
||||||
|
expect(captured.clientName).toBe("openclaw-control-ui");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does_not_auto_connect_without_a_last_known_good_state", async () => {
|
it("does_not_auto_connect_without_a_last_known_good_state", async () => {
|
||||||
@@ -257,6 +357,19 @@ describe("useGatewayConnection", () => {
|
|||||||
expect(mod.resolveInitialGatewayConnectAttemptCount("openclaw", true)).toBe(1);
|
expect(mod.resolveInitialGatewayConnectAttemptCount("openclaw", true)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses_webchat_client_id_only_for_remote_openclaw", async () => {
|
||||||
|
const mod = await import("@/lib/gateway/GatewayClient");
|
||||||
|
expect(mod.resolveGatewayClientName("openclaw", "wss://pi5.myth-coho.ts.net")).toBe(
|
||||||
|
"webchat-ui"
|
||||||
|
);
|
||||||
|
expect(mod.resolveGatewayClientName("openclaw", "ws://localhost:18789")).toBe(
|
||||||
|
"openclaw-control-ui"
|
||||||
|
);
|
||||||
|
expect(mod.resolveGatewayClientName("hermes", "ws://localhost:18789")).toBe(
|
||||||
|
"openclaw-control-ui"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => {
|
it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => {
|
||||||
// Simulates #57: NEXT_PUBLIC_GATEWAY_URL was never rebuilt, but
|
// Simulates #57: NEXT_PUBLIC_GATEWAY_URL was never rebuilt, but
|
||||||
// CLAW3D_GATEWAY_URL is set on the server so localGatewayDefaults
|
// CLAW3D_GATEWAY_URL is set on the server so localGatewayDefaults
|
||||||
|
|||||||
Reference in New Issue
Block a user