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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react";
import { toast } from "@/components/ui/sonner";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { t } from "@/i18n";
import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-session-provider";
Expand All @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = {
connectRemoteWorkspace: (input: {
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkDenBaseUrl?: string | null;
openworkDenOrgId?: string | null;
openworkDenWorkerId?: string | null;
directory?: string | null;
displayName?: string | null;
}) => Promise<boolean>;
Expand All @@ -23,11 +29,18 @@ export function CloudWorkersView({
connectRemoteWorkspace,
onOpenAccount,
}: CloudWorkersViewProps) {
const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession();
const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession();
const [workersBusy, setWorkersBusy] = React.useState(false);
const [openingWorkerId, setOpeningWorkerId] = React.useState<string | null>(null);
const [attachBusy, setAttachBusy] = React.useState(false);
const [workers, setWorkers] = React.useState<CloudWorker[]>([]);
const [workersError, setWorkersError] = React.useState<string | null>(null);
const [staticWorkerForm, setStaticWorkerForm] = React.useState({
name: "LAN static worker",
url: "",
clientToken: "",
hostToken: "",
});
const activeOrgId = activeOrg?.id ?? "";

const refreshWorkers = React.useCallback(
Expand Down Expand Up @@ -82,14 +95,19 @@ export function CloudWorkersView({
try {
const tokens = await client.getWorkerTokens(workerId, activeOrgId);
const openworkUrl = tokens.openworkUrl?.trim() ?? "";
const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || "";
const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || "";
if (!openworkUrl || !accessToken) {
throw new Error(t("den.error_worker_not_ready"));
}

const ok = await connectRemoteWorkspace({
openworkHostUrl: openworkUrl,
openworkToken: accessToken,
openworkClientToken: tokens.clientToken?.trim() || null,
openworkHostToken: tokens.hostToken?.trim() || null,
openworkDenBaseUrl: baseUrl,
openworkDenOrgId: activeOrgId,
openworkDenWorkerId: workerId,
directory: null,
displayName: workerName,
});
Expand All @@ -108,9 +126,47 @@ export function CloudWorkersView({
setOpeningWorkerId(null);
}
},
[activeOrgId, client, connectRemoteWorkspace],
[activeOrgId, baseUrl, client, connectRemoteWorkspace],
);

const attachStaticWorker = React.useCallback(async () => {
if (!activeOrgId) {
setWorkersError(t("den.error_choose_org"));
return;
}

const name = staticWorkerForm.name.trim();
const url = staticWorkerForm.url.trim();
const clientToken = staticWorkerForm.clientToken.trim();
const hostToken = staticWorkerForm.hostToken.trim();
if (!name || !url || !clientToken || !hostToken) {
setWorkersError("Name, URL, client token, and host token are required to attach a static worker.");
return;
}

setAttachBusy(true);
setWorkersError(null);
try {
const worker = await client.attachStaticWorker(activeOrgId, {
name,
url,
clientToken,
hostToken,
});
setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]);
setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" }));
toast.success(`Attached ${worker.workerName}`);
void refreshWorkers(true);
} catch (error) {
const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null;
setWorkersError(status === 403
? "Only organization owners and admins can attach static workers. Ask an operator to register this worker."
: error instanceof Error ? error.message : "Static worker attach failed.");
} finally {
setAttachBusy(false);
}
}, [activeOrgId, client, refreshWorkers, staticWorkerForm]);

if (!isSignedIn) {
return (
<SettingsStack>
Expand All @@ -130,6 +186,45 @@ export function CloudWorkersView({
return (
<SettingsStack>
<Separator />
<SettingsNotice>
<div className="flex flex-col gap-3">
<div>
<div className="text-sm font-medium">Admin/operator: attach LAN static worker</div>
<div className="text-xs text-muted-foreground">
Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment.
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<Input
value={staticWorkerForm.name}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))}
placeholder="Worker name"
/>
<Input
value={staticWorkerForm.url}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))}
placeholder="http://192.168.1.50:8787"
/>
<Input
value={staticWorkerForm.clientToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))}
placeholder="OPENWORK_TOKEN"
type="password"
/>
<Input
value={staticWorkerForm.hostToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))}
placeholder="OPENWORK_HOST_TOKEN"
type="password"
/>
</div>
<div>
<Button size="sm" onClick={() => void attachStaticWorker()} disabled={attachBusy || workersBusy || !activeOrgId}>
{attachBusy ? "Attaching..." : "Attach static worker"}
</Button>
</div>
</div>
</SettingsNotice>
<CloudWorkersSection
openingWorkerId={openingWorkerId}
workers={workers}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createRuntimeManager } from "./runtime.mjs";
import { registerUpdaterIpc } from "./updater.mjs";
import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs";
import {
isDesktopFetchAllowedForWorkspaces,
openworkWorkspaceDisplayName,
selectOpenworkWorkspaceForConnection,
} from "./remote-workspace.mjs";
Expand Down Expand Up @@ -2765,6 +2766,14 @@ async function handleDesktopInvoke(event, command, ...args) {
const url = String(args[0] ?? "").trim();
const init = args[1] ?? {};
if (!url) throw new Error("URL is required.");
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("Desktop fetch only supports HTTP(S) URLs.");
}
const state = await readWorkspaceState();
if (!isDesktopFetchAllowedForWorkspaces(parsed.toString(), state.workspaces)) {
throw new Error("Desktop fetch is limited to configured remote workspace origins.");
}
const timeoutMs = Number(init.timeoutMs);
const response = await fetch(url, {
method: typeof init.method === "string" ? init.method : undefined,
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/electron/remote-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,46 @@ export function openworkWorkspaceDisplayName(workspace) {
null
);
}

function stripOpenworkWorkspaceMount(input) {
const raw = trim(input);
if (!raw) return null;
try {
const url = new URL(raw);
const segments = url.pathname.split("/").filter(Boolean);
const workspaceIndex = segments.indexOf("workspace");
const legacyIndex = segments.indexOf("w");
const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex;
if (mountIndex >= 0 && segments[mountIndex + 1]) {
const prefix = segments.slice(0, mountIndex).join("/");
url.pathname = prefix ? `/${prefix}` : "/";
}
return url.toString().replace(/\/+$/, "");
} catch {
return raw.replace(/\/(?:workspace|w)\/[^/?#]+.*$/, "").replace(/\/+$/, "") || raw;
}
}

export function isDesktopFetchAllowedForWorkspaces(url, workspaces) {
let parsed;
try {
parsed = new URL(trim(url));
} catch {
return false;
}
if (!["http:", "https:"].includes(parsed.protocol)) return false;

for (const workspace of Array.isArray(workspaces) ? workspaces : []) {
if (workspace?.workspaceType !== "remote") continue;
for (const candidate of [workspace.baseUrl, workspace.openworkHostUrl]) {
const stripped = stripOpenworkWorkspaceMount(candidate);
if (!stripped) continue;
try {
if (new URL(stripped).origin === parsed.origin) return true;
} catch {
// Ignore malformed persisted values; they are not valid fetch targets.
}
}
}
return false;
}
21 changes: 21 additions & 0 deletions apps/desktop/electron/remote-workspace.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";

import {
isDesktopFetchAllowedForWorkspaces,
openworkWorkspaceDisplayName,
selectOpenworkWorkspaceForConnection,
} from "./remote-workspace.mjs";
Expand Down Expand Up @@ -87,6 +88,26 @@ describe("selectOpenworkWorkspaceForConnection", () => {
});
});

describe("isDesktopFetchAllowedForWorkspaces", () => {
const workspaces = [
{
workspaceType: "remote",
baseUrl: "https://worker.example.com/w/rem_ws_123",
openworkHostUrl: "https://worker.example.com",
},
{ workspaceType: "local", baseUrl: "https://ignored.example.com" },
];

it("allows configured remote workspace origins", () => {
assert.equal(isDesktopFetchAllowedForWorkspaces("https://worker.example.com/workspaces", workspaces), true);
});

it("rejects unconfigured origins and non-HTTP protocols", () => {
assert.equal(isDesktopFetchAllowedForWorkspaces("https://attacker.example.com/workspaces", workspaces), false);
assert.equal(isDesktopFetchAllowedForWorkspaces("file:///etc/passwd", workspaces), false);
});
});

describe("openworkWorkspaceDisplayName", () => {
it("prefers display fields before id", () => {
assert.equal(
Expand Down
3 changes: 2 additions & 1 deletion ee/apps/den-api/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createDenDb } from "@openwork-ee/den-db"
import { env } from "./env.js"

export const { db } = createDenDb({
export const denDb = createDenDb({
databaseUrl: env.databaseUrl,
mode: env.dbMode,
planetscale: env.planetscale,
})
export const { client: dbClient, db } = denDb
Loading