diff --git a/integrations/dsh/src/config.ts b/integrations/dsh/src/config.ts index 228bcd5b..ce4421bc 100644 --- a/integrations/dsh/src/config.ts +++ b/integrations/dsh/src/config.ts @@ -3,31 +3,19 @@ import z from "@deepseek-ai/schemastery"; import { validTimezone, validateDailyCron } from "./scheduling.js"; import type { ReMeConfig, ReMeConfigInput } from "./types.js"; -// DSH sends volatile schemas to the browser as JSON, so these checks must serialize without callbacks. -const HTTP_ENDPOINT = - /^https?:\/\/(?:\[[0-9a-fA-F:.]+\]|[^\s/?#:@]+)(?::\d{1,5})?(?:[/?#][^\s]*)?$/; -const DAILY_CRON = /^(?:[0-5]?\d)\s+(?:[01]?\d|2[0-3])\s+\*\s+\*\s+\*$/; -const TIMEZONES = [ - ...Intl.supportedValuesOf("timeZone"), - "UTC", - "Etc/UTC", - "GMT", - "Etc/GMT", -]; -const TIMEZONE = new RegExp(`^(?:${TIMEZONES.map(escapeRegExp).join("|")})$`); +const DEFAULT_ENDPOINT = + process.env.REME_URL || + `http://${process.env.REME_HOST || "127.0.0.1"}:${ + process.env.REME_PORT || "2333" + }`; +const DEFAULT_DREAM_CRON = process.env.REME_DSH_DREAM_CRON || "0 23 * * *"; export const Config = z.object({ - endpoint: z - .string() - .pattern(HTTP_ENDPOINT) - .description("ReMe HTTP service URL") - .default( - process.env.REME_URL || - `http://${process.env.REME_HOST || "127.0.0.1"}:${ - process.env.REME_PORT || "2333" - }`, - ) - .volatile(), + endpoint: checkedString( + DEFAULT_ENDPOINT, + "ReMe HTTP service URL", + assertEndpoint, + ), requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(), backgroundTimeoutMs: z .natural() @@ -39,27 +27,47 @@ export const Config = z.object({ autoMemoryEnabled: z.boolean().default(true).volatile(), autoMemoryInterval: z.natural().min(1).max(1000).default(5).volatile(), autoDreamEnabled: z.boolean().default(true).volatile(), - dreamCron: z - .string() - .pattern(DAILY_CRON) - .description("Daily cron in the workspace timezone") - .default(process.env.REME_DSH_DREAM_CRON || "0 23 * * *") - .volatile(), + dreamCron: checkedString( + DEFAULT_DREAM_CRON, + "Daily cron in the workspace timezone", + validateDailyCron, + ), dreamHint: z.string().default("").volatile(), dreamIntervalMs: z.natural().max(2147483647).default(0), rootAgentsOnly: z.boolean().default(true).volatile(), language: z.union(["en", "zh"]).default("en").volatile(), searchLimit: z.natural().min(1).max(50).default(5).volatile(), - timezone: z - .string() - .pattern(TIMEZONE) - .default("Asia/Shanghai") - .description("IANA timezone matching the ReMe workspace") - .volatile(), + timezone: checkedString( + "Asia/Shanghai", + "IANA timezone matching the ReMe workspace", + (value) => { + if (!validTimezone(value)) + throw new TypeError(`Invalid ReMe timezone: ${value}`); + }, + ), }); -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function checkedString( + defaultValue: string, + description: string, + check: (value: string) => void, +) { + const form = z + .string() + .description(description) + .default(defaultValue) + .volatile(); + const host = z + .transform(z.string(), (value) => { + check(value); + return value; + }) + .description(description) + .default(defaultValue) + .volatile(); + // DSH sends Config.toJSON() to the browser; keep Host validation while serializing a plain form field. + host.toJSON = () => form.toJSON(); + return host; } const DEFAULT_CONFIG: Readonly = Object.freeze({ diff --git a/integrations/dsh/tests/config.test.mjs b/integrations/dsh/tests/config.test.mjs index 1c52963e..8e2613eb 100644 --- a/integrations/dsh/tests/config.test.mjs +++ b/integrations/dsh/tests/config.test.mjs @@ -30,10 +30,24 @@ test("exports a Cordis schema that rejects invalid configuration", async () => { for (const input of [ { endpoint: "not-a-url" }, { endpoint: "http://localhost:bad-port" }, + { endpoint: "http://localhost:65536" }, + { endpoint: "http://[:::]" }, + { endpoint: "http://999.0.0.1" }, { dreamCron: "every night" }, { timezone: "Mars/Olympus" }, ]) { - assert.ok(Config["~standard"].validate(input).issues?.length); + assert.throws(() => Config["~standard"].validate(input)); + } + + for (const timezone of [ + "US/Pacific", + "US/Eastern", + "Etc/GMT+8", + "Etc/GMT-8", + ]) { + const result = Config["~standard"].validate({ timezone }); + assert.equal(result.issues, undefined); + assert.equal(result.value.timezone.get(), timezone); } }); @@ -51,9 +65,10 @@ test("volatile settings survive Host projection and browser schema rehydration", fields.map(([name]) => [name, defaults[name].get()]), ); assert.equal(browserForm["~standard"].validate(values).issues, undefined); - assert.ok( - browserForm["~standard"].validate({ ...values, dreamCron: "bad" }).issues - ?.length, + assert.equal( + browserForm["~standard"].validate({ ...values, timezone: "US/Pacific" }) + .issues, + undefined, ); }); @@ -116,28 +131,43 @@ test("Loader keeps the previous live values when an update fails schema validati const id = await ctx.loader.create({ id: "reme-memory", name: "cordis:remeTest", - config: { endpoint: "http://valid.test", autoDreamEnabled: false }, + config: { + endpoint: "http://valid.test", + autoMemoryEnabled: false, + autoDreamEnabled: false, + timezone: "US/Pacific", + }, }); const entry = ctx.loader.resolve(id); await entry.fiber.await(); for (const patch of [ { endpoint: "not-a-url" }, + { endpoint: "http://localhost:65536" }, + { endpoint: "http://999.0.0.1" }, { dreamCron: "every night" }, { timezone: "Mars/Olympus" }, ]) { await entry.update({ config: { endpoint: "http://valid.test", + autoMemoryEnabled: true, autoDreamEnabled: false, + timezone: "US/Pacific", ...patch, }, }); assert.equal(live.endpoint.get(), "http://valid.test"); + assert.equal(live.autoMemoryEnabled.get(), false); assert.equal(live.dreamCron.get(), "0 23 * * *"); - assert.equal(live.timezone.get(), "Asia/Shanghai"); + assert.equal(live.timezone.get(), "US/Pacific"); } await entry.update({ - config: { endpoint: "http://updated.test", autoDreamEnabled: false }, + config: { + endpoint: "http://updated.test", + autoMemoryEnabled: false, + autoDreamEnabled: false, + timezone: "US/Pacific", + }, }); assert.equal(live.endpoint.get(), "http://updated.test"); } finally {