@khorsheed/dsh-ankh-guard 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.1.1(2026-08-23)
4
+
5
+ - 修复:随包 skill 在目录里可见、调用即炸——宿主在 load 时才校验注册的 `source` 字段,之前没传;已补 `source: 'runtime'`,并加了真实 SkillRegistry 往返测试(list + load)防回归
6
+
3
7
  ## 0.1.0(2026-08-22)
4
8
 
5
9
  首个公开发布。
package/lib/cli.js CHANGED
@@ -74,17 +74,36 @@ function sandboxGate(verb, options, io) {
74
74
  io.stderr(`${verb} refused: this environment is sandboxed (a probe write outside the workspace was denied), so a detached restart/watchdog process would be reaped when the turn ends. You cannot switch the sandbox yourself — ask the user to run /permission danger-full-access in THIS session (a yes/no "authorization" changes nothing), then verify with \`dsh-ankh-guard check-env\` and retry. Certain the probe is wrong? Re-run with --force\n`);
75
75
  return false;
76
76
  }
77
+ /**
78
+ * Whether the pid named by this raw pid/lock-file content is alive. Empty
79
+ * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
80
+ * process group (always succeeds), which once read as "alive" and refused
81
+ * every restart forever — the bug that had to be fixed in two copies of this
82
+ * logic before it was consolidated here.
83
+ */
84
+ function pidAlive(raw) {
85
+ const pid = Number(raw);
86
+ if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
95
+ function livePidIn(file) {
96
+ try {
97
+ const raw = readFileSync(file, "utf8").trim();
98
+ return pidAlive(raw) ? raw : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
77
103
  /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
78
104
  function liveWatchdogPid(stateDir) {
79
- try {
80
- const raw = readFileSync(stateFile(stateDir, "watchdogPid"), "utf8").trim();
81
- const pid = Number(raw);
82
- if (raw !== "" && Number.isInteger(pid) && pid > 0) {
83
- process.kill(pid, 0);
84
- return pid;
85
- }
86
- } catch {}
87
- return null;
105
+ const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
106
+ return raw === null ? null : Number(raw);
88
107
  }
89
108
  /**
90
109
  * Cross-session restart mutual exclusion: two concurrent restarts would both
@@ -116,14 +135,10 @@ function acquireRestartLock(stateDir, holderPid = process.pid) {
116
135
  holder: `unreadable (${String(error)})`
117
136
  };
118
137
  }
119
- const pid = Number(holder);
120
- if (holder !== "" && Number.isInteger(pid) && pid > 0) try {
121
- process.kill(pid, 0);
122
- return {
123
- ok: false,
124
- holder
125
- };
126
- } catch {}
138
+ if (pidAlive(holder)) return {
139
+ ok: false,
140
+ holder
141
+ };
127
142
  try {
128
143
  unlinkSync(file);
129
144
  } catch (error) {
@@ -456,18 +471,6 @@ function restartMarkerState(stateDir) {
456
471
  return "stale";
457
472
  }
458
473
  }
459
- /** The restart lock's live holder pid (as a string), or null when free/stale. */
460
- function liveRestartLockHolder(stateDir) {
461
- try {
462
- const raw = readFileSync(stateFile(stateDir, "restartLock"), "utf8").trim();
463
- const pid = Number(raw);
464
- if (raw !== "" && Number.isInteger(pid) && pid > 0) try {
465
- process.kill(pid, 0);
466
- return raw;
467
- } catch {}
468
- } catch {}
469
- return null;
470
- }
471
474
  /** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
472
475
  const PREFLIGHT_OUTPUT_CAP = 65536;
473
476
  /**
@@ -1150,46 +1153,50 @@ async function runCli(argv, io) {
1150
1153
  if (!sandboxGate("schedule-exit", options, io)) return 1;
1151
1154
  if (!await preflightGate("schedule-exit", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
1152
1155
  if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
1153
- const markerFile = stateFile(stateDir, "restartRequested");
1154
- const markerState = restartMarkerState(stateDir);
1155
- if (markerState === "fresh") {
1156
- io.stderr("schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n");
1157
- return 1;
1158
- }
1159
- if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
1160
- const inFlight = liveRestartLockHolder(stateDir);
1161
- if (inFlight !== null) {
1162
- io.stderr(`schedule-exit refused: a restart is in flight (pid ${inFlight}) — the exit agent would kill the instance it is starting\n`);
1156
+ const lock = acquireRestartLock(stateDir);
1157
+ if (!lock.ok) {
1158
+ io.stderr(`schedule-exit refused: a restart is in flight (pid ${lock.holder}) — the exit agent would kill the instance it is starting\n`);
1163
1159
  return 1;
1164
1160
  }
1165
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1166
- mkdirSync(stateDir, { recursive: true });
1167
- writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
1168
- reason: "scheduled self-restart",
1169
- requestedAt: Date.now(),
1170
- ...initiator !== void 0 ? { initiator } : {}
1171
- })}\n`);
1172
- const resultFile = stateFile(stateDir, "lastRestart");
1173
- const logPath = options.log ?? stateFile(stateDir, "scheduleExitLog");
1174
- mkdirSync(dirname(logPath), { recursive: true });
1175
- const child = spawn(process.execPath, exitAgentInvocation(), {
1176
- detached: true,
1177
- stdio: [
1178
- "ignore",
1179
- openSync(logPath, "a"),
1180
- openSync(logPath, "a")
1181
- ],
1182
- env: {
1183
- ...process.env,
1184
- WD_PORT: String(port),
1185
- WD_DELAY_MS: String(delayMs),
1186
- WD_RESULT_FILE: resultFile,
1187
- ...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
1161
+ try {
1162
+ const markerFile = stateFile(stateDir, "restartRequested");
1163
+ const markerState = restartMarkerState(stateDir);
1164
+ if (markerState === "fresh") {
1165
+ io.stderr("schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n");
1166
+ return 1;
1188
1167
  }
1189
- });
1190
- child.unref();
1191
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
1192
- return 0;
1168
+ if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
1169
+ const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1170
+ mkdirSync(stateDir, { recursive: true });
1171
+ writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
1172
+ reason: "scheduled self-restart",
1173
+ requestedAt: Date.now(),
1174
+ ...initiator !== void 0 ? { initiator } : {}
1175
+ })}\n`);
1176
+ const resultFile = stateFile(stateDir, "lastRestart");
1177
+ const logPath = options.log ?? stateFile(stateDir, "scheduleExitLog");
1178
+ mkdirSync(dirname(logPath), { recursive: true });
1179
+ const child = spawn(process.execPath, exitAgentInvocation(), {
1180
+ detached: true,
1181
+ stdio: [
1182
+ "ignore",
1183
+ openSync(logPath, "a"),
1184
+ openSync(logPath, "a")
1185
+ ],
1186
+ env: {
1187
+ ...process.env,
1188
+ WD_PORT: String(port),
1189
+ WD_DELAY_MS: String(delayMs),
1190
+ WD_RESULT_FILE: resultFile,
1191
+ ...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
1192
+ }
1193
+ });
1194
+ child.unref();
1195
+ io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
1196
+ return 0;
1197
+ } finally {
1198
+ lock.release();
1199
+ }
1193
1200
  }
1194
1201
  default:
1195
1202
  io.stderr(`unknown command ${command}\n\n${USAGE}`);
package/lib/index.js CHANGED
@@ -68,7 +68,8 @@ function registerRestartSkill(ctx, stateDir) {
68
68
  ctx.effect(() => skills.register({
69
69
  name,
70
70
  description,
71
- content
71
+ content,
72
+ source: "runtime"
72
73
  }));
73
74
  writeSkillRegistration(stateDir, {
74
75
  registered: true,
package/lib/types/cli.js CHANGED
@@ -80,20 +80,39 @@ function sandboxGate(verb, options, io) {
80
80
  io.stderr(`${verb} refused: this environment is sandboxed (a probe write outside the workspace was denied), so a detached restart/watchdog process would be reaped when the turn ends. You cannot switch the sandbox yourself — ask the user to run /permission danger-full-access in THIS session (a yes/no "authorization" changes nothing), then verify with \`dsh-ankh-guard check-env\` and retry. Certain the probe is wrong? Re-run with --force\n`);
81
81
  return false;
82
82
  }
83
- /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
84
- function liveWatchdogPid(stateDir) {
83
+ /**
84
+ * Whether the pid named by this raw pid/lock-file content is alive. Empty
85
+ * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
86
+ * process group (always succeeds), which once read as "alive" and refused
87
+ * every restart forever — the bug that had to be fixed in two copies of this
88
+ * logic before it was consolidated here.
89
+ */
90
+ function pidAlive(raw) {
91
+ const pid = Number(raw);
92
+ if (raw === '' || !Number.isInteger(pid) || pid <= 0)
93
+ return false;
85
94
  try {
86
- const raw = readFileSync(stateFile(stateDir, 'watchdogPid'), 'utf8').trim();
87
- const pid = Number(raw);
88
- // raw '' → 0, and kill(0, 0) always succeeds (it probes our own process
89
- // group): an empty pidfile must read as NO watchdog, never as alive.
90
- if (raw !== '' && Number.isInteger(pid) && pid > 0) {
91
- process.kill(pid, 0);
92
- return pid;
93
- }
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
94
100
  }
95
- catch { /* no pidfile or a dead owner */ }
96
- return null;
101
+ }
102
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
103
+ function livePidIn(file) {
104
+ try {
105
+ const raw = readFileSync(file, 'utf8').trim();
106
+ return pidAlive(raw) ? raw : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
113
+ function liveWatchdogPid(stateDir) {
114
+ const raw = livePidIn(stateFile(stateDir, 'watchdogPid'));
115
+ return raw === null ? null : Number(raw);
97
116
  }
98
117
  /**
99
118
  * Cross-session restart mutual exclusion: two concurrent restarts would both
@@ -125,14 +144,8 @@ function acquireRestartLock(stateDir, holderPid = process.pid) {
125
144
  catch (error) {
126
145
  return { ok: false, holder: `unreadable (${String(error)})` };
127
146
  }
128
- const pid = Number(holder);
129
- if (holder !== '' && Number.isInteger(pid) && pid > 0) {
130
- try {
131
- process.kill(pid, 0);
132
- return { ok: false, holder };
133
- }
134
- catch { /* dead holder — reclaim below */ }
135
- }
147
+ if (pidAlive(holder))
148
+ return { ok: false, holder };
136
149
  try {
137
150
  unlinkSync(file);
138
151
  }
@@ -443,22 +456,6 @@ function restartMarkerState(stateDir) {
443
456
  return 'stale'; // unparseable is stale by definition
444
457
  }
445
458
  }
446
- /** The restart lock's live holder pid (as a string), or null when free/stale. */
447
- function liveRestartLockHolder(stateDir) {
448
- try {
449
- const raw = readFileSync(stateFile(stateDir, 'restartLock'), 'utf8').trim();
450
- const pid = Number(raw);
451
- if (raw !== '' && Number.isInteger(pid) && pid > 0) {
452
- try {
453
- process.kill(pid, 0);
454
- return raw;
455
- }
456
- catch { /* dead holder */ }
457
- }
458
- }
459
- catch { /* no lock file */ }
460
- return null;
461
- }
462
459
  /** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
463
460
  const PREFLIGHT_OUTPUT_CAP = 64 * 1024;
464
461
  /**
@@ -1282,64 +1279,74 @@ export async function runCli(argv, io) {
1282
1279
  if (liveWatchdogPid(stateDir) === null) {
1283
1280
  io.stderr(NO_WATCHDOG_HINT);
1284
1281
  }
1285
- // One scheduled restart at a time: the marker carries a single
1286
- // initiator, so overwriting a FRESH one would silently reassign the
1287
- // pending report. A marker past the TTL is stale (the watchdog died
1288
- // mid-flow without clearing it) overwrite with a warning instead of
1289
- // refusing forever.
1290
- const markerFile = stateFile(stateDir, 'restartRequested');
1291
- const markerState = restartMarkerState(stateDir);
1292
- if (markerState === 'fresh') {
1293
- io.stderr('schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n');
1282
+ // One scheduled restart at a time, and never while a restart is in
1283
+ // flight. Both checks must hold ATOMICALLY with writing the marker and
1284
+ // spawning the exit agent: an earlier version checked without holding
1285
+ // the lock, and the narrow read→write window let a concurrent restart
1286
+ // pass its own checks in between — the scheduled exit agent then
1287
+ // SIGTERMed the instance that restart had just started. Taking the
1288
+ // restart lock here serializes the two verbs on the same primitive
1289
+ // (restart's own marker check stays: it guards against an exit agent
1290
+ // scheduled BEFORE its acquisition, already past this window).
1291
+ const lock = acquireRestartLock(stateDir);
1292
+ if (!lock.ok) {
1293
+ io.stderr(`schedule-exit refused: a restart is in flight (pid ${lock.holder}) — the exit agent would kill the instance it is starting\n`);
1294
1294
  return 1;
1295
1295
  }
1296
- if (markerState === 'stale' && existsSync(markerFile)) {
1297
- io.stderr('warning: overwriting a stale restart marker (a previous schedule never completed)\n');
1296
+ try {
1297
+ // The marker carries a single initiator, so overwriting a FRESH one
1298
+ // would silently reassign the pending report. A marker past the TTL
1299
+ // is stale (the watchdog died mid-flow without clearing it) —
1300
+ // overwrite with a warning instead of refusing forever.
1301
+ const markerFile = stateFile(stateDir, 'restartRequested');
1302
+ const markerState = restartMarkerState(stateDir);
1303
+ if (markerState === 'fresh') {
1304
+ io.stderr('schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n');
1305
+ return 1;
1306
+ }
1307
+ if (markerState === 'stale' && existsSync(markerFile)) {
1308
+ io.stderr('warning: overwriting a stale restart marker (a previous schedule never completed)\n');
1309
+ }
1310
+ // Intentional-restart marker: the supervising watchdog runs the canary
1311
+ // after the respawn and clears this on pass. The initiator (the session
1312
+ // that requested the exit) rides along so the restart report can return
1313
+ // to that session instead of racing to whichever root agent resumes
1314
+ // first. Everything lands in stateDir directly — the same directory the
1315
+ // plugin reads (see the supervise case for why no home is derived).
1316
+ const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1317
+ mkdirSync(stateDir, { recursive: true });
1318
+ writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
1319
+ reason: 'scheduled self-restart',
1320
+ requestedAt: Date.now(),
1321
+ ...(initiator !== undefined ? { initiator } : {}),
1322
+ })}\n`);
1323
+ // A DETACHED exit agent (setsid via node spawn): it cannot be reaped by
1324
+ // the sandbox/harness process group, so the scheduled kill actually
1325
+ // lands even after the scheduling turn ends — the fix for "the kill
1326
+ // never happened" seen with `(sleep N; kill) &` from a managed shell.
1327
+ // The agent is a real shipped file (typechecked, linted, unit-tested),
1328
+ // spawned with the same source/built split as guardInvocation().
1329
+ const resultFile = stateFile(stateDir, 'lastRestart');
1330
+ const logPath = options.log ?? stateFile(stateDir, 'scheduleExitLog');
1331
+ mkdirSync(dirname(logPath), { recursive: true });
1332
+ const child = spawn(process.execPath, exitAgentInvocation(), {
1333
+ detached: true,
1334
+ stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1335
+ env: {
1336
+ ...process.env,
1337
+ WD_PORT: String(port),
1338
+ WD_DELAY_MS: String(delayMs),
1339
+ WD_RESULT_FILE: resultFile,
1340
+ ...(initiator !== undefined ? { WD_INITIATOR: initiator } : {}),
1341
+ },
1342
+ });
1343
+ child.unref();
1344
+ io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
1345
+ return 0;
1298
1346
  }
1299
- // And the other direction of the same invariant: a restart in flight
1300
- // (live lock holder) means an instance is being stopped/started right
1301
- // now — scheduling an exit would SIGTERM the one it just started.
1302
- const inFlight = liveRestartLockHolder(stateDir);
1303
- if (inFlight !== null) {
1304
- io.stderr(`schedule-exit refused: a restart is in flight (pid ${inFlight}) — the exit agent would kill the instance it is starting\n`);
1305
- return 1;
1347
+ finally {
1348
+ lock.release();
1306
1349
  }
1307
- // Intentional-restart marker: the supervising watchdog runs the canary
1308
- // after the respawn and clears this on pass. The initiator (the session
1309
- // that requested the exit) rides along so the restart report can return
1310
- // to that session instead of racing to whichever root agent resumes
1311
- // first. Everything lands in stateDir directly — the same directory the
1312
- // plugin reads (see the supervise case for why no home is derived).
1313
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1314
- mkdirSync(stateDir, { recursive: true });
1315
- writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
1316
- reason: 'scheduled self-restart',
1317
- requestedAt: Date.now(),
1318
- ...(initiator !== undefined ? { initiator } : {}),
1319
- })}\n`);
1320
- // A DETACHED exit agent (setsid via node spawn): it cannot be reaped by
1321
- // the sandbox/harness process group, so the scheduled kill actually
1322
- // lands even after the scheduling turn ends — the fix for "the kill
1323
- // never happened" seen with `(sleep N; kill) &` from a managed shell.
1324
- // The agent is a real shipped file (typechecked, linted, unit-tested),
1325
- // spawned with the same source/built split as guardInvocation().
1326
- const resultFile = stateFile(stateDir, 'lastRestart');
1327
- const logPath = options.log ?? stateFile(stateDir, 'scheduleExitLog');
1328
- mkdirSync(dirname(logPath), { recursive: true });
1329
- const child = spawn(process.execPath, exitAgentInvocation(), {
1330
- detached: true,
1331
- stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1332
- env: {
1333
- ...process.env,
1334
- WD_PORT: String(port),
1335
- WD_DELAY_MS: String(delayMs),
1336
- WD_RESULT_FILE: resultFile,
1337
- ...(initiator !== undefined ? { WD_INITIATOR: initiator } : {}),
1338
- },
1339
- });
1340
- child.unref();
1341
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
1342
- return 0;
1343
1350
  }
1344
1351
  default:
1345
1352
  io.stderr(`unknown command ${command}\n\n${USAGE}`);
@@ -56,7 +56,7 @@ function registerRestartSkill(ctx, stateDir) {
56
56
  writeSkillRegistration(stateDir, { registered: false, reason: 'shipped SKILL.md malformed', at: Date.now() });
57
57
  return;
58
58
  }
59
- ctx.effect(() => skills.register({ name, description, content }));
59
+ ctx.effect(() => skills.register({ name, description, content, source: 'runtime' }));
60
60
  writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
61
61
  }
62
62
  catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-ankh-guard",
3
3
  "description": "Hard gate for self-modification restarts: a green-build credential bound to the git HEAD, checked before any restart of the running instance",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -57,7 +57,8 @@
57
57
  "@types/node": "^22.0.0",
58
58
  "tsdown": "^0.22.2",
59
59
  "typescript": "^5.9.0",
60
- "vitest": "^3.0.0"
60
+ "vitest": "^3.0.0",
61
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.8"
61
62
  },
62
63
  "keywords": [
63
64
  "dsh",