Skip to content
Open
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
17 changes: 14 additions & 3 deletions electron/automationbuilder/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ function automationsRoot(): string {
return path.join(os.homedir(), ".copilot", "automations");
}

/** True when `dir` is `root` or nested inside it (so we can safely re-use it). */
function isInside(root: string, dir: string): boolean {
const rel = path.relative(root, dir);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

interface LiveBuild extends BaseLive {
sessionDir: string;
architecture: SkillArchitecture;
Expand Down Expand Up @@ -217,10 +223,15 @@ export class AutomationBuilder extends AgentBuilder<LiveBuild> {
const root = automationsRoot();
const name = slugifySkillName(automation.name);
const prior = loadPersistedAutomation(automation.sessionId);
const priorDir = prior?.exportedPath ? path.dirname(prior.exportedPath) : null;
// Re-export to the same folder if this session already exported one; otherwise pick
// a fresh, non-colliding directory so we never clobber an unrelated automation.
let dir = prior?.exportedPath ? path.dirname(prior.exportedPath) : path.join(root, name);
if (!prior?.exportedPath && existsSync(dir)) {
// the same in-root folder. Persisted paths outside the automations root may come
// from a prior download/export or tampered session data and must not be reused.
// Otherwise pick a fresh, non-colliding directory so we never clobber an unrelated
// automation.
const reuse = priorDir !== null && isInside(root, priorDir);
let dir = reuse ? (priorDir as string) : path.join(root, name);
if (!reuse && existsSync(dir)) {
let n = 2;
while (existsSync(path.join(root, `${name}-${n}`))) n++;
dir = path.join(root, `${name}-${n}`);
Expand Down
83 changes: 83 additions & 0 deletions electron/automationbuilder/export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { mkdirSync, readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";

import { AutomationPlanSchema } from "../../common/automation";
import { AutomationBuilder, loadPersistedAutomation } from "./builder";

test("automation re-export refuses persisted targets outside the automations root", async () => {
await withRoots(async ({ automationsRoot, sessionsRoot, outsideRoot }) => {
const builder = new AutomationBuilder(() => undefined);
const sessionId = "automation-reexport-safety";
mkdirSync(path.join(sessionsRoot, sessionId), { recursive: true });
const plan = AutomationPlanSchema.parse({
architecture: "cowork",
name: "daily-digest",
title: "Daily digest",
description: "Build a daily digest automation.",
trigger: {
type: "schedule",
schedule: {
kind: "single",
naturalLanguage: "Every weekday at 09:00",
days: [1, 2, 3, 4, 5],
time: { hour: 9, minute: 0 },
},
},
steps: [{ label: "Draft", prompt: "Draft the digest." }],
});

const first = await builder.create(sessionId, plan);
assert.match(first.path, new RegExp(`^${escapeRegExp(automationsRoot)}`));

const persistedPath = path.join(sessionsRoot, sessionId, "built-automation.json");
const persisted = loadPersistedAutomation(sessionId);
assert.ok(persisted?.exportedPath, "expected first create() to persist an exported path");

const escaped = path.join(outsideRoot, "escaped", "automation.json");
await writeFile(
persistedPath,
`${JSON.stringify({ ...persisted, exportedPath: escaped }, null, 2)}\n`,
"utf8",
);

const second = await builder.create(sessionId, plan);
assert.match(second.path, new RegExp(`^${escapeRegExp(automationsRoot)}`));
assert.doesNotMatch(second.path, new RegExp(`^${escapeRegExp(outsideRoot)}`));

const rendered = readFileSync(second.path, "utf8");
assert.match(rendered, /daily-digest/);
});
});

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

async function withRoots(
run: (roots: { automationsRoot: string; sessionsRoot: string; outsideRoot: string }) => Promise<void>,
): Promise<void> {
const automationsRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-automations-"));
const sessionsRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-sessions-"));
const outsideRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-outside-"));
const previousAutomations = process.env.SKILL_RECORDER_AUTOMATIONS_DIR;
const previousSessions = process.env.SKILL_RECORDER_SESSIONS_DIR;
process.env.SKILL_RECORDER_AUTOMATIONS_DIR = automationsRoot;
process.env.SKILL_RECORDER_SESSIONS_DIR = sessionsRoot;
try {
await run({ automationsRoot, sessionsRoot, outsideRoot });
} finally {
if (previousAutomations === undefined) delete process.env.SKILL_RECORDER_AUTOMATIONS_DIR;
else process.env.SKILL_RECORDER_AUTOMATIONS_DIR = previousAutomations;
if (previousSessions === undefined) delete process.env.SKILL_RECORDER_SESSIONS_DIR;
else process.env.SKILL_RECORDER_SESSIONS_DIR = previousSessions;
await Promise.all([
rm(automationsRoot, { recursive: true, force: true }),
rm(sessionsRoot, { recursive: true, force: true }),
rm(outsideRoot, { recursive: true, force: true }),
]);
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"check:lockfile": "node scripts/check-lockfile-portability.mjs",
"typecheck": "tsc --noEmit",
"typecheck:evals": "tsc --noEmit -p evals/tsconfig.json",
"test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
"test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts electron/automationbuilder/export.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
"eval": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts",
"eval:builder": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/builder/run.ts",
"eval:skill": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/skillbuilder/run.ts",
Expand Down