Skip to content
Merged
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
75 changes: 67 additions & 8 deletions packages/cli/src/commands/office-flair-spoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,34 @@ WantedBy=timers.target

// ─── launchd plist generation (macOS branches) ────────────────────────────────

/**
* Escape XML metacharacters in a string so it can be embedded inside an XML
* element value (e.g., `<string>...</string>` in a launchd plist) without
* breaking the surrounding markup or, worse, silently corrupting the plist
* and causing `launchctl load` to fail without a clear error.
*
* launchd's plist parser unescapes the standard 5 entities when reading, so
* values escaped here round-trip cleanly to whatever the spawned process
* sees in argv / env.
*/
function xmlEscape(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

/**
* Generate a launchd plist for Harper on a macOS branch.
*
* All caller-supplied string values (label, flairDir, harperDataDir, home)
* are XML-escaped before interpolation — flagged by K&S on #290 as a
* defense-in-depth gap. Today's callers pass safe internal values but the
* `home` path comes from the remote shell via `detectRemoteHome` and a
* hostile / mistyped home with `&`, `<`, `>`, `"`, or `'` would silently
* break the plist.
*/
export function generateLaunchdFlairPlist(
label: string,
Expand All @@ -291,24 +317,57 @@ export function generateLaunchdFlairPlist(
home: string,
port: number = DEFAULT_FLAIR_PORT,
): string {
const eLabel = xmlEscape(label);
const eFlairDir = xmlEscape(flairDir);
const eDataDir = xmlEscape(harperDataDir);
const eHome = xmlEscape(home);
const eStdout = xmlEscape(join(home, ".tps", "logs", `flair-${label}.log`));
const eStderr = xmlEscape(join(home, ".tps", "logs", `flair-${label}.error.log`));

// Build the inner JSON config with native JSON escaping, then XML-escape
// the whole string before embedding in <string>...</string>. launchd's
// plist parser undoes the XML layer; Harper consumes the resulting JSON.
const harperConfigJson = JSON.stringify({
rootPath: harperDataDir,
http: {
port,
cors: true,
corsAccessList: [`http://127.0.0.1:${port}`, `http://localhost:${port}`],
},
operationsApi: {
network: {
port: DEFAULT_HARPER_OPS_PORT,
cors: true,
corsAccessList: [
`http://127.0.0.1:${DEFAULT_HARPER_OPS_PORT}`,
`http://localhost:${DEFAULT_HARPER_OPS_PORT}`,
],
domainSocket: `${harperDataDir}/operations-server`,
},
},
mqtt: { network: { port: null }, webSocket: false },
localStudio: { enabled: false },
});
const eHarperConfig = xmlEscape(harperConfigJson);

return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<string>${eLabel}</string>

<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>${flairDir}/node_modules/harper/dist/bin/harper.js</string>
<string>${eFlairDir}/node_modules/harper/dist/bin/harper.js</string>
<string>dev</string>
<string>${flairDir}</string>
<string>${eFlairDir}</string>
</array>

<key>WorkingDirectory</key>
<string>${flairDir}</string>
<string>${eFlairDir}</string>

<key>RunAtLoad</key>
<true/>
Expand All @@ -323,19 +382,19 @@ export function generateLaunchdFlairPlist(
<integer>10</integer>

<key>StandardOutPath</key>
<string>${join(home, ".tps", "logs", `flair-${label}.log`)}</string>
<string>${eStdout}</string>

<key>StandardErrorPath</key>
<string>${join(home, ".tps", "logs", `flair-${label}.error.log`)}</string>
<string>${eStderr}</string>

<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>${home}</string>
<string>${eHome}</string>
<key>HARPER_SET_CONFIG</key>
<string>{"rootPath":"${harperDataDir}","http":{"port":${port},"cors":true,"corsAccessList":["http://127.0.0.1:${port}","http://localhost:${port}"]},"operationsApi":{"network":{"port":${DEFAULT_HARPER_OPS_PORT},"cors":true,"corsAccessList":["http://127.0.0.1:${DEFAULT_HARPER_OPS_PORT}","http://localhost:${DEFAULT_HARPER_OPS_PORT}"],"domainSocket":"${harperDataDir}/operations-server"}},"mqtt":{"network":{"port":null},"webSocket":false},"localStudio":{"enabled":false}}</string>
<string>${eHarperConfig}</string>
</dict>
</dict>
</plist>
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/test/office-flair-spoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,46 @@ describe("generateLaunchdFlairPlist", () => {
expect(plist).toContain("<string>/Users/exedev</string>"); // HOME env var
expect(plist).not.toContain(homedir()); // no leak of the local home
});

test("XML-escapes caller-supplied string values (ops-y722)", () => {
// K&S flagged on PR #290 that the plist embedded caller strings raw —
// a `&` or `<` in any interpolated value would silently corrupt the
// XML and break launchctl load with an unhelpful parser error.
// Note: harperDataDir is only used inside the JSON config (covered by
// the separate HARPER_SET_CONFIG test); these assertions cover the
// direct <string> embedding paths.
const plist = generateLaunchdFlairPlist(
"label&with<special>chars",
"~/.flair'apos",
"~/.harper/flair",
"/Users/<weird&user>",
9926,
);
// Raw metachars must not appear in the value positions
expect(plist).not.toContain("label&with<special>chars");
expect(plist).not.toContain("/Users/<weird&user>");
// Encoded forms must be present
expect(plist).toContain("label&amp;with&lt;special&gt;chars");
expect(plist).toContain("/Users/&lt;weird&amp;user&gt;");
expect(plist).toContain("~/.flair&apos;apos");
});

test("XML-escapes HARPER_SET_CONFIG JSON before embedding (ops-y722)", () => {
// HARPER_SET_CONFIG is a JSON string wrapped in <string>...</string>.
// The inner JSON has quotes which would tear out of the XML element
// unless XML-escaped. Verify the embedded config is encoded.
const plist = generateLaunchdFlairPlist(
"ai.tpsdev.flair-test",
"~/.flair",
"~/.harper/flair",
"/Users/exedev",
9926,
);
// The inner JSON quotes must be encoded; the surrounding XML must be parseable
expect(plist).toContain("&quot;rootPath&quot;");
expect(plist).toContain("&quot;http&quot;");
expect(plist).not.toMatch(/<string>\{"rootPath":/); // raw unescaped JSON would have this
});
});

// ─── 4. Extended supervision manifest round-trip ──────────────────────────────
Expand Down
Loading