Skip to content

Commit 3dda7e0

Browse files
dcramercodex
andcommitted
fix(scheduler): Harden optional plugin state access
Keep legacy scheduler state access scoped to the scheduler plugin namespace so the migration path does not expose arbitrary raw state prefixes. Clarify heartbeat docs now that scheduled tasks are enabled by registering the scheduler plugin explicitly. Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 78c1290 commit 3dda7e0

6 files changed

Lines changed: 61 additions & 11 deletions

File tree

apps/example/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ Copy `.env.example` and set:
2828
- `AI_VISION_MODEL` (optional, enables image-understanding; unset disables vision features)
2929
- `AI_WEB_SEARCH_MODEL` (optional, overrides the `webSearch` tool model; defaults to a search-tuned model)
3030
- `JUNIOR_SECRET` (required outside `pnpm dev`; the local wrapper supplies a dev-only secret when unset)
31-
- `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only secret when both are unset)
31+
- `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only heartbeat secret when both are unset)
3232
- `NOTION_TOKEN` (optional, enables the bundled Notion plugin)
3333

3434
## Wiring
3535

3636
- `plugin-packages.ts` is the single source of truth for installed plugin packages in this app
3737
- `nitro.config.ts` passes that list to `juniorNitro()` so plugin content is copied into the build output
3838
- `server.ts` passes the same list to `createApp()` so local dev does not depend on Nitro's virtual config path for plugin discovery
39-
- root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used by the built-in scheduler plugin; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover scheduled dispatches
39+
- root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for trusted plugin heartbeats and stale dispatch recovery; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover dispatched runs

packages/docs/src/content/docs/extend/scheduler-plugin.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ If you manage routes manually, call the heartbeat route on a one-minute cadence:
6666

6767
## Configure environment variables
6868

69-
Set one scheduler route secret:
69+
Set one heartbeat route secret:
7070

71-
| Variable | Required | Purpose |
72-
| ------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------- |
73-
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Production | Bearer token for internal scheduler and heartbeat routes. Use `CRON_SECRET` with Vercel Cron. |
74-
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for schedule authoring. Defaults to `America/Los_Angeles`. |
71+
| Variable | Required | Purpose |
72+
| ------------------------------------------ | ---------- | ---------------------------------------------------------------------------------- |
73+
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Production | Bearer token for the internal heartbeat route. Use `CRON_SECRET` with Vercel Cron. |
74+
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for schedule authoring. Defaults to `America/Los_Angeles`. |
7575

76-
Local development can run without a scheduler route secret when you call the dev server directly. Production deployments should set `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET`.
76+
Local development can run without a heartbeat route secret when you call the dev server directly. Production deployments should set `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET`.
7777

7878
## Verify
7979

packages/docs/src/content/docs/reference/config-and-env.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ related:
2525
| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. |
2626
| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. |
2727
| `JUNIOR_STATE_KEY_PREFIX` | No | Optional namespace prepended to all state-adapter keys, locks, and queues. Use separate prefixes when sharing one Redis database across environments. |
28-
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for internal scheduler and heartbeat routes; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. |
29-
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. |
28+
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for the internal heartbeat route; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for a non-Vercel heartbeat caller. |
29+
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring when the scheduler plugin is enabled. Defaults to `America/Los_Angeles`. |
3030
| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. |
3131

3232
Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app:

packages/docs/src/content/docs/start-here/deploy-to-vercel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Keep the Vercel build command as `pnpm build`. `junior snapshot create` prepares
4343

4444
## Enable the heartbeat cron
4545

46-
Junior uses a one-minute internal heartbeat to run scheduled tasks and recover stale agent dispatches. The scaffolded `vercel.json` should include this cron:
46+
Junior uses a one-minute internal heartbeat to run trusted plugin heartbeats and recover stale agent dispatches. The scheduler plugin uses this heartbeat when scheduled tasks are enabled. The scaffolded `vercel.json` should include this cron:
4747

4848
```json title="vercel.json"
4949
{

packages/junior/src/chat/plugins/agent-hooks.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,33 @@ let agentPlugins: JuniorPlugin[] = [];
4141
const AGENT_PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/;
4242
const AGENT_PLUGIN_TOOL_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
4343

44+
function validateLegacyStatePrefixes(plugin: JuniorPlugin): void {
45+
const prefixes = plugin.pluginConfig?.legacyStatePrefixes;
46+
if (prefixes === undefined) {
47+
return;
48+
}
49+
if (!Array.isArray(prefixes)) {
50+
throw new Error(
51+
`Trusted plugin "${plugin.name}" legacyStatePrefixes must be an array`,
52+
);
53+
}
54+
55+
const allowedPrefix = `junior:${plugin.name}`;
56+
for (const rawPrefix of prefixes) {
57+
const prefix = typeof rawPrefix === "string" ? rawPrefix.trim() : "";
58+
if (!prefix) {
59+
throw new Error(
60+
`Trusted plugin "${plugin.name}" legacy state prefixes must be non-empty strings`,
61+
);
62+
}
63+
if (prefix !== allowedPrefix && !prefix.startsWith(`${allowedPrefix}:`)) {
64+
throw new Error(
65+
`Trusted plugin "${plugin.name}" legacy state prefix "${prefix}" must stay under "${allowedPrefix}"`,
66+
);
67+
}
68+
}
69+
}
70+
4471
/** Validate trusted plugin identity before it can affect process-wide hooks. */
4572
export function validateAgentPlugins(plugins: JuniorPlugin[]): void {
4673
const seen = new Set<string>();
@@ -54,6 +81,7 @@ export function validateAgentPlugins(plugins: JuniorPlugin[]): void {
5481
throw new Error(`Duplicate trusted plugin name "${plugin.name}"`);
5582
}
5683
seen.add(plugin.name);
84+
validateLegacyStatePrefixes(plugin);
5785
}
5886
}
5987

packages/junior/tests/unit/app-config.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,26 @@ describe("createApp plugin config", () => {
250250
expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]);
251251
expect(getPluginProviders()).toEqual([]);
252252
});
253+
254+
it("rejects legacy state prefixes outside the trusted plugin namespace", async () => {
255+
await createApp({
256+
plugins: [],
257+
});
258+
259+
await expect(
260+
createApp({
261+
plugins: [
262+
defineJuniorPlugin({
263+
name: "trusted",
264+
pluginConfig: { legacyStatePrefixes: ["junior:scheduler"] },
265+
}),
266+
],
267+
}),
268+
).rejects.toThrow(
269+
'Trusted plugin "trusted" legacy state prefix "junior:scheduler" must stay under "junior:trusted"',
270+
);
271+
272+
expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]);
273+
expect(getPluginProviders()).toEqual([]);
274+
});
253275
});

0 commit comments

Comments
 (0)