fix(harness): keep edits beside themselves, never follow symlinks, cut only gstack's own context block

Review blockers on #3526: uninstall deleted the edit backups under the state root;
unregister followed a symlinked folder below the destination; a lone or edited
context marker made the cut eat user lines; a re-register adding --context left an
unrecorded block. Also: ledger written before the files, --refresh-registered no
longer re-runs harness hosts through setup (gstack-harness refresh keeps their mode),
bash goes through runBashSync, and setup tests follow openclaw/hermes becoming installs.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-28 01:40:21 +00:00
1 parent 808dead70e
commit dd2f3c13ec
10 files changed
+200 -71

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@
### Added
- **`bin/gstack-harness`** (`lib/harness-register.ts`, `lib/harness-qm.ts`): `register`, `unregister`, `status`, `detect`, `refresh`, `auto` and `manifest` for seven targets. OpenClaw gets one `gstack` router skill (one 254-byte description line in every request; its body lists every skill with its path) in `$OPENCLAW_STATE_DIR/skills`, or every skill with `--skills all`. Hermes gets every skill as `gstack-<skill>` in one `gstack` category, because it opens only skills it has indexed. A Dot gets the router in `/home/dot/skills`. QM gets an org skill pack (`github.com/garrytan/gstack` at the installed commit) through one explicit admin command, `register qm --yes`, which lists packs first so a retry never makes a second one. Muse Code and Grok Build already read `~/.claude/skills`; `status` reports them as discovered, Claude-rendered and unverified. Any other harness gets the router in a skills folder (`--skills-dir`) or a routing block of at most 600 bytes between `gstack:begin`/`gstack:end` markers in a context file (`--context`), and `--print` writes both to stdout for chat-only apps such as Grok Bot.
- **Ownership ledger**: every file gstack-harness writes goes into `$GSTACK_STATE_ROOT/harness/<id>.ledger` with its sha256. Registering again changes only what changed; a folder or a skill name gstack did not write stops it; a destination that is your home, the checkout, a symlink or outside a plain path is refused. `gstack-uninstall` reads the ledgers with `bin/gstack-harness-ledger.sh` (bash only) before it removes the checkout, and moves any file you edited to `$GSTACK_STATE_ROOT/backups/harness/`.
- **Ownership ledger**: every file gstack-harness writes goes into `$GSTACK_STATE_ROOT/harness/<id>.ledger` with its sha256. Registering again changes only what changed; a folder or a skill name gstack did not write stops it; a destination that is your home, the checkout, a symlink or outside a plain path is refused. `gstack-uninstall` reads the ledgers with `bin/gstack-harness-ledger.sh` (bash only) before it removes the checkout, never follows a symlink below the destination, and renames any file you edited to `<file>.edited-<time>` beside itself, so the edit outlives the uninstall. A `--context` block is cut only when its file holds exactly the block gstack wrote (whole-line markers, hash in the ledger); a lone marker or an edited block is left as it is.
- **Ten reason codes** (`HARNESS_*`), each with a fix line and a `docs/troubleshooting.md` section.
### Changed
@@ -20,7 +20,7 @@
- `./setup --status` no longer tells an OpenClaw or Hermes install to move the checkout out of its runtime root, because those hosts run from the checkout by design, as Claude does.
### Tests
- `test/harness-register.test.ts`: per-target registration against temporary harness folders (idempotent rerun, exact removal, user skills and context lines kept, edited files backed up), the refusals, the Dot engine's skill-parser rules, the budgets, every reason code's anchor, inside-harness detection, the bun-free ledger reader, QM against a fake admin API (a failed import retried without a second pack, two machines sharing one pack, `--remote` archiving), and the real `./setup` and `gstack-uninstall` for `--host openclaw`, `--host hermes` and setup run inside OpenClaw.
- `test/harness-register.test.ts`: per-target registration against temporary harness folders (idempotent rerun, exact removal, user skills and context lines kept, edited files kept beside themselves through a full uninstall, a lone, repeated or edited context block left alone, a moved `--context` recorded, a symlinked folder below the destination never followed), the refusals, the Dot engine's skill-parser rules, the budgets, every reason code's anchor, inside-harness detection, the bun-free ledger reader, QM against a fake admin API (a failed import retried without a second pack, two machines sharing one pack, `--remote` archiving), and the real `./setup` and `gstack-uninstall` for `--host openclaw`, `--host hermes` and setup run inside OpenClaw.
### Docs
- docs/hosts.md § Agent harnesses (install, verify and remove for each harness), docs/install.md, docs/uninstall.md, docs/OPENCLAW.md (native skills or ACP), docs/ADDING_A_HOST.md (harness hosts and how to add a target), docs/capy-install.md, README host matrix.
+1
View File
@@ -41,6 +41,7 @@ function receiptLines(r: RegisterResult): string[] {
return [
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []),
...r.kept.map(k => ' kept your edit: ' + k),
' ledger: ' + r.ledger,
' remove: ' + self + ' unregister ' + r.id,
];
+40 -18
View File
@@ -8,43 +8,65 @@
# gstack_harness_unregister_all remove every registration under
# $GSTACK_STATE_ROOT/harness
#
# A file whose bytes still match the ledger is removed; an edited one is moved
# to $GSTACK_STATE_ROOT/backups/harness/<stamp>/; a context block is cut out of
# its file between the gstack:begin/end harness-routing markers (the file is
# removed only when nothing else is left in it); folders gstack created are
# removed only when empty. A QM pack is never touched here: detaching it from
# A file whose bytes still match the ledger is removed; an edited one is renamed
# in place to <file>.edited-<stamp>, a name no harness loads, so it survives the
# uninstall of the state root and the checkout. Only paths below the recorded
# destination, reached through no symlink, are touched. A context block is cut
# only when its file holds exactly one whole-line begin/end marker pair whose
# lines hash to the ledger's block hash; otherwise the file is left as it is
# (the file is removed only when nothing else is left in it). Folders gstack
# created are removed only when empty. A QM pack is never touched here: detaching it from
# the org is `gstack-harness unregister qm --remote`.
_gstack_harness_sha() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
else shasum -a 256 "$1" | cut -d' ' -f1; fi
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1
else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi
}
# True when $1 is below $2 and no path from $2 down to $1 is a symlink.
_gstack_harness_owned() {
local p="$1" dest="$2"
case "$p" in "$dest"/*) ;; *) return 1 ;; esac
while [ "$p" != "$dest" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done
[ ! -L "$dest" ]
}
_gstack_harness_cut_block() {
local file="$1" tmp
[ -f "$file" ] || return 0
local file="$1" want="$2" span s e tmp
local begin='<!-- gstack:begin harness-routing -->' end='<!-- gstack:end harness-routing -->'
{ [ -f "$file" ] && [ ! -L "$file" ]; } || return 0
span="$(awk -v B="$begin" -v E="$end" '$0 == B { nb++; s = NR } $0 == E { ne++; e = NR } END { if (nb == 1 && ne == 1 && e > s) print s, e; else if (nb || ne) print "bad" }' "$file")"
[ -n "$span" ] || return 0
s="${span% *}"; e="${span#* }"
if [ "$span" = bad ] || [ "$(awk -v s="$s" -v e="$e" 'NR >= s && NR <= e' "$file" | _gstack_harness_sha)" != "$want" ]; then
echo " left $file as it is: its gstack block was edited; delete the lines from $begin to $end yourself"
return 0
fi
tmp="$file.gstack-cut.$$"
awk 'index($0, "<!-- gstack:begin harness-routing -->") { skip = 1 } !skip { print } index($0, "<!-- gstack:end harness-routing -->") { skip = 0 }' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$tmp" "$file"; else mv "$tmp" "$file"; fi
awk -v s="$s" -v e="$e" 'NR < s || NR > e' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$file"; else cat "$tmp" > "$file"; fi
rm -f "$tmp"
}
gstack_harness_unregister_ledger() {
local ledger="$1" root="${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}" stamp kind a b target="" dest="" pack="" backup
local ledger="$1" stamp kind a b target="" dest="" pack=""
: "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}"
[ -f "$ledger" ] || return 0
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)-$$"
backup="$root/backups/harness/$stamp"
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
while IFS=$'\t' read -r kind a b; do
case "$kind" in
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;;
file)
{ [ -f "$b" ] && [ ! -L "$b" ]; } || continue
{ [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
else mkdir -p "$backup$(dirname "$b")" && mv "$b" "$backup$b" && echo " kept your edit: $b moved to $backup$b"; fi ;;
block) _gstack_harness_cut_block "$b" ;;
else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;;
block) _gstack_harness_cut_block "$b" "$a" ;;
esac
done < "$ledger"
while IFS=$'\t' read -r kind a b; do
[ "$kind" = dir ] && [ -d "$b" ] && rmdir "$b" 2>/dev/null
[ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue
case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac
rmdir "$b" 2>/dev/null
done < "$ledger"
case "$target" in
openclaw|hermes)
+4 -2
View File
@@ -26,8 +26,10 @@ prints every path it wrote and the command that removes them. An explicit
Every file goes into a ledger, `$GSTACK_STATE_ROOT/harness/<id>.ledger`, with
its sha256. Registering again changes only what changed; a folder or skill name
gstack did not write stops it (`HARNESS_UNOWNED_ENTRY`); `gstack-uninstall`
and `gstack-harness unregister` remove exactly the ledger's files and move any
file you edited to `$GSTACK_STATE_ROOT/backups/harness/`. `./setup
and `gstack-harness unregister` remove exactly the ledger's files, never
through a symlink, and rename any file you edited to `<file>.edited-<time>`
beside itself, where uninstall cannot take it. A context block is cut only
when it is the one gstack wrote; an edited block is left for you. `./setup
--refresh-registered` (what `/gstack-upgrade` runs) refreshes every registration.
`gstack-harness status` lists them and runs the harness's own skill list where
it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshooting.md#agent-harnesses-gstack-harness).
+3 -1
View File
@@ -5077,13 +5077,15 @@ Hermes opens only skills it has indexed, so a router that points at unlisted fil
<a id="harness-unsafe-destination"></a>
### `gstack-harness: refusing to write into <dir>: <why>` (HARNESS_UNSAFE_DESTINATION)
The destination is your home folder, the filesystem root, inside the gstack checkout, a symlink, or a path with a tab or newline. Nothing was written. Pick the harness's own skills folder, for example `--skills-dir ~/.openclaw/skills`.
The destination is your home folder, the filesystem root, inside the gstack checkout, a symlink (or holds one where gstack's own folders were), or a path with a tab or newline. Nothing was written. Pick the harness's own skills folder, for example `--skills-dir ~/.openclaw/skills`.
<a id="harness-unowned-entry"></a>
### `gstack-harness: <path> already exists and gstack did not write it` / `a skill named <name> already exists at <path>` (HARNESS_UNOWNED_ENTRY)
gstack only replaces files its ledger says it wrote. A folder or a skill with the same name is yours (or another tool's), and harnesses resolve skills by name, so gstack stops instead of shadowing it. Rename or move that entry, then register again.
The same code stops `register generic --context <file>` when the file holds a `<!-- gstack:begin harness-routing -->` or `<!-- gstack:end harness-routing -->` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again.
<a id="harness-render-failed"></a>
### `gstack-harness: could not render <harness> skills: ...` (HARNESS_RENDER_FAILED)
+80 -38
View File
@@ -20,6 +20,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation';
export type SkillsMode = 'router' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
@@ -241,7 +242,7 @@ export function contextBlock(routerPath: string): string {
// --- ledger ----------------------------------------------------------------
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: string[] }
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: Array<{ sha: string; path: string }> }
export const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
export const ledgerDir = (stateRoot: string) => path.join(stateRoot, 'harness');
@@ -257,7 +258,7 @@ export function readLedger(file: string): Ledger | null {
if (kind === 'meta') l.meta[a] = b;
else if (kind === 'file') l.files.push({ sha: a, path: b });
else if (kind === 'dir') l.dirs.push(b);
else if (kind === 'block') l.blocks.push(b);
else if (kind === 'block') l.blocks.push({ sha: a, path: b });
}
return l;
}
@@ -267,7 +268,7 @@ export function writeLedger(file: string, l: Ledger): void {
...Object.entries(l.meta).map(([k, v]) => 'meta\t' + k + '\t' + v),
...l.files.map(f => 'file\t' + f.sha + '\t' + f.path),
...l.dirs.map(d => 'dir\t-\t' + d),
...l.blocks.map(b => 'block\t-\t' + b)];
...l.blocks.map(b => 'block\t' + b.sha + '\t' + b.path)];
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
fs.writeFileSync(file + '.tmp', rows.join('\n') + '\n', { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
@@ -299,7 +300,7 @@ export interface RegisterOptions {
}
export interface RegisterResult {
id: string; target: TargetName; dest: string; mode: SkillsMode;
status: 'installed' | 'updated' | 'unchanged'; written: number; skills: number; router: string | null; ledger: string;
status: 'installed' | 'updated' | 'unchanged'; written: number; skills: number; router: string | null; ledger: string; kept: string[];
}
const PLAIN = /^[^\t\n\r]+$/;
@@ -362,32 +363,65 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
return { files, router, names: ['gstack', ...entries.map(e => e.name)] };
}
function backupFile(file: string, stateRoot: string, stamp: string): void {
const to = path.join(stateRoot, 'backups', 'harness', stamp, file.replace(/^\/+/, ''));
fs.mkdirSync(path.dirname(to), { recursive: true });
/** An edited file stays beside itself under a name no harness loads, so no uninstall step that removes gstack's folders can take it. */
function keepEdit(file: string, stamp: string): string {
const to = file + '.edited-' + stamp;
fs.renameSync(file, to);
return to;
}
/** Refuses a path outside dest, or one reached through a symlink below dest. */
function assertOwnedPath(dest: string, p: string): void {
if (!p.startsWith(dest + path.sep)) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + dest);
for (let d = p; d !== dest; d = path.dirname(d)) {
let st: fs.Stats;
try { st = fs.lstatSync(d); } catch { continue; }
if (st.isSymbolicLink()) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', d + ' is a symlink');
}
}
function onDiskSha(file: string): string | null {
try { return sha256(fs.readFileSync(file)); } catch { return null; }
}
export function insertBlock(file: string, block: string): void {
/** The ledger's hash of a context block: its lines, each ending in a newline, as the bash reader's awk prints them. */
export const blockSha = (block: string) => sha256(block + '\n');
/**
* The text of file with the block in it: replaces the one gstack block there (whole-line markers) when it is the block
* gstack wrote (ownedSha) or already current, else appends. A lone or repeated marker, or a block someone
* edited, is refused so nothing outside gstack's own lines is ever rewritten.
*/
export function withBlock(file: string, block: string, ownedSha?: string): string {
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
const a = text.indexOf(BLOCK_BEGIN);
const b = text.indexOf(BLOCK_END, a);
const next = a >= 0 && b > a
? text.slice(0, a) + block + text.slice(b + BLOCK_END.length)
: text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block + '\n';
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, next);
const lines = text.split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
let next: string;
if (!bs.length && !es.length) next = text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block + '\n';
else if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
const cur = sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n');
if (cur !== blockSha(block) && cur !== ownedSha) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'the gstack block in ' + file + ' was edited or gstack did not write it; delete the lines from ' + BLOCK_BEGIN + ' to ' + BLOCK_END);
lines.splice(bs[0], es[0] - bs[0] + 1, ...block.split('\n'));
next = lines.join('\n');
} else throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
return next;
}
/** Runs fn from one of the checkout's sourced bash libraries, passing args through the environment. */
function sourcedBash(checkout: string, lib: string, fn: string, args: string[], stateRoot: string, env: Env) {
const argEnv = Object.fromEntries(args.map((a, i) => ['GSTACK_HARNESS_ARG' + i, a]));
return runBashSync('. "$GSTACK_HARNESS_LIB" && ' + fn + args.map((_, i) => ' "$GSTACK_HARNESS_ARG' + i + '"').join(''),
{ env: { ...process.env, ...env, ...argEnv, GSTACK_HARNESS_LIB: path.join(checkout, 'bin', lib), GSTACK_STATE_ROOT: stateRoot }, encoding: 'utf8', timeout: 60_000 });
}
/** Cuts a block this ledger recorded through the bash reader, so register and uninstall share one cutter. */
function cutBlock(opts: RegisterOptions, file: string, sha: string): void {
process.stdout.write(sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', '_gstack_harness_cut_block', [file, sha], opts.stateRoot, opts.env).stdout || '');
}
function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boolean {
const lib = path.join(opts.checkout, 'bin', 'gstack-install-registry.sh');
const r = spawnSync('bash', ['-c', '. "$0" && ' + fn + ' "$@"', lib, ...args],
{ env: { ...process.env, ...opts.env, GSTACK_STATE_ROOT: opts.stateRoot }, encoding: 'utf8', timeout: 30_000 });
return r.status === 0;
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
}
export function register(opts: RegisterOptions): RegisterResult {
@@ -425,42 +459,50 @@ export function register(opts: RegisterOptions): RegisterResult {
const other = frontmatterField(fs.readFileSync(md, 'utf8'), 'name');
if (visible.includes(other)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'a skill named ' + other + ' already exists at ' + path.join(dest, e) + ' and gstack did not write it');
}
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p);
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
const ctx = opts.context ? path.resolve(opts.context) : '';
const block = opts.context ? contextBlock(router ?? '') : null;
if (block && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
const blocks = block ? [{ sha: blockSha(block), path: ctx }] : [];
const ctxText = block ? withBlock(ctx, block, prev?.blocks.find(b => b.path === ctx)?.sha) : null;
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
&& (prev.meta.context ?? '') === ctx && prev.blocks.every(b => blocks.some(n => n.path === b.path && n.sha === b.sha))
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const kept: string[] = [];
let written = 0;
if (!same) {
const dirs = new Set(prev?.dirs ?? []);
for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d);
const record = (recorded: typeof want) => writeLedger(lfile, {
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(ctx ? { context: ctx } : {}) },
files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks,
});
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]);
for (const b of prev?.blocks ?? []) if (b.path !== ctx) cutBlock(opts, b.path, b.sha);
for (const [p, sha] of prevFiles) {
if (files.has(p)) continue;
const disk = onDiskSha(p);
if (disk === sha) fs.rmSync(p, { force: true }); else if (disk) backupFile(p, opts.stateRoot, stamp);
if (disk === sha) fs.rmSync(p, { force: true }); else if (disk) kept.push(keepEdit(p, stamp));
}
const dirs = new Set(prev?.dirs ?? []);
for (const [p, b] of files) {
const disk = onDiskSha(p);
const sha = sha256(b);
if (disk === sha) continue;
if (disk && prevFiles.get(p) !== disk) backupFile(p, opts.stateRoot, stamp);
let d = path.dirname(p);
const made: string[] = [];
while (!fs.existsSync(d)) { made.push(d); d = path.dirname(d); }
if (disk === sha256(b)) continue;
if (disk && prevFiles.get(p) !== disk) kept.push(keepEdit(p, stamp));
fs.mkdirSync(path.dirname(p), { recursive: true });
made.forEach(m => dirs.add(m));
fs.writeFileSync(p + '.gstack-new', b);
fs.renameSync(p + '.gstack-new', p);
written++;
}
writeLedger(lfile, {
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(opts.context ? { context: path.resolve(opts.context) } : {}) },
files: want, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks: opts.context ? [path.resolve(opts.context)] : [],
});
record(want);
}
if (ctxText !== null && ctxText !== (fs.existsSync(ctx) ? fs.readFileSync(ctx, 'utf8') : null)) {
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, ctxText);
}
if (block) insertBlock(path.resolve(opts.context!), block);
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile };
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept };
} finally {
fs.rmSync(scratch, { recursive: true, force: true });
}
@@ -478,8 +520,8 @@ export function unregister(stateRoot: string, checkout: string, idOrTarget: stri
const all = listLedgers(stateRoot).filter(l => l.ledger.meta.id === idOrTarget || l.ledger.meta.target === idOrTarget);
if (!all.length) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no registration named ' + JSON.stringify(idOrTarget));
for (const { file } of all) {
const r = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_ledger "$1"', path.join(checkout, 'bin', 'gstack-harness-ledger.sh'), file],
{ env: { ...process.env, ...env, GSTACK_STATE_ROOT: stateRoot }, encoding: 'utf8', timeout: 60_000 });
const r = sourcedBash(checkout, 'gstack-harness-ledger.sh', 'gstack_harness_unregister_ledger', [file], stateRoot, env);
process.stdout.write(r.stdout || '');
if (r.status !== 0) throw new HarnessError('HARNESS_NOT_REGISTERED', 'could not remove ' + file + ': ' + (r.stderr || '').trim());
}
return all.map(l => l.ledger.meta.id);
+1
View File
@@ -428,6 +428,7 @@ if [ "$REFRESH_REGISTERED" -eq 1 ] && [ -z "${GSTACK_SETUP_REFRESH_CHILD:-}" ];
_RF_TARGETS=""
_RF_SKIPPED=""
_rf_add() {
case "$1" in openclaw|hermes) return 0 ;; esac
case "
$_RF_TARGETS" in *"
$1 $2 $3
+2 -2
View File
@@ -3689,11 +3689,11 @@ describe('setup script validation', () => {
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
});
test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => {
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|openclaw|hermes; slate is informational', () => {
expect(setupContent).toContain('--host');
// #2361: slate moved OUT of the install accept-list (it was accepted but
// never dispatched — a silent exit-0 no-op) into an informational arm.
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto');
expect(setupContent).toMatch(/^ {2}slate\)/m);
});
+66 -4
View File
@@ -152,9 +152,67 @@ describe('gstack-harness register and unregister (skill folders)', () => {
unregister(path.join(d, 'state'), ROOT, 'generic');
expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n\n');
expect(fs.existsSync(r.router!)).toBe(false);
const kept = files(path.join(d, 'state', 'backups', 'harness'));
expect(kept.length).toBe(1);
expect(kept[0].endsWith('gstack/SKILL.md')).toBe(true);
const kept = files(path.dirname(r.router!));
expect(kept).toHaveLength(1);
expect(kept[0]).toMatch(/^SKILL\.md\.edited-/);
expect(fs.readFileSync(path.join(path.dirname(r.router!), kept[0]), 'utf8')).toEndWith('\nmy note\n');
}, 60_000);
test('generic: a lone, repeated or edited marker block is never cut or rewritten; prose that names a marker is left alone', () => {
const d = tmp();
const ctx = path.join(d, 'AGENTS.md');
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
const lone = '# mine\n' + BLOCK_BEGIN + '\n## Important\nkeep me\n';
fs.writeFileSync(ctx, lone);
expect(code(() => register(opts(d, 'generic', { context: ctx })))).toBe('HARNESS_UNOWNED_ENTRY');
expect(fs.readFileSync(ctx, 'utf8')).toBe(lone);
const prose = '# mine\nthe markers look like ' + BLOCK_BEGIN + ' and ' + BLOCK_END + '\n';
fs.writeFileSync(ctx, prose);
register(opts(d, 'generic', { context: ctx }));
unregister(path.join(d, 'state'), ROOT, 'generic');
expect(fs.readFileSync(ctx, 'utf8')).toBe(prose + '\n');
for (const edit of [(s: string) => s.replace(BLOCK_END + '\n', ''), (s: string) => s.replace('## gstack skills', '## gstack skills\nmy own line')]) {
fs.writeFileSync(ctx, '# mine\n');
register(opts(d, 'generic', { context: ctx }));
const edited = edit(fs.readFileSync(ctx, 'utf8')) + '## after\nkeep me too\n';
fs.writeFileSync(ctx, edited);
const un = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', path.join(ROOT, 'bin', 'gstack-harness-ledger.sh')], { env: { PATH: '/usr/bin:/bin', GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 30_000 });
expect(un.status, un.stderr).toBe(0);
expect(un.stdout).toContain('left ' + ctx + ' as it is');
expect(fs.readFileSync(ctx, 'utf8')).toBe(edited);
}
}, 60_000);
test('generic: adding or moving --context is recorded, and the old block is cut', () => {
const d = tmp();
const [a, b] = [path.join(d, 'a.md'), path.join(d, 'b.md')];
fs.writeFileSync(a, '# a\n');
fs.writeFileSync(b, '# b\n');
const skills = path.join(d, 'skills');
register(opts(d, 'dots', { skillsDir: skills }));
expect(register(opts(d, 'dots', { skillsDir: skills, context: a })).status).toBe('updated');
expect(fs.readFileSync(a, 'utf8')).toContain(BLOCK_BEGIN);
const r = register(opts(d, 'dots', { skillsDir: skills, context: b }));
expect(fs.readFileSync(a, 'utf8')).toBe('# a\n\n');
expect(readLedger(r.ledger)!.blocks.map(x => x.path)).toEqual([b]);
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.readFileSync(b, 'utf8')).toBe('# b\n\n');
}, 60_000);
test('a folder swapped for a symlink below the destination is never followed', () => {
const d = tmp();
const skills = path.join(d, 'skills');
const r = register(opts(d, 'openclaw', { skillsDir: skills }));
const review = path.join(skills, 'gstack', 'skills', fs.readdirSync(path.join(skills, 'gstack', 'skills')).sort()[0]);
const outside = path.join(d, 'outside');
fs.cpSync(review, outside, { recursive: true });
fs.rmSync(review, { recursive: true });
fs.symlinkSync(outside, review);
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
expect(code(() => register(opts(d, 'openclaw', { skillsDir: skills })))).toBe('HARNESS_UNSAFE_DESTINATION');
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.existsSync(path.join(outside, 'SKILL.md'))).toBe(true);
expect(fs.lstatSync(review).isSymbolicLink()).toBe(true);
}, 60_000);
});
@@ -254,9 +312,13 @@ describe.skipIf(process.platform === 'win32')('setup and uninstall with harnesse
const status = runSetup(f, path.join(src, 'setup'), ['--status']);
expect(status.stdout).toMatch(new RegExp('\\b' + host + '\\s+experimental\\s+global\\s+current'));
expect(status.stdout).not.toContain('move it first');
fs.appendFileSync(path.join(f.home, dest, probe), '\nmy note\n');
const un = spawnSync('bash', [path.join(src, 'bin/gstack-uninstall'), '--force'], { cwd: f.home, env: { ...f.env, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 60_000 });
expect(un.status, un.stdout + un.stderr).toBe(0);
expect(files(path.join(f.home, dest)).filter(p => !p.startsWith('.'))).toEqual([]);
const left = files(path.join(f.home, dest)).filter(p => !p.startsWith('.'));
expect(left).toHaveLength(1);
expect(left[0]).toStartWith(probe + '.edited-');
expect(fs.readFileSync(path.join(f.home, dest, left[0]), 'utf8')).toEndWith('\nmy note\n');
}, 120_000);
}
+1 -4
View File
@@ -118,7 +118,7 @@ describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
});
test.each(['slate', 'openclaw', 'hermes', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
test.each(['slate', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-info-preflight-'));
const startup = path.join(temporary, 'startup.bash');
fs.writeFileSync(startup, `command() { echo 'unexpected installation preflight' >&2; return 97; }
@@ -134,9 +134,6 @@ date() { echo 'unexpected backup timestamp' >&2; return 97; }
expect(res.status).toBe(0);
expect(res.stderr).toBe('');
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
if (host === 'openclaw' || host === 'hermes') {
expect(res.stdout.replaceAll('\\', '/')).toContain('/agents-digest/gstack-AGENTS.md');
}
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
});