Skill Tracer
Skills › gstack › browse › browser-skill-write.ts

browser-skill-write.ts

216 lines · browse/src/browser-skill-write.ts at 3f79d81

1/**
2 * Atomic-write helper for agent-authored browser-skills (D3 from Phase 2 plan).
3 *
4 * /skillify stages a candidate skill into ~/.gstack/.tmp/skillify-<spawnId>/,
5 * runs $B skill test against it, and only renames the directory into its final
6 * tier path on success + user approval. On failure or rejection, the staged
7 * directory is removed entirely — no half-written skill ever appears in
8 * $B skill list, no tombstone for something the user never approved.
9 *
10 * stageSkill — write all files into the staging dir, return its path
11 * commitSkill — atomic rename into the final tier path; refuses to clobber
12 * discardStaged — rm -rf the staged dir (called on test fail or reject)
13 *
14 * Symlink discipline: lstat() the staging dir before rename to refuse moves
15 * through symlinks; realpath() the final tier root to ensure the destination
16 * lands inside the expected directory tree.
17 */
19import * as fs from 'fs';
20import * as path from 'path';
21import { mkdirSecure } from './file-permissions';
22import { isPathWithin } from './platform';
23import type { TierPaths } from './browser-skills';
24import { defaultTierPaths } from './browser-skills';
25import { resolveStateRoot } from '../../lib/state-root';
27// ─── Naming validation ──────────────────────────────────────────
29/**
30 * Skill names must be safe directory names: lowercase letters, digits, dashes.
31 * Starts with a letter, no consecutive dashes, no trailing dash, ≤64 chars.
32 * Rejects '..', leading dots, slashes, anything that could escape the tier dir.
33 */
34const SKILL_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
36export function validateSkillName(name: string): void {
37 if (!name) throw new Error('Skill name is empty.');
38 if (name.length > 64) throw new Error(`Skill name too long (${name.length} > 64).`);
39 if (!SKILL_NAME_PATTERN.test(name)) {
40 throw new Error(
41 `Invalid skill name "${name}". Must be lowercase letters/digits/dashes, ` +
42 `start with a letter, no leading/trailing/consecutive dashes.`,
43 );
44 }
47// ─── Staging ────────────────────────────────────────────────────
49export interface StageSkillOptions {
50 name: string;
51 /** Map of relative path → contents. Path may contain '/' for nested dirs. */
52 files: Map<string, string | Buffer>;
53 /** Optional override (tests pass synthetic spawn ids). */
54 spawnId?: string;
55 /** Optional override (tests pass a fake tmp root). */
56 tmpRoot?: string;
59/**
60 * Stage a skill into the staging tree:
61 * <tmpRoot>/.gstack/.tmp/skillify-<spawnId>/<name>/
62 *
63 * The leaf <name> directory is what gets renamed during commit. The wrapper
64 * skillify-<spawnId>/ is per-spawn so concurrent /skillify invocations don't
65 * collide. Returns the absolute path to the staged skill dir (ending in <name>).
66 */
67export function stageSkill(opts: StageSkillOptions): string {
68 validateSkillName(opts.name);
69 if (opts.files.size === 0) {
70 throw new Error('stageSkill: files map is empty.');
71 }
73 const spawnId = opts.spawnId ?? generateSpawnId();
74 const tmpRoot = opts.tmpRoot ?? path.join(resolveStateRoot(), '.tmp');
75 const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
76 const stagedDir = path.join(wrapperDir, opts.name);
78 mkdirSecure(wrapperDir);
79 mkdirSecure(stagedDir);
81 for (const [relPath, contents] of opts.files) {
82 if (relPath.startsWith('/') || relPath.includes('..')) {
83 // Defense in depth: validateSkillName above bounds the leaf, but a
84 // bad relPath in files could still write outside the staged dir.
85 throw new Error(`Invalid file path in stageSkill: "${relPath}".`);
86 }
87 const filePath = path.join(stagedDir, relPath);
88 const fileDir = path.dirname(filePath);
89 fs.mkdirSync(fileDir, { recursive: true });
90 fs.writeFileSync(filePath, contents);
91 }
93 return stagedDir;
96// ─── Commit (atomic rename) ─────────────────────────────────────
98export interface CommitSkillOptions {
99 name: string;
100 tier: 'project' | 'global';
101 stagedDir: string;
102 /** Optional override (tests pass synthetic tier paths). */
103 tiers?: TierPaths;
106/**
107 * Atomically move the staged skill into its final tier path. Refuses to
108 * clobber an existing skill at the same path — the agent's approval gate
109 * MUST surface name collisions before calling this.
110 *
111 * Returns the absolute path of the committed skill dir.
112 *
113 * Throws when:
114 * - tier path is unresolved (project tier with no project root)
115 * - destination already exists
116 * - staged dir is a symlink (refuses to follow)
117 * - resolved destination escapes the tier root (defense in depth)
118 */
119export function commitSkill(opts: CommitSkillOptions): string {
120 validateSkillName(opts.name);
122 const tiers = opts.tiers ?? defaultTierPaths();
123 const tierRoot = opts.tier === 'project' ? tiers.project : tiers.global;
124 if (!tierRoot) {
125 throw new Error(`commitSkill: tier "${opts.tier}" has no resolved path.`);
126 }
128 // Refuse to follow a symlinked staging dir — caller should hand us the path
129 // returned by stageSkill, which is always a real directory.
130 let stagedStat: fs.Stats;
131 try {
132 stagedStat = fs.lstatSync(opts.stagedDir);
133 } catch (err: any) {
134 throw new Error(`commitSkill: staged dir "${opts.stagedDir}" not accessible: ${err.code ?? err.message}`);
135 }
136 if (stagedStat.isSymbolicLink()) {
137 throw new Error(`commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.`);
138 }
139 if (!stagedStat.isDirectory()) {
140 throw new Error(`commitSkill: staged path "${opts.stagedDir}" is not a directory.`);
141 }
143 // Ensure the tier root exists, then resolve its real path so the final
144 // destination check defends against tierRoot itself being a symlink.
145 fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 });
146 const realTierRoot = fs.realpathSync(tierRoot);
148 const dest = path.join(realTierRoot, opts.name);
149 if (!isPathWithin(dest, realTierRoot)) {
150 // Should be impossible after validateSkillName, but defense in depth.
151 throw new Error(`commitSkill: destination "${dest}" escapes tier root.`);
152 }
154 // Refuse to clobber. Both regular dirs and symlinks count.
155 let destExists = false;
156 try {
157 fs.lstatSync(dest);
158 destExists = true;
159 } catch (err: any) {
160 if (err.code !== 'ENOENT') throw err;
161 }
162 if (destExists) {
163 throw new Error(
164 `commitSkill: a skill named "${opts.name}" already exists at ${dest}. ` +
165 `Pick a different name or remove the existing skill first ` +
166 `($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).`,
167 );
168 }
170 fs.renameSync(opts.stagedDir, dest);
171 return dest;
174// ─── Discard (cleanup on failure or reject) ─────────────────────
176/**
177 * Remove the staged skill directory and its per-spawn wrapper. Called on
178 * test failure (step 8 of /skillify) or approval rejection (step 9).
179 *
180 * Idempotent: missing dirs are not an error. Best-effort: failures are
181 * swallowed (cleanup is fire-and-forget, not load-bearing).
182 */
183export function discardStaged(stagedDir: string): void {
184 // Remove the leaf skill dir first, then the wrapper skillify-<spawnId>/.
185 // If the wrapper was the only thing inside it, this tidies up that too.
186 try {
187 fs.rmSync(stagedDir, { recursive: true, force: true });
188 } catch {
189 // best effort
190 }
191 const wrapperDir = path.dirname(stagedDir);
192 if (path.basename(wrapperDir).startsWith('skillify-')) {
193 try {
194 // Only remove the wrapper if it's now empty — concurrent /skillify
195 // invocations get their own wrappers, but if a buggy caller passed
196 // a stagedDir not under a skillify-<id> wrapper we should not nuke
197 // an unrelated parent.
198 const remaining = fs.readdirSync(wrapperDir);
199 if (remaining.length === 0) {
200 fs.rmdirSync(wrapperDir);
201 }
202 } catch {
203 // best effort
204 }
205 }
208// ─── Spawn id ───────────────────────────────────────────────────
210/** Per-spawn id matching the format used by skill-token.ts. */
211function generateSpawnId(): string {
212 // 8 random hex chars + millis suffix — collision risk negligible across
213 // concurrent /skillify invocations on a single machine.
214 const rand = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0');
215 return `${rand}-${Date.now().toString(36)}`;