@bridge_gpt/mcp-server 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/build/agent-launchers/claude.js +85 -0
- package/build/agent-launchers/index.js +17 -0
- package/build/agent-launchers/types.js +1 -0
- package/build/chain-orchestrator.js +1150 -0
- package/build/chain-utils.js +68 -0
- package/build/commands.generated.js +3 -1
- package/build/fetch-stub.js +139 -0
- package/build/index.js +137 -10
- package/build/pipeline-orchestrator.js +57 -0
- package/build/pipelines.generated.js +132 -3
- package/build/schedule-run.js +951 -0
- package/build/schedule-store.js +132 -0
- package/build/scheduler-backends/at-fallback.js +144 -0
- package/build/scheduler-backends/escaping.js +113 -0
- package/build/scheduler-backends/index.js +72 -0
- package/build/scheduler-backends/launchd.js +216 -0
- package/build/scheduler-backends/systemd-user.js +237 -0
- package/build/scheduler-backends/task-scheduler.js +219 -0
- package/build/scheduler-backends/types.js +23 -0
- package/build/start-tickets.js +119 -59
- package/build/version.generated.js +1 -1
- package/package.json +7 -7
- package/pipelines/full-automation.json +47 -0
- package/pipelines/idea-to-ticket.json +71 -0
- package/smoke-test/SMOKE-TEST.md +509 -0
- package/smoke-test/smoke-test-mcp.md +23 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-only schedule metadata storage (BAPI-327).
|
|
3
|
+
*
|
|
4
|
+
* Schedules live entirely under `~/.bridge-gpt/schedules/` on the user's machine
|
|
5
|
+
* and are NEVER mirrored to the Bridge server. One pretty-printed JSON file per
|
|
6
|
+
* schedule id holds the metadata; stdout/stderr land in `logs/<id>.{out,err}`.
|
|
7
|
+
*
|
|
8
|
+
* All paths are rooted at the injected `homeDir` (never `os.homedir()` directly)
|
|
9
|
+
* and built with the platform path API, so the store is fully unit-testable for
|
|
10
|
+
* any OS from any host and never touches the real home directory in tests.
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
import { pathApiForPlatform } from "./scheduler-backends/types.js";
|
|
14
|
+
/**
|
|
15
|
+
* Build the schedule root from the injected home directory and platform path
|
|
16
|
+
* API: `~/.bridge-gpt/schedules` on POSIX, `%USERPROFILE%\.bridge-gpt\schedules`
|
|
17
|
+
* on Windows.
|
|
18
|
+
*/
|
|
19
|
+
export function getScheduleRoot(homeDir, platform) {
|
|
20
|
+
const pathApi = pathApiForPlatform(platform);
|
|
21
|
+
return pathApi.join(homeDir, ".bridge-gpt", "schedules");
|
|
22
|
+
}
|
|
23
|
+
/** Return the metadata + log paths for a schedule id under the schedule root. */
|
|
24
|
+
export function getSchedulePaths(id, homeDir, platform) {
|
|
25
|
+
const pathApi = pathApiForPlatform(platform);
|
|
26
|
+
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
27
|
+
const logsDir = pathApi.join(schedulesDir, "logs");
|
|
28
|
+
return {
|
|
29
|
+
schedulesDir,
|
|
30
|
+
logsDir,
|
|
31
|
+
metadataPath: pathApi.join(schedulesDir, `${id}.json`),
|
|
32
|
+
stdoutPath: pathApi.join(logsDir, `${id}.out`),
|
|
33
|
+
stderrPath: pathApi.join(logsDir, `${id}.err`),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create only the schedules directory and the logs directory. Must NOT be called
|
|
38
|
+
* by dry-run code paths — dry-run never touches the filesystem.
|
|
39
|
+
*/
|
|
40
|
+
export async function ensureScheduleDirectories(homeDir, platform) {
|
|
41
|
+
const pathApi = pathApiForPlatform(platform);
|
|
42
|
+
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
43
|
+
const logsDir = pathApi.join(schedulesDir, "logs");
|
|
44
|
+
await fs.mkdir(schedulesDir, { recursive: true });
|
|
45
|
+
await fs.mkdir(logsDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Persist one pretty-printed JSON file per schedule id with a trailing newline.
|
|
49
|
+
* Called only AFTER the scheduler backend successfully creates the unit/job.
|
|
50
|
+
*/
|
|
51
|
+
export async function writeScheduleMetadata(metadata, homeDir, platform) {
|
|
52
|
+
const { metadataPath } = getSchedulePaths(metadata.id, homeDir, platform);
|
|
53
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
|
|
54
|
+
}
|
|
55
|
+
/** Read and parse a single schedule metadata JSON file; `null` when missing. */
|
|
56
|
+
export async function readScheduleMetadata(id, homeDir, platform) {
|
|
57
|
+
const { metadataPath } = getSchedulePaths(id, homeDir, platform);
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = await fs.readFile(metadataPath, "utf-8");
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "ENOENT")
|
|
64
|
+
return null;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read every `*.json` metadata file from the schedule root. Malformed JSON is
|
|
71
|
+
* surfaced as a controlled parse-error row (never silently swallowed); a missing
|
|
72
|
+
* schedule directory yields an empty list, but any other filesystem error
|
|
73
|
+
* (e.g. EACCES) propagates rather than masquerading as "no schedules".
|
|
74
|
+
*/
|
|
75
|
+
export async function listScheduleMetadata(homeDir, platform) {
|
|
76
|
+
const pathApi = pathApiForPlatform(platform);
|
|
77
|
+
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = await fs.readdir(schedulesDir);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code === "ENOENT")
|
|
84
|
+
return [];
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const entry of entries.sort()) {
|
|
89
|
+
if (!entry.endsWith(".json"))
|
|
90
|
+
continue;
|
|
91
|
+
const id = entry.slice(0, -".json".length);
|
|
92
|
+
const fullPath = pathApi.join(schedulesDir, entry);
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await fs.readFile(fullPath, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
continue; // raced delete
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const metadata = JSON.parse(raw);
|
|
104
|
+
rows.push({ ok: true, id, metadata, path: fullPath });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
rows.push({
|
|
108
|
+
ok: false,
|
|
109
|
+
id,
|
|
110
|
+
path: fullPath,
|
|
111
|
+
error: error instanceof Error ? error.message : String(error),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Delete the schedule metadata JSON file after a successful or stale cancel. The
|
|
119
|
+
* local log files (`logs/<id>.out|.err`) are deliberately left in place for
|
|
120
|
+
* post-run inspection.
|
|
121
|
+
*/
|
|
122
|
+
export async function deleteScheduleMetadata(id, homeDir, platform) {
|
|
123
|
+
const { metadataPath } = getSchedulePaths(id, homeDir, platform);
|
|
124
|
+
try {
|
|
125
|
+
await fs.unlink(metadataPath);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code === "ENOENT")
|
|
129
|
+
return;
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { formatEnvExportsForGeneratedUnit, posixShellQuote } from "./escaping.js";
|
|
2
|
+
/** Virtual artifact path for the script piped to `at` (never written to disk). */
|
|
3
|
+
export const AT_STDIN_ARTIFACT_PATH = "<at-stdin>";
|
|
4
|
+
/** Convert an ISO timestamp to the `YYYYMMDDHHMM` form required by `at -t`. */
|
|
5
|
+
export function formatAtTimestamp(runAtIso) {
|
|
6
|
+
const d = new Date(runAtIso);
|
|
7
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
8
|
+
return (`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
9
|
+
`${pad(d.getHours())}${pad(d.getMinutes())}`);
|
|
10
|
+
}
|
|
11
|
+
/** Render the script piped into `at`. */
|
|
12
|
+
export function renderAtScript(input) {
|
|
13
|
+
const exports = formatEnvExportsForGeneratedUnit({
|
|
14
|
+
envPath: input.envPath,
|
|
15
|
+
nodePath: input.nodePath,
|
|
16
|
+
npxPath: input.npxPath,
|
|
17
|
+
claudePath: input.claudePath,
|
|
18
|
+
ideaFile: input.ideaFile,
|
|
19
|
+
repoPath: input.repoPath,
|
|
20
|
+
});
|
|
21
|
+
const command = [input.invocation.exe, ...input.invocation.args]
|
|
22
|
+
.map((part) => posixShellQuote(part))
|
|
23
|
+
.join(" ");
|
|
24
|
+
return [
|
|
25
|
+
"#!/bin/sh",
|
|
26
|
+
exports,
|
|
27
|
+
`cd ${posixShellQuote(input.repoPath)}`,
|
|
28
|
+
`${command} >>${posixShellQuote(input.paths.stdoutPath)} 2>>${posixShellQuote(input.paths.stderrPath)}`,
|
|
29
|
+
"",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse an `at` job id from common stdout/stderr forms; `null` when absent.
|
|
34
|
+
* Anchored to the start of a line so a stray earlier "job" word can't be
|
|
35
|
+
* mistaken for the job line, and (defensively, should `at` ever print more than
|
|
36
|
+
* one) the LAST `job <id> at …` line wins.
|
|
37
|
+
*/
|
|
38
|
+
export function parseAtJobId(stdout, stderr) {
|
|
39
|
+
const combined = `${stdout}\n${stderr}`;
|
|
40
|
+
const matches = [...combined.matchAll(/^job\s+(\d+)\s+at\b/gim)];
|
|
41
|
+
return matches.length > 0 ? matches[matches.length - 1][1] : null;
|
|
42
|
+
}
|
|
43
|
+
/** Whether both `at` and `atq` resolve (used by isAvailable). */
|
|
44
|
+
async function atToolsResolvable(deps) {
|
|
45
|
+
const at = await deps.runCommand("which", ["at"]);
|
|
46
|
+
if (at.exitCode !== 0)
|
|
47
|
+
return false;
|
|
48
|
+
const atq = await deps.runCommand("which", ["atq"]);
|
|
49
|
+
return atq.exitCode === 0;
|
|
50
|
+
}
|
|
51
|
+
/** Create the Linux `at` fallback backend. */
|
|
52
|
+
export function createAtFallbackBackend() {
|
|
53
|
+
return {
|
|
54
|
+
name: "at-fallback",
|
|
55
|
+
async isAvailable(deps) {
|
|
56
|
+
if (deps.platform !== "linux")
|
|
57
|
+
return false;
|
|
58
|
+
return atToolsResolvable(deps);
|
|
59
|
+
},
|
|
60
|
+
async create(input) {
|
|
61
|
+
const script = renderAtScript(input);
|
|
62
|
+
const artifact = {
|
|
63
|
+
path: AT_STDIN_ARTIFACT_PATH,
|
|
64
|
+
content: script,
|
|
65
|
+
kind: "at-script",
|
|
66
|
+
};
|
|
67
|
+
if (input.dryRun) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
backend: "at-fallback",
|
|
71
|
+
unitPath: null,
|
|
72
|
+
unitPaths: [],
|
|
73
|
+
backendJobId: null,
|
|
74
|
+
artifacts: [artifact],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const timestamp = formatAtTimestamp(input.runAtIso);
|
|
78
|
+
const result = await input.deps.runCommand("at", ["-t", timestamp], { input: script });
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
backend: "at-fallback",
|
|
83
|
+
unitPath: null,
|
|
84
|
+
unitPaths: [],
|
|
85
|
+
backendJobId: null,
|
|
86
|
+
artifacts: [artifact],
|
|
87
|
+
error: `at scheduling failed: ${(result.stderr || result.stdout).trim()}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const jobId = parseAtJobId(result.stdout, result.stderr);
|
|
91
|
+
if (!jobId) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
backend: "at-fallback",
|
|
95
|
+
unitPath: null,
|
|
96
|
+
unitPaths: [],
|
|
97
|
+
backendJobId: null,
|
|
98
|
+
artifacts: [artifact],
|
|
99
|
+
error: "Could not parse an at job id from the scheduler output.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
backend: "at-fallback",
|
|
105
|
+
unitPath: null,
|
|
106
|
+
unitPaths: [],
|
|
107
|
+
backendJobId: jobId,
|
|
108
|
+
artifacts: [artifact],
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
async list(input) {
|
|
112
|
+
const atq = await input.deps.runCommand("atq", []);
|
|
113
|
+
if (atq.exitCode !== 0) {
|
|
114
|
+
return input.recorded.map((metadata) => ({
|
|
115
|
+
metadata,
|
|
116
|
+
status: "backend-unavailable",
|
|
117
|
+
detail: "atq unavailable",
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
const liveJobIds = new Set(atq.stdout
|
|
121
|
+
.split(/\r?\n/)
|
|
122
|
+
.map((line) => line.trim().match(/^(\d+)\b/)?.[1])
|
|
123
|
+
.filter((id) => Boolean(id)));
|
|
124
|
+
return input.recorded.map((metadata) => {
|
|
125
|
+
const jobId = metadata.backend_job_id;
|
|
126
|
+
const active = jobId !== null && liveJobIds.has(jobId);
|
|
127
|
+
return {
|
|
128
|
+
metadata,
|
|
129
|
+
status: active ? "active" : "stale",
|
|
130
|
+
detail: active ? `at job ${jobId}` : "at job not queued",
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
async cancel(input) {
|
|
135
|
+
const jobId = input.metadata.backend_job_id;
|
|
136
|
+
if (!jobId) {
|
|
137
|
+
return { ok: true, nativeRemoved: false, stale: true };
|
|
138
|
+
}
|
|
139
|
+
const result = await input.deps.runCommand("atrm", [jobId]);
|
|
140
|
+
const nativeRemoved = result.exitCode === 0;
|
|
141
|
+
return { ok: true, nativeRemoved, stale: !nativeRemoved };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escaping / quoting helpers shared by every scheduler backend (BAPI-327).
|
|
3
|
+
*
|
|
4
|
+
* Generated unit content is attacker-adjacent: idea-file paths, repo paths, and
|
|
5
|
+
* prompts flow into plist XML, Windows `.cmd` wrappers, systemd unit directives,
|
|
6
|
+
* and `at` heredoc scripts. Each target has a different quoting grammar, so each
|
|
7
|
+
* gets a dedicated helper. Every helper first rejects NUL bytes — a NUL can
|
|
8
|
+
* truncate a generated file mid-write and silently drop a security-relevant
|
|
9
|
+
* suffix — via {@link assertNoNul}.
|
|
10
|
+
*/
|
|
11
|
+
/** All baked PATH-trap environment variable names, in a stable order. */
|
|
12
|
+
export const BAKED_ENV_VAR_NAMES = [
|
|
13
|
+
"PATH",
|
|
14
|
+
"BRIDGE_GPT_NODE",
|
|
15
|
+
"BRIDGE_GPT_NPX",
|
|
16
|
+
"BRIDGE_GPT_CLAUDE",
|
|
17
|
+
"BRIDGE_GPT_IDEA_FILE",
|
|
18
|
+
"BRIDGE_GPT_REPO_PATH",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Reject any generated value containing a NUL byte. NUL can prematurely
|
|
22
|
+
* terminate a C-string when a unit file is parsed by the OS scheduler, silently
|
|
23
|
+
* dropping the rest of the value. The `label` names the offending field so the
|
|
24
|
+
* error is actionable.
|
|
25
|
+
*/
|
|
26
|
+
export function assertNoNul(value, label) {
|
|
27
|
+
if (value.includes("\0")) {
|
|
28
|
+
throw new Error(`Refusing to generate scheduler content: ${label} contains a NUL byte, which is not allowed.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Escape a string for use inside a launchd plist `<string>` value. */
|
|
32
|
+
export function xmlEscape(value) {
|
|
33
|
+
assertNoNul(value, "xml value");
|
|
34
|
+
return value
|
|
35
|
+
.replace(/&/g, "&")
|
|
36
|
+
.replace(/</g, "<")
|
|
37
|
+
.replace(/>/g, ">")
|
|
38
|
+
.replace(/"/g, """)
|
|
39
|
+
.replace(/'/g, "'");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Quote a value as a single POSIX shell argument (single-quote form). Empty
|
|
43
|
+
* strings and strings with spaces become a quoted token; embedded single quotes
|
|
44
|
+
* use the canonical close/escape/reopen `'\''` form. Used by the `at` heredoc.
|
|
45
|
+
*/
|
|
46
|
+
export function posixShellQuote(value) {
|
|
47
|
+
assertNoNul(value, "posix shell value");
|
|
48
|
+
if (value === "")
|
|
49
|
+
return "''";
|
|
50
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Quote a value for a Windows command line (a single argument inside a `.cmd`
|
|
54
|
+
* wrapper). Wraps in double quotes and escapes embedded double quotes the
|
|
55
|
+
* cmd-compatible way. Always quoting keeps paths/prompts with spaces intact.
|
|
56
|
+
*/
|
|
57
|
+
export function windowsCmdQuote(value) {
|
|
58
|
+
assertNoNul(value, "windows cmd value");
|
|
59
|
+
// Within double quotes, cmd treats "" as a literal quote.
|
|
60
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Escape a value to be assigned via `set "KEY=value"` in a `.cmd` wrapper. The
|
|
64
|
+
* `set "KEY=..."` quoted form already neutralises `& | < > ^`, but a literal `%`
|
|
65
|
+
* still triggers variable expansion, so it is doubled to `%%`. A double quote
|
|
66
|
+
* would close the `set` quoting, so it is dropped/escaped to keep the line safe.
|
|
67
|
+
*/
|
|
68
|
+
export function escapeWindowsCmdSetValue(value) {
|
|
69
|
+
assertNoNul(value, "windows cmd set value");
|
|
70
|
+
return value.replace(/%/g, "%%").replace(/"/g, "");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Quote a value for a systemd unit directive (e.g. `ExecStart=`, `Environment=`,
|
|
74
|
+
* `WorkingDirectory=`). systemd accepts double-quoted tokens with C-style
|
|
75
|
+
* backslash escaping for embedded backslashes and quotes. Empty strings render
|
|
76
|
+
* as an explicit empty quoted token.
|
|
77
|
+
*/
|
|
78
|
+
export function systemdQuote(value) {
|
|
79
|
+
assertNoNul(value, "systemd value");
|
|
80
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
81
|
+
return `"${escaped}"`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Build the ordered baked PATH-trap env entries from explicit, schedule-time
|
|
85
|
+
* values — never `process.env`. Each value is NUL-checked. This is the single
|
|
86
|
+
* source every backend uses so PATH, BRIDGE_GPT_NODE, BRIDGE_GPT_NPX,
|
|
87
|
+
* BRIDGE_GPT_CLAUDE, BRIDGE_GPT_IDEA_FILE, and BRIDGE_GPT_REPO_PATH are baked
|
|
88
|
+
* consistently across launchd / Task Scheduler / systemd / at.
|
|
89
|
+
*/
|
|
90
|
+
export function bakedEnvEntries(env) {
|
|
91
|
+
const entries = [
|
|
92
|
+
["PATH", env.envPath],
|
|
93
|
+
["BRIDGE_GPT_NODE", env.nodePath],
|
|
94
|
+
["BRIDGE_GPT_NPX", env.npxPath],
|
|
95
|
+
["BRIDGE_GPT_CLAUDE", env.claudePath],
|
|
96
|
+
["BRIDGE_GPT_IDEA_FILE", env.ideaFile],
|
|
97
|
+
["BRIDGE_GPT_REPO_PATH", env.repoPath],
|
|
98
|
+
];
|
|
99
|
+
for (const [key, value] of entries) {
|
|
100
|
+
assertNoNul(value, key);
|
|
101
|
+
}
|
|
102
|
+
return entries;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Render the baked env as POSIX `export KEY='value'` lines (used by the `at`
|
|
106
|
+
* heredoc script). Values come only from {@link bakedEnvEntries}; no value is
|
|
107
|
+
* ever read from the ambient process environment.
|
|
108
|
+
*/
|
|
109
|
+
export function formatEnvExportsForGeneratedUnit(env) {
|
|
110
|
+
return bakedEnvEntries(env)
|
|
111
|
+
.map(([key, value]) => `export ${key}=${posixShellQuote(value)}`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createLaunchdBackend } from "./launchd.js";
|
|
2
|
+
import { createTaskSchedulerBackend } from "./task-scheduler.js";
|
|
3
|
+
import { createSystemdUserBackend } from "./systemd-user.js";
|
|
4
|
+
import { createAtFallbackBackend } from "./at-fallback.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
const LAUNCHD = createLaunchdBackend();
|
|
7
|
+
const TASK_SCHEDULER = createTaskSchedulerBackend();
|
|
8
|
+
const SYSTEMD_USER = createSystemdUserBackend();
|
|
9
|
+
const AT_FALLBACK = createAtFallbackBackend();
|
|
10
|
+
const SUPPORTED_PLATFORMS = ["darwin", "win32", "linux"];
|
|
11
|
+
/** Controlled message for an unsupported `process.platform`. */
|
|
12
|
+
export function unsupportedSchedulerPlatformMessage(platform) {
|
|
13
|
+
return (`Unsupported platform '${platform}'. schedule-run supports ` +
|
|
14
|
+
`${SUPPORTED_PLATFORMS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
/** Return the backend instance for a specific name, or `null` when unknown. */
|
|
17
|
+
export function getSchedulerBackendByName(name) {
|
|
18
|
+
switch (name) {
|
|
19
|
+
case "launchd":
|
|
20
|
+
return LAUNCHD;
|
|
21
|
+
case "task-scheduler":
|
|
22
|
+
return TASK_SCHEDULER;
|
|
23
|
+
case "systemd-user":
|
|
24
|
+
return SYSTEMD_USER;
|
|
25
|
+
case "at-fallback":
|
|
26
|
+
return AT_FALLBACK;
|
|
27
|
+
default:
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Return the ordered candidate backends for a platform (or unsupported). */
|
|
32
|
+
export function getSchedulerBackendsForPlatform(platform) {
|
|
33
|
+
switch (platform) {
|
|
34
|
+
case "darwin":
|
|
35
|
+
return { ok: true, backends: [LAUNCHD] };
|
|
36
|
+
case "win32":
|
|
37
|
+
return { ok: true, backends: [TASK_SCHEDULER] };
|
|
38
|
+
case "linux":
|
|
39
|
+
return { ok: true, backends: [SYSTEMD_USER, AT_FALLBACK] };
|
|
40
|
+
default:
|
|
41
|
+
return { ok: false, error: unsupportedSchedulerPlatformMessage(platform) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Select the scheduler backend for a create.
|
|
46
|
+
*
|
|
47
|
+
* For dry-run, return the platform's PRIMARY backend without probing tool
|
|
48
|
+
* availability — previewing a unit must not depend on installed schedulers. For
|
|
49
|
+
* real creates, return the first backend whose `isAvailable` resolves true
|
|
50
|
+
* (Linux prefers systemd-user, then at-fallback). Returns a controlled error
|
|
51
|
+
* (never throws) for unsupported platforms or when nothing is available.
|
|
52
|
+
*/
|
|
53
|
+
export async function selectSchedulerBackend(deps, dryRun) {
|
|
54
|
+
const platformResult = getSchedulerBackendsForPlatform(deps.platform);
|
|
55
|
+
if (!platformResult.ok) {
|
|
56
|
+
return { ok: false, error: platformResult.error };
|
|
57
|
+
}
|
|
58
|
+
const candidates = platformResult.backends;
|
|
59
|
+
if (dryRun) {
|
|
60
|
+
return { ok: true, backend: candidates[0] };
|
|
61
|
+
}
|
|
62
|
+
for (const backend of candidates) {
|
|
63
|
+
if (await backend.isAvailable(deps)) {
|
|
64
|
+
return { ok: true, backend };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `No available scheduler backend for platform '${deps.platform}'. ` +
|
|
70
|
+
`Tried: ${candidates.map((b) => b.name).join(", ")}.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS launchd backend (BAPI-327).
|
|
3
|
+
*
|
|
4
|
+
* Generates and installs a one-shot LaunchAgent plist that fires the baked
|
|
5
|
+
* Claude invocation at a single calendar minute via `StartCalendarInterval`.
|
|
6
|
+
*
|
|
7
|
+
* Drift policy: the plist omits `RunAtLoad` as defense-in-depth (it avoids an
|
|
8
|
+
* immediate run when the agent is reloaded at login), but this is NOT a true
|
|
9
|
+
* no-late-fire guarantee — launchd still fires a missed `StartCalendarInterval`
|
|
10
|
+
* event on the next wake. Unlike systemd `Persistent=false` / Task Scheduler,
|
|
11
|
+
* launchd has no OS-level "skip if missed" knob, so the authoritative staleness
|
|
12
|
+
* guard is the baked `--scheduled-at <T>` value in the prompt, which
|
|
13
|
+
* `/full-automation` (Phase C) checks before running. The working directory is
|
|
14
|
+
* set by the `WorkingDirectory` key, not a Claude flag. `ProgramArguments` is a
|
|
15
|
+
* string array (no shell concatenation).
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import { pathApiForPlatform } from "./types.js";
|
|
19
|
+
import { bakedEnvEntries, xmlEscape } from "./escaping.js";
|
|
20
|
+
/** launchd label for a schedule id. */
|
|
21
|
+
export function launchdLabelForId(id) {
|
|
22
|
+
return `com.bridge-gpt.full-automation.${id}`;
|
|
23
|
+
}
|
|
24
|
+
/** `~/Library/LaunchAgents/<label>.plist` for a schedule id. */
|
|
25
|
+
export function launchdPlistPathForId(id, homeDir) {
|
|
26
|
+
const pathApi = pathApiForPlatform("darwin");
|
|
27
|
+
return pathApi.join(homeDir, "Library", "LaunchAgents", `${launchdLabelForId(id)}.plist`);
|
|
28
|
+
}
|
|
29
|
+
/** Convert an ISO timestamp to local launchd calendar fields (minute precision). */
|
|
30
|
+
export function startCalendarIntervalFromIso(runAtIso) {
|
|
31
|
+
const date = new Date(runAtIso);
|
|
32
|
+
return {
|
|
33
|
+
Year: date.getFullYear(),
|
|
34
|
+
Month: date.getMonth() + 1, // launchd months are 1-12
|
|
35
|
+
Day: date.getDate(),
|
|
36
|
+
Hour: date.getHours(),
|
|
37
|
+
Minute: date.getMinutes(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** UID used by `launchctl` domain targets; falls back to the process UID. */
|
|
41
|
+
function resolveUid(deps) {
|
|
42
|
+
const fromEnv = deps.env.UID;
|
|
43
|
+
if (fromEnv && /^\d+$/.test(fromEnv))
|
|
44
|
+
return fromEnv;
|
|
45
|
+
const procUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
46
|
+
return procUid !== undefined ? String(procUid) : "501";
|
|
47
|
+
}
|
|
48
|
+
/** Render a valid one-shot launchd plist. */
|
|
49
|
+
export function renderLaunchdPlist(input) {
|
|
50
|
+
const label = launchdLabelForId(input.id);
|
|
51
|
+
const cal = startCalendarIntervalFromIso(input.runAtIso);
|
|
52
|
+
const env = bakedEnvEntries({
|
|
53
|
+
envPath: input.envPath,
|
|
54
|
+
nodePath: input.nodePath,
|
|
55
|
+
npxPath: input.npxPath,
|
|
56
|
+
claudePath: input.claudePath,
|
|
57
|
+
ideaFile: input.ideaFile,
|
|
58
|
+
repoPath: input.repoPath,
|
|
59
|
+
});
|
|
60
|
+
const programArgs = [input.invocation.exe, ...input.invocation.args]
|
|
61
|
+
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
62
|
+
.join("\n");
|
|
63
|
+
const envEntries = env
|
|
64
|
+
.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
|
|
65
|
+
.join("\n");
|
|
66
|
+
// No run-at-load key (defense-in-depth: avoids an immediate run on agent
|
|
67
|
+
// reload). This does NOT stop a missed StartCalendarInterval from firing on
|
|
68
|
+
// wake — staleness is enforced by `/full-automation --scheduled-at` (Phase C).
|
|
69
|
+
return [
|
|
70
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
71
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
72
|
+
'<plist version="1.0">',
|
|
73
|
+
"<dict>",
|
|
74
|
+
" <key>Label</key>",
|
|
75
|
+
` <string>${xmlEscape(label)}</string>`,
|
|
76
|
+
" <key>StartCalendarInterval</key>",
|
|
77
|
+
" <dict>",
|
|
78
|
+
` <key>Year</key><integer>${cal.Year}</integer>`,
|
|
79
|
+
` <key>Month</key><integer>${cal.Month}</integer>`,
|
|
80
|
+
` <key>Day</key><integer>${cal.Day}</integer>`,
|
|
81
|
+
` <key>Hour</key><integer>${cal.Hour}</integer>`,
|
|
82
|
+
` <key>Minute</key><integer>${cal.Minute}</integer>`,
|
|
83
|
+
" </dict>",
|
|
84
|
+
" <key>EnvironmentVariables</key>",
|
|
85
|
+
" <dict>",
|
|
86
|
+
envEntries,
|
|
87
|
+
" </dict>",
|
|
88
|
+
" <key>ProgramArguments</key>",
|
|
89
|
+
" <array>",
|
|
90
|
+
programArgs,
|
|
91
|
+
" </array>",
|
|
92
|
+
" <key>WorkingDirectory</key>",
|
|
93
|
+
` <string>${xmlEscape(input.repoPath)}</string>`,
|
|
94
|
+
" <key>StandardOutPath</key>",
|
|
95
|
+
` <string>${xmlEscape(input.paths.stdoutPath)}</string>`,
|
|
96
|
+
" <key>StandardErrorPath</key>",
|
|
97
|
+
` <string>${xmlEscape(input.paths.stderrPath)}</string>`,
|
|
98
|
+
"</dict>",
|
|
99
|
+
"</plist>",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
/** Create the macOS launchd scheduler backend. */
|
|
104
|
+
export function createLaunchdBackend() {
|
|
105
|
+
return {
|
|
106
|
+
name: "launchd",
|
|
107
|
+
async isAvailable(deps) {
|
|
108
|
+
return deps.platform === "darwin";
|
|
109
|
+
},
|
|
110
|
+
async create(input) {
|
|
111
|
+
const plistPath = launchdPlistPathForId(input.id, input.deps.homeDir);
|
|
112
|
+
const content = renderLaunchdPlist(input);
|
|
113
|
+
const artifact = { path: plistPath, content, kind: "launchd-plist" };
|
|
114
|
+
if (input.dryRun) {
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
backend: "launchd",
|
|
118
|
+
unitPath: plistPath,
|
|
119
|
+
unitPaths: [plistPath],
|
|
120
|
+
backendJobId: null,
|
|
121
|
+
artifacts: [artifact],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// ~/Library/LaunchAgents may not exist yet for a fresh user account.
|
|
125
|
+
await fs.mkdir(pathApiForPlatform("darwin").dirname(plistPath), { recursive: true });
|
|
126
|
+
await fs.writeFile(plistPath, content, "utf-8");
|
|
127
|
+
const uid = resolveUid(input.deps);
|
|
128
|
+
const result = await input.deps.runCommand("launchctl", [
|
|
129
|
+
"bootstrap",
|
|
130
|
+
`gui/${uid}`,
|
|
131
|
+
plistPath,
|
|
132
|
+
]);
|
|
133
|
+
if (result.exitCode !== 0) {
|
|
134
|
+
// Don't leave an orphaned plist behind when bootstrap fails.
|
|
135
|
+
await fs.unlink(plistPath).catch(() => undefined);
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
backend: "launchd",
|
|
139
|
+
unitPath: plistPath,
|
|
140
|
+
unitPaths: [plistPath],
|
|
141
|
+
backendJobId: null,
|
|
142
|
+
artifacts: [artifact],
|
|
143
|
+
error: `launchctl bootstrap failed: ${(result.stderr || result.stdout).trim()}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
backend: "launchd",
|
|
149
|
+
unitPath: plistPath,
|
|
150
|
+
unitPaths: [plistPath],
|
|
151
|
+
backendJobId: null,
|
|
152
|
+
artifacts: [artifact],
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
async list(input) {
|
|
156
|
+
const uid = resolveUid(input.deps);
|
|
157
|
+
const entries = [];
|
|
158
|
+
for (const metadata of input.recorded) {
|
|
159
|
+
const plistPath = metadata.unit_path ?? launchdPlistPathForId(metadata.id, input.deps.homeDir);
|
|
160
|
+
let exists = true;
|
|
161
|
+
try {
|
|
162
|
+
await fs.access(plistPath);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
exists = false;
|
|
166
|
+
}
|
|
167
|
+
if (!exists) {
|
|
168
|
+
entries.push({ metadata, status: "stale", detail: "plist missing" });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const label = launchdLabelForId(metadata.id);
|
|
172
|
+
const printed = await input.deps.runCommand("launchctl", ["print", `gui/${uid}/${label}`]);
|
|
173
|
+
entries.push({
|
|
174
|
+
metadata,
|
|
175
|
+
status: printed.exitCode === 0 ? "active" : "stale",
|
|
176
|
+
detail: printed.exitCode === 0 ? "loaded" : "plist present but not loaded",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
},
|
|
181
|
+
async cancel(input) {
|
|
182
|
+
const uid = resolveUid(input.deps);
|
|
183
|
+
const label = launchdLabelForId(input.metadata.id);
|
|
184
|
+
const plistPath = input.metadata.unit_path ?? launchdPlistPathForId(input.metadata.id, input.deps.homeDir);
|
|
185
|
+
let plistExisted = true;
|
|
186
|
+
try {
|
|
187
|
+
await fs.access(plistPath);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
plistExisted = false;
|
|
191
|
+
}
|
|
192
|
+
// bootout is best-effort: an already-unloaded label returns non-zero, which
|
|
193
|
+
// is treated as stale-but-cancelable rather than a hard failure.
|
|
194
|
+
const bootout = await input.deps.runCommand("launchctl", [
|
|
195
|
+
"bootout",
|
|
196
|
+
`gui/${uid}/${label}`,
|
|
197
|
+
]);
|
|
198
|
+
if (plistExisted) {
|
|
199
|
+
try {
|
|
200
|
+
await fs.unlink(plistPath);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code !== "ENOENT") {
|
|
204
|
+
return { ok: false, nativeRemoved: false, stale: false, error: String(error) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const nativeRemoved = bootout.exitCode === 0;
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
nativeRemoved,
|
|
212
|
+
stale: !plistExisted || !nativeRemoved,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|