fix(devcontainer): set persist-credentials:false on CI checkouts + prettier

- zizmor `artipacked` (CodeQL/GitHub Advanced Security) flagged both
  actions/checkout steps in ci-devcontainer.yml: checkout defaults to
  persist-credentials:true, leaving GITHUB_TOKEN in .git/config where it
  can leak into uploaded artifacts. Both jobs are read-only (run tests /
  build smoke, never push), so persist-credentials:false is correct —
  matches the repo convention in codeql.yml / ci-tests.yml.
- Ran prettier 3.8.0 over the new .cjs modules + test (single-quote/style
  normalization to match the repo). JSON/YAML were already compliant;
  README is in .prettierignore; .sh has no prettier parser. Behavior
  unchanged — 12/12 transform unit tests still pass.
This commit is contained in:
Gergo Magyar 2026-05-28 21:51:55 +01:00
parent 1008b0dcf9
commit 1a2c480847
4 changed files with 92 additions and 102 deletions

View file

@ -14,17 +14,17 @@
// test harness). DISABLE_AUTOUPDATER=1 (containerEnv) already neutralizes
// runtime updates; this purely silences the doctor mismatch + native probe.
"use strict";
'use strict';
const fs = require("fs");
const fs = require('fs');
// Host binary-management / machine-install fields — never valid for an
// `npm install -g` container. Stripping lets Claude auto-detect npm-global.
const MACHINE_FIELDS = [
"installMethod",
"autoUpdates",
"autoUpdatesProtectedForNative",
"shiftEnterKeyBindingInstalled",
'installMethod',
'autoUpdates',
'autoUpdatesProtectedForNative',
'shiftEnterKeyBindingInstalled',
];
// Pure transform: take whatever the host file parsed to and return the
@ -34,7 +34,7 @@ const MACHINE_FIELDS = [
// hasCompletedOnboarding assignment, and re-trigger onboarding every rebuild).
function sanitizeClaudeConfig(parsed) {
let cfg = parsed;
if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
cfg = {};
}
for (const k of MACHINE_FIELDS) {
@ -47,7 +47,7 @@ function sanitizeClaudeConfig(parsed) {
function readHostConfig(src) {
try {
if (fs.existsSync(src) && fs.statSync(src).size > 0) {
return JSON.parse(fs.readFileSync(src, "utf8"));
return JSON.parse(fs.readFileSync(src, 'utf8'));
}
} catch {
// Malformed/unreadable host file — fall back to an empty config so the
@ -57,16 +57,14 @@ function readHostConfig(src) {
}
function main() {
const src = process.argv[2] || "/host/.claude.json";
const dst = process.argv[3] || "/home/node/.claude.json";
const src = process.argv[2] || '/host/.claude.json';
const dst = process.argv[3] || '/home/node/.claude.json';
const cfg = sanitizeClaudeConfig(readHostConfig(src));
try {
fs.writeFileSync(dst, JSON.stringify(cfg, null, 2));
fs.chmodSync(dst, 0o644);
} catch (err) {
console.error(
`[post-create] ERROR: failed to seed ${dst}: ${err && err.message}`,
);
console.error(`[post-create] ERROR: failed to seed ${dst}: ${err && err.message}`);
process.exit(1);
}
}

View file

@ -13,18 +13,16 @@
// Extracted from a post-create.sh heredoc so the regex + deep rewrite are
// lintable and unit-tested (the regex has had path-handling bugs before).
"use strict";
'use strict';
const fs = require("fs");
const path = require("path");
const fs = require('fs');
const path = require('path');
// Match an absolute path that contains `<sep>.<cli><sep>plugins<sep><rest>`
// where <sep> is `/` or `\`. Anchored at start; the lazy `.*?` consumes the
// home prefix up to the FIRST `.<cli>/plugins` segment.
function buildRe(cliName) {
return new RegExp(
`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`,
);
return new RegExp(`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`);
}
// Recursively rewrite every string value in `obj` that matches `re`,
@ -32,33 +30,29 @@ function buildRe(cliName) {
// Windows backslashes to forward slashes.
function rewriteDeep(obj, re, ctr) {
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
if (obj && typeof obj === "object") {
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
return out;
}
if (typeof obj === "string") {
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, "/")}`);
if (typeof obj === 'string') {
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, '/')}`);
}
return obj;
}
const REGISTRIES = [
{
cli: "claude",
host: "/host/.claude/plugins",
ctr: "/home/node/.claude/plugins",
files: [
"known_marketplaces.json",
"installed_plugins.json",
"plugin-catalog-cache.json",
],
cli: 'claude',
host: '/host/.claude/plugins',
ctr: '/home/node/.claude/plugins',
files: ['known_marketplaces.json', 'installed_plugins.json', 'plugin-catalog-cache.json'],
},
{
cli: "cursor",
host: "/host/.cursor/plugins",
ctr: "/home/node/.cursor/plugins",
files: ["installed_plugins.json"],
cli: 'cursor',
host: '/host/.cursor/plugins',
ctr: '/home/node/.cursor/plugins',
files: ['installed_plugins.json'],
},
];
@ -68,9 +62,7 @@ function translate(registries) {
try {
fs.mkdirSync(reg.ctr, { recursive: true });
} catch (err) {
console.error(
`[post-create] ERROR: failed to create ${reg.ctr}: ${err && err.message}`,
);
console.error(`[post-create] ERROR: failed to create ${reg.ctr}: ${err && err.message}`);
process.exit(1);
}
for (const name of reg.files) {
@ -79,19 +71,14 @@ function translate(registries) {
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
data = JSON.parse(fs.readFileSync(src, 'utf8'));
} catch {
continue; // skip a malformed host registry rather than abort
}
try {
fs.writeFileSync(
dst,
JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2),
);
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));
} catch (err) {
console.error(
`[post-create] ERROR: failed to write ${dst}: ${err && err.message}`,
);
console.error(`[post-create] ERROR: failed to write ${dst}: ${err && err.message}`);
process.exit(1);
}
}

View file

@ -8,121 +8,118 @@
// Run with the built-in Node test runner (no deps):
// node --test .devcontainer/
"use strict";
'use strict';
const test = require("node:test");
const assert = require("node:assert/strict");
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildRe,
rewriteDeep,
} = require("./translate-plugin-registries.cjs");
const { sanitizeClaudeConfig } = require("./seed-claude-config.cjs");
const { buildRe, rewriteDeep } = require('./translate-plugin-registries.cjs');
const { sanitizeClaudeConfig } = require('./seed-claude-config.cjs');
const CLAUDE = "/home/node/.claude/plugins";
const CURSOR = "/home/node/.cursor/plugins";
const CLAUDE = '/home/node/.claude/plugins';
const CURSOR = '/home/node/.cursor/plugins';
function rw(value, cli, ctr) {
return rewriteDeep(value, buildRe(cli), ctr);
}
test("claude: Windows backslash absolute path -> container path", () => {
test('claude: Windows backslash absolute path -> container path', () => {
assert.equal(
rw("C:\\Users\\gergo\\.claude\\plugins\\cache\\x\\1.0", "claude", CLAUDE),
"/home/node/.claude/plugins/cache/x/1.0",
rw('C:\\Users\\gergo\\.claude\\plugins\\cache\\x\\1.0', 'claude', CLAUDE),
'/home/node/.claude/plugins/cache/x/1.0',
);
});
test("claude: Windows forward-slash absolute path -> container path", () => {
test('claude: Windows forward-slash absolute path -> container path', () => {
assert.equal(
rw("C:/Users/gergo/.claude/plugins/marketplaces/m", "claude", CLAUDE),
"/home/node/.claude/plugins/marketplaces/m",
rw('C:/Users/gergo/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
'/home/node/.claude/plugins/marketplaces/m',
);
});
test("claude: macOS POSIX path -> container path", () => {
test('claude: macOS POSIX path -> container path', () => {
assert.equal(
rw("/Users/alice/.claude/plugins/marketplaces/m", "claude", CLAUDE),
"/home/node/.claude/plugins/marketplaces/m",
rw('/Users/alice/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
'/home/node/.claude/plugins/marketplaces/m',
);
});
test("claude: Linux POSIX path -> container path", () => {
test('claude: Linux POSIX path -> container path', () => {
assert.equal(
rw("/home/bob/.claude/plugins/cache/foo", "claude", CLAUDE),
"/home/node/.claude/plugins/cache/foo",
rw('/home/bob/.claude/plugins/cache/foo', 'claude', CLAUDE),
'/home/node/.claude/plugins/cache/foo',
);
});
test("cursor: Windows path -> container cursor path", () => {
test('cursor: Windows path -> container cursor path', () => {
assert.equal(
rw("C:\\Users\\gergo\\.cursor\\plugins\\local\\myplug", "cursor", CURSOR),
"/home/node/.cursor/plugins/local/myplug",
rw('C:\\Users\\gergo\\.cursor\\plugins\\local\\myplug', 'cursor', CURSOR),
'/home/node/.cursor/plugins/local/myplug',
);
});
test("cross-CLI isolation: claude regex leaves a .cursor path untouched", () => {
const input = "C:\\Users\\g\\.cursor\\plugins\\x";
assert.equal(rw(input, "claude", CLAUDE), input);
test('cross-CLI isolation: claude regex leaves a .cursor path untouched', () => {
const input = 'C:\\Users\\g\\.cursor\\plugins\\x';
assert.equal(rw(input, 'claude', CLAUDE), input);
});
test("non-path strings pass through unchanged", () => {
assert.equal(rw("not-a-path", "claude", CLAUDE), "not-a-path");
assert.equal(rw("https://github.com/EveryInc/x.git", "claude", CLAUDE), "https://github.com/EveryInc/x.git");
test('non-path strings pass through unchanged', () => {
assert.equal(rw('not-a-path', 'claude', CLAUDE), 'not-a-path');
assert.equal(
rw('https://github.com/EveryInc/x.git', 'claude', CLAUDE),
'https://github.com/EveryInc/x.git',
);
});
test("non-string scalars pass through unchanged", () => {
assert.equal(rw(42, "claude", CLAUDE), 42);
assert.equal(rw(null, "claude", CLAUDE), null);
assert.equal(rw(true, "claude", CLAUDE), true);
test('non-string scalars pass through unchanged', () => {
assert.equal(rw(42, 'claude', CLAUDE), 42);
assert.equal(rw(null, 'claude', CLAUDE), null);
assert.equal(rw(true, 'claude', CLAUDE), true);
});
test("nested objects/arrays are rewritten deeply", () => {
test('nested objects/arrays are rewritten deeply', () => {
const input = {
"compound-engineering@m": [
{ installPath: "C:\\Users\\g\\.claude\\plugins\\cache\\ce\\3.9.2", version: "3.9.2" },
'compound-engineering@m': [
{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\ce\\3.9.2', version: '3.9.2' },
],
nested: { installLocation: "/Users/g/.claude/plugins/marketplaces/m" },
nested: { installLocation: '/Users/g/.claude/plugins/marketplaces/m' },
};
const out = rw(input, "claude", CLAUDE);
const out = rw(input, 'claude', CLAUDE);
assert.equal(
out["compound-engineering@m"][0].installPath,
"/home/node/.claude/plugins/cache/ce/3.9.2",
);
assert.equal(out["compound-engineering@m"][0].version, "3.9.2");
assert.equal(
out.nested.installLocation,
"/home/node/.claude/plugins/marketplaces/m",
out['compound-engineering@m'][0].installPath,
'/home/node/.claude/plugins/cache/ce/3.9.2',
);
assert.equal(out['compound-engineering@m'][0].version, '3.9.2');
assert.equal(out.nested.installLocation, '/home/node/.claude/plugins/marketplaces/m');
});
test("sanitizeClaudeConfig: strips machine fields, forces hasCompletedOnboarding", () => {
test('sanitizeClaudeConfig: strips machine fields, forces hasCompletedOnboarding', () => {
const out = sanitizeClaudeConfig({
installMethod: "native",
installMethod: 'native',
autoUpdates: false,
autoUpdatesProtectedForNative: true,
shiftEnterKeyBindingInstalled: true,
userID: "abc",
oauthAccount: { emailAddress: "x@y.z" },
userID: 'abc',
oauthAccount: { emailAddress: 'x@y.z' },
});
assert.equal(out.installMethod, undefined);
assert.equal(out.autoUpdates, undefined);
assert.equal(out.autoUpdatesProtectedForNative, undefined);
assert.equal(out.shiftEnterKeyBindingInstalled, undefined);
assert.equal(out.userID, "abc");
assert.equal(out.oauthAccount.emailAddress, "x@y.z");
assert.equal(out.userID, 'abc');
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
assert.equal(out.hasCompletedOnboarding, true);
});
test("sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing object", () => {
for (const bad of [42, "x", null, ["a"], true]) {
test('sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing object', () => {
for (const bad of [42, 'x', null, ['a'], true]) {
const out = sanitizeClaudeConfig(bad);
assert.equal(typeof out, "object");
assert.equal(typeof out, 'object');
assert.equal(Array.isArray(out), false);
assert.equal(out.hasCompletedOnboarding, true);
}
});
test("sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding", () => {
test('sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding', () => {
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
});

View file

@ -31,7 +31,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# persist-credentials: false — read-only job (tests + syntax checks),
# never pushes; keeps GITHUB_TOKEN out of .git/config (zizmor artipacked).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
@ -47,7 +51,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# persist-credentials: false — read-only build smoke, never pushes;
# keeps GITHUB_TOKEN out of .git/config (zizmor artipacked).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22