@bridge_gpt/mcp-server 0.2.49 → 0.2.51
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 +25 -8
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +51 -0
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +352 -4
- package/build/epic-integration-pr.js +280 -0
- package/build/executor/job-runner.js +7 -1
- package/build/executor/merge-job.js +46 -1
- package/build/executor/worktree.js +46 -1
- package/build/index.js +153 -65
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +8 -2
- package/build/plan-epic-conductor-eligibility.js +183 -0
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/setup-epic.js +32 -0
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +6 -3
- package/pipelines/plan-epic.json +5 -0
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- package/build/scheduler-backends/types.js +0 -23
package/build/schedule-store.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
/** Upper bound on retained run-history events per schedule (oldest dropped). */
|
|
15
|
-
export const MAX_RUN_HISTORY_EVENTS = 50;
|
|
16
|
-
/** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
|
|
17
|
-
let atomicWriteCounter = 0;
|
|
18
|
-
/**
|
|
19
|
-
* Build the schedule root from the injected home directory and platform path
|
|
20
|
-
* API: `~/.bridge-gpt/schedules` on POSIX, `%USERPROFILE%\.bridge-gpt\schedules`
|
|
21
|
-
* on Windows.
|
|
22
|
-
*/
|
|
23
|
-
export function getScheduleRoot(homeDir, platform) {
|
|
24
|
-
const pathApi = pathApiForPlatform(platform);
|
|
25
|
-
return pathApi.join(homeDir, ".bridge-gpt", "schedules");
|
|
26
|
-
}
|
|
27
|
-
/** Return the metadata + log paths for a schedule id under the schedule root. */
|
|
28
|
-
export function getSchedulePaths(id, homeDir, platform) {
|
|
29
|
-
const pathApi = pathApiForPlatform(platform);
|
|
30
|
-
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
31
|
-
const logsDir = pathApi.join(schedulesDir, "logs");
|
|
32
|
-
return {
|
|
33
|
-
schedulesDir,
|
|
34
|
-
logsDir,
|
|
35
|
-
metadataPath: pathApi.join(schedulesDir, `${id}.json`),
|
|
36
|
-
stdoutPath: pathApi.join(logsDir, `${id}.out`),
|
|
37
|
-
stderrPath: pathApi.join(logsDir, `${id}.err`),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Create only the schedules directory and the logs directory. Must NOT be called
|
|
42
|
-
* by dry-run code paths — dry-run never touches the filesystem.
|
|
43
|
-
*/
|
|
44
|
-
export async function ensureScheduleDirectories(homeDir, platform) {
|
|
45
|
-
const pathApi = pathApiForPlatform(platform);
|
|
46
|
-
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
47
|
-
const logsDir = pathApi.join(schedulesDir, "logs");
|
|
48
|
-
await fs.mkdir(schedulesDir, { recursive: true });
|
|
49
|
-
await fs.mkdir(logsDir, { recursive: true });
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Persist one pretty-printed JSON file per schedule id with a trailing newline.
|
|
53
|
-
* Called only AFTER the scheduler backend successfully creates the unit/job.
|
|
54
|
-
*
|
|
55
|
-
* The write is atomic (BAPI-351): the content is written to a temp file in the
|
|
56
|
-
* same directory and renamed into place, so a concurrent `_execute` run-history
|
|
57
|
-
* append never observes a partially-written metadata file, and a crash mid-write
|
|
58
|
-
* leaves the prior valid file intact rather than a truncated one.
|
|
59
|
-
*/
|
|
60
|
-
export async function writeScheduleMetadata(metadata, homeDir, platform) {
|
|
61
|
-
const { metadataPath } = getSchedulePaths(metadata.id, homeDir, platform);
|
|
62
|
-
const content = `${JSON.stringify(metadata, null, 2)}\n`;
|
|
63
|
-
const tmpPath = `${metadataPath}.tmp-${process.pid}-${atomicWriteCounter++}`;
|
|
64
|
-
try {
|
|
65
|
-
await fs.writeFile(tmpPath, content, "utf-8");
|
|
66
|
-
await fs.rename(tmpPath, metadataPath);
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
// Best-effort cleanup of the temp file; never leave it orphaned, and never
|
|
70
|
-
// partially overwrite the real metadata file.
|
|
71
|
-
await fs.unlink(tmpPath).catch(() => undefined);
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Append a bounded run-history event to a schedule's metadata and rewrite it
|
|
77
|
-
* atomically (BAPI-351). Tolerant of legacy records that have no `run_history`
|
|
78
|
-
* (initializes it). Returns the updated metadata, or `null` if the schedule
|
|
79
|
-
* metadata no longer exists.
|
|
80
|
-
*/
|
|
81
|
-
export async function appendScheduleRunEvent(id, event, homeDir, platform) {
|
|
82
|
-
const metadata = await readScheduleMetadata(id, homeDir, platform);
|
|
83
|
-
if (!metadata)
|
|
84
|
-
return null;
|
|
85
|
-
const history = Array.isArray(metadata.run_history) ? [...metadata.run_history] : [];
|
|
86
|
-
history.push(event);
|
|
87
|
-
// Keep the newest events when bounding (drop from the front).
|
|
88
|
-
metadata.run_history =
|
|
89
|
-
history.length > MAX_RUN_HISTORY_EVENTS
|
|
90
|
-
? history.slice(history.length - MAX_RUN_HISTORY_EVENTS)
|
|
91
|
-
: history;
|
|
92
|
-
await writeScheduleMetadata(metadata, homeDir, platform);
|
|
93
|
-
return metadata;
|
|
94
|
-
}
|
|
95
|
-
/** Read and parse a single schedule metadata JSON file; `null` when missing. */
|
|
96
|
-
export async function readScheduleMetadata(id, homeDir, platform) {
|
|
97
|
-
const { metadataPath } = getSchedulePaths(id, homeDir, platform);
|
|
98
|
-
let raw;
|
|
99
|
-
try {
|
|
100
|
-
raw = await fs.readFile(metadataPath, "utf-8");
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
if (error.code === "ENOENT")
|
|
104
|
-
return null;
|
|
105
|
-
throw error;
|
|
106
|
-
}
|
|
107
|
-
return JSON.parse(raw);
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Read every `*.json` metadata file from the schedule root. Malformed JSON is
|
|
111
|
-
* surfaced as a controlled parse-error row (never silently swallowed); a missing
|
|
112
|
-
* schedule directory yields an empty list, but any other filesystem error
|
|
113
|
-
* (e.g. EACCES) propagates rather than masquerading as "no schedules".
|
|
114
|
-
*/
|
|
115
|
-
export async function listScheduleMetadata(homeDir, platform) {
|
|
116
|
-
const pathApi = pathApiForPlatform(platform);
|
|
117
|
-
const schedulesDir = getScheduleRoot(homeDir, platform);
|
|
118
|
-
let entries;
|
|
119
|
-
try {
|
|
120
|
-
entries = await fs.readdir(schedulesDir);
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
if (error.code === "ENOENT")
|
|
124
|
-
return [];
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
const rows = [];
|
|
128
|
-
for (const entry of entries.sort()) {
|
|
129
|
-
if (!entry.endsWith(".json"))
|
|
130
|
-
continue;
|
|
131
|
-
const id = entry.slice(0, -".json".length);
|
|
132
|
-
const fullPath = pathApi.join(schedulesDir, entry);
|
|
133
|
-
let raw;
|
|
134
|
-
try {
|
|
135
|
-
raw = await fs.readFile(fullPath, "utf-8");
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
if (error.code === "ENOENT")
|
|
139
|
-
continue; // raced delete
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
const metadata = JSON.parse(raw);
|
|
144
|
-
rows.push({ ok: true, id, metadata, path: fullPath });
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
rows.push({
|
|
148
|
-
ok: false,
|
|
149
|
-
id,
|
|
150
|
-
path: fullPath,
|
|
151
|
-
error: error instanceof Error ? error.message : String(error),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return rows;
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Delete the schedule metadata JSON file after a successful or stale cancel. The
|
|
159
|
-
* local log files (`logs/<id>.out|.err`) are deliberately left in place for
|
|
160
|
-
* post-run inspection.
|
|
161
|
-
*/
|
|
162
|
-
export async function deleteScheduleMetadata(id, homeDir, platform) {
|
|
163
|
-
const { metadataPath } = getSchedulePaths(id, homeDir, platform);
|
|
164
|
-
try {
|
|
165
|
-
await fs.unlink(metadataPath);
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
if (error.code === "ENOENT")
|
|
169
|
-
return;
|
|
170
|
-
throw error;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { schemaSupportsAutoFlag } from "./command-catalog.js";
|
|
2
|
-
/** The fixed late-fire threshold, in seconds, shared by every scheduled run. */
|
|
3
|
-
export const LATE_FIRE_THRESHOLD_SECONDS = 60;
|
|
4
|
-
/**
|
|
5
|
-
* Quote a single argument token for safe inclusion in the rendered prompt while
|
|
6
|
-
* preserving its boundary. Simple tokens (alphanumerics plus a small set of
|
|
7
|
-
* path/flag-safe punctuation) are left bare; anything containing whitespace or
|
|
8
|
-
* prompt/markup-sensitive characters is wrapped in double quotes with embedded
|
|
9
|
-
* double quotes escaped. This is prompt-token quoting, NOT shell quoting — no
|
|
10
|
-
* shell ever sees these strings.
|
|
11
|
-
*/
|
|
12
|
-
export function quotePromptToken(token) {
|
|
13
|
-
if (token === "")
|
|
14
|
-
return '""';
|
|
15
|
-
// Safe: letters, digits, and characters that never need quoting in a prompt
|
|
16
|
-
// (path separators, ISO-timestamp punctuation, flag dashes, etc.).
|
|
17
|
-
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token))
|
|
18
|
-
return token;
|
|
19
|
-
return `"${token.replace(/"/g, '\\"')}"`;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Compute the augmented argv tokens the scheduled run delegates to the command:
|
|
23
|
-
* the normalized `input.args`, plus (only where the command can parse them)
|
|
24
|
-
* `--scheduled-at <ISO>` and `--auto`.
|
|
25
|
-
*
|
|
26
|
-
* `--scheduled-at` is appended for `full-automation` (legacy) and `epic-tick`
|
|
27
|
-
* (BAPI-418), both of whose parsers accept it as a first-class argument. Every
|
|
28
|
-
* other command rejects unrecognized flags and halts (e.g. `start-tickets` stops
|
|
29
|
-
* on any unsupported flag), and the shared late-fire gate already embeds the
|
|
30
|
-
* scheduled time — so injecting `--scheduled-at` into their argv would break an
|
|
31
|
-
* otherwise `schedulable: true` command for no benefit.
|
|
32
|
-
*
|
|
33
|
-
* `--auto` is appended when auto-approve is set AND the command supports it (its
|
|
34
|
-
* schema declares a boolean `--auto` flag, or it is `full-automation` / `epic-tick`).
|
|
35
|
-
* It is never duplicated if already present in `input.args`.
|
|
36
|
-
*
|
|
37
|
-
* This is the SINGLE source of the delegated argv: both the rendered target
|
|
38
|
-
* command line and the `$ARGUMENTS` body substitution derive from it, so the body
|
|
39
|
-
* parse and the command line never disagree (e.g. `review-ticket`, which reads
|
|
40
|
-
* `--auto` out of `$ARGUMENTS`, sees the same `--auto` the command line shows).
|
|
41
|
-
*/
|
|
42
|
-
export function buildAugmentedArgs(input) {
|
|
43
|
-
const args = [...input.args];
|
|
44
|
-
// Commands whose parsers accept --scheduled-at as a first-class argument.
|
|
45
|
-
// epic-tick already accepts --scheduled-at (parseEpicTickArgs, cli.ts) so it
|
|
46
|
-
// receives the scheduled time as a structured arg for the late-fire decision,
|
|
47
|
-
// not just via the embedded gate text.
|
|
48
|
-
if (input.commandName === "full-automation" || input.commandName === "epic-tick") {
|
|
49
|
-
args.push("--scheduled-at", input.scheduledAt);
|
|
50
|
-
}
|
|
51
|
-
const supportsAuto = schemaSupportsAutoFlag(input.schema) ||
|
|
52
|
-
input.commandName === "full-automation" ||
|
|
53
|
-
input.commandName === "epic-tick";
|
|
54
|
-
const alreadyHasAuto = input.args.includes("--auto");
|
|
55
|
-
if (input.autoApprove && supportsAuto && !alreadyHasAuto) {
|
|
56
|
-
args.push("--auto");
|
|
57
|
-
}
|
|
58
|
-
return args;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Render the delegated target command line as
|
|
62
|
-
* `/<commandName> <augmented args...>` (see {@link buildAugmentedArgs}).
|
|
63
|
-
*/
|
|
64
|
-
export function buildTargetCommandLine(input) {
|
|
65
|
-
const parts = [`/${input.commandName}`];
|
|
66
|
-
for (const arg of buildAugmentedArgs(input))
|
|
67
|
-
parts.push(quotePromptToken(arg));
|
|
68
|
-
return parts.join(" ");
|
|
69
|
-
}
|
|
70
|
-
/** Render only the fixed late-fire gate text (exposed for focused testing). */
|
|
71
|
-
export function renderLateFireGate(scheduleId, scheduledAt, autoApprove) {
|
|
72
|
-
const lateAction = autoApprove
|
|
73
|
-
? "explain that the run is firing late and then PROCEED with the command below (this schedule was created with auto-approve)."
|
|
74
|
-
: "explain that the run is firing late and then HALT WITHOUT EXECUTING the command below (this schedule was created without auto-approve, and a headless run must not proceed unconfirmed).";
|
|
75
|
-
return [
|
|
76
|
-
"## Scheduled-run drift gate",
|
|
77
|
-
"",
|
|
78
|
-
`This is an automated, headless scheduled run (schedule id: ${scheduleId}).`,
|
|
79
|
-
`It was scheduled to fire at ${scheduledAt}.`,
|
|
80
|
-
"",
|
|
81
|
-
`Before doing anything else, check how late this run is firing. If it is more than ${LATE_FIRE_THRESHOLD_SECONDS} seconds later than the scheduled time above, ${lateAction}`,
|
|
82
|
-
`If it is within ${LATE_FIRE_THRESHOLD_SECONDS} seconds of the scheduled time, proceed silently.`,
|
|
83
|
-
"",
|
|
84
|
-
"Then run the following command exactly as written:",
|
|
85
|
-
].join("\n");
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Replace `$ARGUMENTS` in a command body with the safely-quoted, normalized
|
|
89
|
-
* argument string. Every occurrence is replaced (commands often repeat the
|
|
90
|
-
* placeholder in a title and a body line).
|
|
91
|
-
*/
|
|
92
|
-
function replaceArgumentsPlaceholder(body, argString) {
|
|
93
|
-
return body.split("$ARGUMENTS").join(argString);
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Render the full scheduled-run prompt: gate + delegated command line + command
|
|
97
|
-
* body (with `$ARGUMENTS` substituted). Deterministic for identical inputs.
|
|
98
|
-
*/
|
|
99
|
-
export function renderScheduledPrompt(input) {
|
|
100
|
-
const gate = renderLateFireGate(input.scheduleId, input.scheduledAt, input.autoApprove);
|
|
101
|
-
const targetInput = {
|
|
102
|
-
commandName: input.commandName,
|
|
103
|
-
args: input.args,
|
|
104
|
-
scheduledAt: input.scheduledAt,
|
|
105
|
-
autoApprove: input.autoApprove,
|
|
106
|
-
schema: input.schema,
|
|
107
|
-
};
|
|
108
|
-
const targetCommandLine = buildTargetCommandLine(targetInput);
|
|
109
|
-
// `$ARGUMENTS` must match what the command line shows — including the appended
|
|
110
|
-
// `--scheduled-at` / `--auto` — so a body that re-parses `$ARGUMENTS` (e.g.
|
|
111
|
-
// review-ticket reading `--auto`) agrees with the rendered command line.
|
|
112
|
-
const argString = buildAugmentedArgs(targetInput).map(quotePromptToken).join(" ");
|
|
113
|
-
const body = replaceArgumentsPlaceholder(input.commandBody, argString);
|
|
114
|
-
return [gate, "", targetCommandLine, "", body].join("\n");
|
|
115
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { bakedEnvFromCreateInput, 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(bakedEnvFromCreateInput(input));
|
|
14
|
-
// The script runs the Node execution shim (trigger invocation); the shim spawns
|
|
15
|
-
// the stored agent invocation and records run-history events.
|
|
16
|
-
const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
17
|
-
.map((part) => posixShellQuote(part))
|
|
18
|
-
.join(" ");
|
|
19
|
-
return [
|
|
20
|
-
"#!/bin/sh",
|
|
21
|
-
exports,
|
|
22
|
-
`cd ${posixShellQuote(input.repoPath)}`,
|
|
23
|
-
`${command} >>${posixShellQuote(input.paths.stdoutPath)} 2>>${posixShellQuote(input.paths.stderrPath)}`,
|
|
24
|
-
"",
|
|
25
|
-
].join("\n");
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Parse an `at` job id from common stdout/stderr forms; `null` when absent.
|
|
29
|
-
* Anchored to the start of a line so a stray earlier "job" word can't be
|
|
30
|
-
* mistaken for the job line, and (defensively, should `at` ever print more than
|
|
31
|
-
* one) the LAST `job <id> at …` line wins.
|
|
32
|
-
*/
|
|
33
|
-
export function parseAtJobId(stdout, stderr) {
|
|
34
|
-
const combined = `${stdout}\n${stderr}`;
|
|
35
|
-
const matches = [...combined.matchAll(/^job\s+(\d+)\s+at\b/gim)];
|
|
36
|
-
return matches.length > 0 ? matches[matches.length - 1][1] : null;
|
|
37
|
-
}
|
|
38
|
-
/** Whether both `at` and `atq` resolve (used by isAvailable). */
|
|
39
|
-
async function atToolsResolvable(deps) {
|
|
40
|
-
const at = await deps.runCommand("which", ["at"]);
|
|
41
|
-
if (at.exitCode !== 0)
|
|
42
|
-
return false;
|
|
43
|
-
const atq = await deps.runCommand("which", ["atq"]);
|
|
44
|
-
return atq.exitCode === 0;
|
|
45
|
-
}
|
|
46
|
-
/** Create the Linux `at` fallback backend. */
|
|
47
|
-
export function createAtFallbackBackend() {
|
|
48
|
-
return {
|
|
49
|
-
name: "at-fallback",
|
|
50
|
-
async isAvailable(deps) {
|
|
51
|
-
if (deps.platform !== "linux")
|
|
52
|
-
return false;
|
|
53
|
-
return atToolsResolvable(deps);
|
|
54
|
-
},
|
|
55
|
-
async create(input) {
|
|
56
|
-
const script = renderAtScript(input);
|
|
57
|
-
const artifact = {
|
|
58
|
-
path: AT_STDIN_ARTIFACT_PATH,
|
|
59
|
-
content: script,
|
|
60
|
-
kind: "at-script",
|
|
61
|
-
};
|
|
62
|
-
if (input.dryRun) {
|
|
63
|
-
return {
|
|
64
|
-
ok: true,
|
|
65
|
-
backend: "at-fallback",
|
|
66
|
-
unitPath: null,
|
|
67
|
-
unitPaths: [],
|
|
68
|
-
backendJobId: null,
|
|
69
|
-
artifacts: [artifact],
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
const timestamp = formatAtTimestamp(input.runAtIso);
|
|
73
|
-
const result = await input.deps.runCommand("at", ["-t", timestamp], { input: script });
|
|
74
|
-
if (result.exitCode !== 0) {
|
|
75
|
-
return {
|
|
76
|
-
ok: false,
|
|
77
|
-
backend: "at-fallback",
|
|
78
|
-
unitPath: null,
|
|
79
|
-
unitPaths: [],
|
|
80
|
-
backendJobId: null,
|
|
81
|
-
artifacts: [artifact],
|
|
82
|
-
error: `at scheduling failed: ${(result.stderr || result.stdout).trim()}`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
const jobId = parseAtJobId(result.stdout, result.stderr);
|
|
86
|
-
if (!jobId) {
|
|
87
|
-
return {
|
|
88
|
-
ok: false,
|
|
89
|
-
backend: "at-fallback",
|
|
90
|
-
unitPath: null,
|
|
91
|
-
unitPaths: [],
|
|
92
|
-
backendJobId: null,
|
|
93
|
-
artifacts: [artifact],
|
|
94
|
-
error: "Could not parse an at job id from the scheduler output.",
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
return {
|
|
98
|
-
ok: true,
|
|
99
|
-
backend: "at-fallback",
|
|
100
|
-
unitPath: null,
|
|
101
|
-
unitPaths: [],
|
|
102
|
-
backendJobId: jobId,
|
|
103
|
-
artifacts: [artifact],
|
|
104
|
-
};
|
|
105
|
-
},
|
|
106
|
-
async list(input) {
|
|
107
|
-
const atq = await input.deps.runCommand("atq", []);
|
|
108
|
-
if (atq.exitCode !== 0) {
|
|
109
|
-
return input.recorded.map((metadata) => ({
|
|
110
|
-
metadata,
|
|
111
|
-
status: "backend-unavailable",
|
|
112
|
-
detail: "atq unavailable",
|
|
113
|
-
}));
|
|
114
|
-
}
|
|
115
|
-
const liveJobIds = new Set(atq.stdout
|
|
116
|
-
.split(/\r?\n/)
|
|
117
|
-
.map((line) => line.trim().match(/^(\d+)\b/)?.[1])
|
|
118
|
-
.filter((id) => Boolean(id)));
|
|
119
|
-
return input.recorded.map((metadata) => {
|
|
120
|
-
const jobId = metadata.backend_job_id;
|
|
121
|
-
const active = jobId !== null && liveJobIds.has(jobId);
|
|
122
|
-
return {
|
|
123
|
-
metadata,
|
|
124
|
-
status: active ? "active" : "stale",
|
|
125
|
-
detail: active ? `at job ${jobId}` : "at job not queued",
|
|
126
|
-
};
|
|
127
|
-
});
|
|
128
|
-
},
|
|
129
|
-
async cancel(input) {
|
|
130
|
-
const jobId = input.metadata.backend_job_id;
|
|
131
|
-
if (!jobId) {
|
|
132
|
-
return { ok: true, nativeRemoved: false, stale: true };
|
|
133
|
-
}
|
|
134
|
-
const result = await input.deps.runCommand("atrm", [jobId]);
|
|
135
|
-
const nativeRemoved = result.exitCode === 0;
|
|
136
|
-
return { ok: true, nativeRemoved, stale: !nativeRemoved };
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
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
|
-
/**
|
|
12
|
-
* All baked PATH-trap / schedule-context environment variable names, in a stable
|
|
13
|
-
* order. Generalized in BAPI-351: the full-automation-specific `BRIDGE_GPT_CLAUDE`
|
|
14
|
-
* became the agent-neutral `BRIDGE_GPT_AGENT_PATH`, and schedule-context vars
|
|
15
|
-
* (`BRIDGE_GPT_SCHEDULE_ID`, `BRIDGE_GPT_COMMAND`, `BRIDGE_GPT_COMMAND_ARGS_JSON`)
|
|
16
|
-
* were added. `BRIDGE_GPT_IDEA_FILE` is now legacy-only (baked only when present).
|
|
17
|
-
*/
|
|
18
|
-
export const BAKED_ENV_VAR_NAMES = [
|
|
19
|
-
"PATH",
|
|
20
|
-
"BRIDGE_GPT_NODE",
|
|
21
|
-
"BRIDGE_GPT_NPX",
|
|
22
|
-
"BRIDGE_GPT_AGENT",
|
|
23
|
-
"BRIDGE_GPT_AGENT_PATH",
|
|
24
|
-
"BRIDGE_GPT_REPO_PATH",
|
|
25
|
-
"BRIDGE_GPT_SCHEDULE_ID",
|
|
26
|
-
"BRIDGE_GPT_COMMAND",
|
|
27
|
-
"BRIDGE_GPT_COMMAND_ARGS_JSON",
|
|
28
|
-
];
|
|
29
|
-
/**
|
|
30
|
-
* Reject any generated value containing a NUL byte. NUL can prematurely
|
|
31
|
-
* terminate a C-string when a unit file is parsed by the OS scheduler, silently
|
|
32
|
-
* dropping the rest of the value. The `label` names the offending field so the
|
|
33
|
-
* error is actionable.
|
|
34
|
-
*/
|
|
35
|
-
export function assertNoNul(value, label) {
|
|
36
|
-
if (value.includes("\0")) {
|
|
37
|
-
throw new Error(`Refusing to generate scheduler content: ${label} contains a NUL byte, which is not allowed.`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** Escape a string for use inside a launchd plist `<string>` value. */
|
|
41
|
-
export function xmlEscape(value) {
|
|
42
|
-
assertNoNul(value, "xml value");
|
|
43
|
-
return value
|
|
44
|
-
.replace(/&/g, "&")
|
|
45
|
-
.replace(/</g, "<")
|
|
46
|
-
.replace(/>/g, ">")
|
|
47
|
-
.replace(/"/g, """)
|
|
48
|
-
.replace(/'/g, "'");
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Quote a value as a single POSIX shell argument (single-quote form). Empty
|
|
52
|
-
* strings and strings with spaces become a quoted token; embedded single quotes
|
|
53
|
-
* use the canonical close/escape/reopen `'\''` form. Used by the `at` heredoc.
|
|
54
|
-
*/
|
|
55
|
-
export function posixShellQuote(value) {
|
|
56
|
-
assertNoNul(value, "posix shell value");
|
|
57
|
-
if (value === "")
|
|
58
|
-
return "''";
|
|
59
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Quote a value for a Windows command line (a single argument inside a `.cmd`
|
|
63
|
-
* wrapper). Wraps in double quotes and escapes embedded double quotes the
|
|
64
|
-
* cmd-compatible way. Always quoting keeps paths/prompts with spaces intact.
|
|
65
|
-
*/
|
|
66
|
-
export function windowsCmdQuote(value) {
|
|
67
|
-
assertNoNul(value, "windows cmd value");
|
|
68
|
-
// Within double quotes, cmd treats "" as a literal quote.
|
|
69
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Escape a value to be assigned via `set "KEY=value"` in a `.cmd` wrapper. The
|
|
73
|
-
* `set "KEY=..."` quoted form already neutralises `& | < > ^`, but a literal `%`
|
|
74
|
-
* still triggers variable expansion, so it is doubled to `%%`. A double quote
|
|
75
|
-
* would close the `set` quoting, so it is dropped/escaped to keep the line safe.
|
|
76
|
-
*/
|
|
77
|
-
export function escapeWindowsCmdSetValue(value) {
|
|
78
|
-
assertNoNul(value, "windows cmd set value");
|
|
79
|
-
return value.replace(/%/g, "%%").replace(/"/g, "");
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Quote a value for a systemd unit directive (e.g. `ExecStart=`, `Environment=`,
|
|
83
|
-
* `WorkingDirectory=`). systemd accepts double-quoted tokens with C-style
|
|
84
|
-
* backslash escaping for embedded backslashes and quotes. Empty strings render
|
|
85
|
-
* as an explicit empty quoted token.
|
|
86
|
-
*/
|
|
87
|
-
export function systemdQuote(value) {
|
|
88
|
-
assertNoNul(value, "systemd value");
|
|
89
|
-
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
90
|
-
return `"${escaped}"`;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Build the ordered baked PATH-trap + schedule-context env entries from explicit,
|
|
94
|
-
* schedule-time values — never `process.env`. Each value is NUL-checked. This is
|
|
95
|
-
* the single source every backend uses so the baked environment is consistent
|
|
96
|
-
* across launchd / Task Scheduler / systemd / at. `BRIDGE_GPT_IDEA_FILE` is
|
|
97
|
-
* appended only for the legacy full-automation path (when `ideaFile` is set).
|
|
98
|
-
*/
|
|
99
|
-
export function bakedEnvEntries(env) {
|
|
100
|
-
const entries = [
|
|
101
|
-
["PATH", env.envPath],
|
|
102
|
-
["BRIDGE_GPT_NODE", env.nodePath],
|
|
103
|
-
["BRIDGE_GPT_NPX", env.npxPath],
|
|
104
|
-
["BRIDGE_GPT_AGENT", env.agent],
|
|
105
|
-
["BRIDGE_GPT_AGENT_PATH", env.agentPath],
|
|
106
|
-
["BRIDGE_GPT_REPO_PATH", env.repoPath],
|
|
107
|
-
["BRIDGE_GPT_SCHEDULE_ID", env.scheduleId],
|
|
108
|
-
["BRIDGE_GPT_COMMAND", env.command],
|
|
109
|
-
["BRIDGE_GPT_COMMAND_ARGS_JSON", env.commandArgsJson],
|
|
110
|
-
];
|
|
111
|
-
if (env.ideaFile !== undefined) {
|
|
112
|
-
entries.push(["BRIDGE_GPT_IDEA_FILE", env.ideaFile]);
|
|
113
|
-
}
|
|
114
|
-
for (const [key, value] of entries) {
|
|
115
|
-
assertNoNul(value, key);
|
|
116
|
-
}
|
|
117
|
-
return entries;
|
|
118
|
-
}
|
|
119
|
-
/** Build a {@link BakedEnv} from a generalized scheduler create input. */
|
|
120
|
-
export function bakedEnvFromCreateInput(input) {
|
|
121
|
-
return {
|
|
122
|
-
envPath: input.envPath,
|
|
123
|
-
nodePath: input.nodePath,
|
|
124
|
-
npxPath: input.npxPath,
|
|
125
|
-
agent: input.agent,
|
|
126
|
-
agentPath: input.agentPath,
|
|
127
|
-
repoPath: input.repoPath,
|
|
128
|
-
scheduleId: input.id,
|
|
129
|
-
command: input.command,
|
|
130
|
-
commandArgsJson: JSON.stringify(input.args),
|
|
131
|
-
ideaFile: input.legacyIdeaFile,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Render the baked env as POSIX `export KEY='value'` lines (used by the `at`
|
|
136
|
-
* heredoc script). Values come only from {@link bakedEnvEntries}; no value is
|
|
137
|
-
* ever read from the ambient process environment.
|
|
138
|
-
*/
|
|
139
|
-
export function formatEnvExportsForGeneratedUnit(env) {
|
|
140
|
-
return bakedEnvEntries(env)
|
|
141
|
-
.map(([key, value]) => `export ${key}=${posixShellQuote(value)}`)
|
|
142
|
-
.join("\n");
|
|
143
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
}
|