@kya-os/cli 1.7.0 → 2.0.0

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.
Files changed (161) hide show
  1. package/dist/commands/build.js +2 -2
  2. package/dist/commands/build.js.map +1 -1
  3. package/dist/commands/check.js +3 -3
  4. package/dist/commands/check.js.map +1 -1
  5. package/dist/commands/checkpoint-init.d.ts +32 -0
  6. package/dist/commands/checkpoint-init.d.ts.map +1 -0
  7. package/dist/commands/checkpoint-init.js +521 -0
  8. package/dist/commands/checkpoint-init.js.map +1 -0
  9. package/dist/commands/claim.js +2 -2
  10. package/dist/commands/claim.js.map +1 -1
  11. package/dist/commands/dco.d.ts +38 -1
  12. package/dist/commands/dco.d.ts.map +1 -1
  13. package/dist/commands/dco.js +180 -7
  14. package/dist/commands/dco.js.map +1 -1
  15. package/dist/commands/dev.js +1 -1
  16. package/dist/commands/dev.js.map +1 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +18 -24
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/env.js +6 -6
  21. package/dist/commands/env.js.map +1 -1
  22. package/dist/commands/identity.js +3 -3
  23. package/dist/commands/identity.js.map +1 -1
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +103 -65
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/register.d.ts +1 -1
  28. package/dist/commands/register.d.ts.map +1 -1
  29. package/dist/commands/register.js +35 -7
  30. package/dist/commands/register.js.map +1 -1
  31. package/dist/commands/rotate.js +4 -4
  32. package/dist/commands/rotate.js.map +1 -1
  33. package/dist/commands/start.js +3 -3
  34. package/dist/commands/start.js.map +1 -1
  35. package/dist/commands/status.js +1 -1
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/commands/verify.js +6 -6
  38. package/dist/commands/verify.js.map +1 -1
  39. package/dist/dco.d.ts +4 -0
  40. package/dist/dco.d.ts.map +1 -0
  41. package/dist/dco.js +21 -0
  42. package/dist/dco.js.map +1 -0
  43. package/dist/index.js +18 -15
  44. package/dist/utils/checkpoint-api.d.ts +86 -0
  45. package/dist/utils/checkpoint-api.d.ts.map +1 -0
  46. package/dist/utils/checkpoint-api.js +196 -0
  47. package/dist/utils/checkpoint-api.js.map +1 -0
  48. package/dist/utils/checkpoint-env.d.ts +23 -0
  49. package/dist/utils/checkpoint-env.d.ts.map +1 -0
  50. package/dist/utils/checkpoint-env.js +59 -0
  51. package/dist/utils/checkpoint-env.js.map +1 -0
  52. package/dist/utils/checkpoint-scaffold.d.ts +61 -0
  53. package/dist/utils/checkpoint-scaffold.d.ts.map +1 -0
  54. package/dist/utils/checkpoint-scaffold.js +268 -0
  55. package/dist/utils/checkpoint-scaffold.js.map +1 -0
  56. package/dist/utils/cli-kit/browser.d.ts +24 -0
  57. package/dist/utils/cli-kit/browser.d.ts.map +1 -0
  58. package/dist/utils/cli-kit/browser.js +58 -0
  59. package/dist/utils/cli-kit/browser.js.map +1 -0
  60. package/dist/utils/cli-kit/claim-polling.d.ts +18 -0
  61. package/dist/utils/cli-kit/claim-polling.d.ts.map +1 -0
  62. package/dist/utils/{claim-polling.js → cli-kit/claim-polling.js} +19 -26
  63. package/dist/utils/cli-kit/claim-polling.js.map +1 -0
  64. package/dist/utils/cli-kit/diagnostics.d.ts +9 -0
  65. package/dist/utils/cli-kit/diagnostics.d.ts.map +1 -0
  66. package/dist/utils/cli-kit/diagnostics.js +11 -0
  67. package/dist/utils/cli-kit/diagnostics.js.map +1 -0
  68. package/dist/utils/cli-kit/fs-safe.d.ts +14 -0
  69. package/dist/utils/cli-kit/fs-safe.d.ts.map +1 -0
  70. package/dist/utils/cli-kit/fs-safe.js +20 -0
  71. package/dist/utils/cli-kit/fs-safe.js.map +1 -0
  72. package/dist/utils/cli-kit/index.d.ts +15 -0
  73. package/dist/utils/cli-kit/index.d.ts.map +1 -0
  74. package/dist/utils/cli-kit/index.js +10 -0
  75. package/dist/utils/cli-kit/index.js.map +1 -0
  76. package/dist/utils/cli-kit/json-file.d.ts +21 -0
  77. package/dist/utils/cli-kit/json-file.d.ts.map +1 -0
  78. package/dist/utils/cli-kit/json-file.js +51 -0
  79. package/dist/utils/cli-kit/json-file.js.map +1 -0
  80. package/dist/utils/cli-kit/marker-block.d.ts +26 -0
  81. package/dist/utils/cli-kit/marker-block.d.ts.map +1 -0
  82. package/dist/utils/cli-kit/marker-block.js +61 -0
  83. package/dist/utils/cli-kit/marker-block.js.map +1 -0
  84. package/dist/utils/cli-kit/paths.d.ts +15 -0
  85. package/dist/utils/cli-kit/paths.d.ts.map +1 -0
  86. package/dist/utils/cli-kit/paths.js +21 -0
  87. package/dist/utils/cli-kit/paths.js.map +1 -0
  88. package/dist/utils/cli-kit/secure-storage.d.ts +14 -0
  89. package/dist/utils/cli-kit/secure-storage.d.ts.map +1 -0
  90. package/dist/utils/cli-kit/secure-storage.js +20 -0
  91. package/dist/utils/cli-kit/secure-storage.js.map +1 -0
  92. package/dist/utils/cli-kit/step-report.d.ts +16 -0
  93. package/dist/utils/cli-kit/step-report.d.ts.map +1 -0
  94. package/dist/utils/cli-kit/step-report.js +36 -0
  95. package/dist/utils/cli-kit/step-report.js.map +1 -0
  96. package/dist/utils/dco/agent-metadata.d.ts +1 -1
  97. package/dist/utils/dco/agent-metadata.js +1 -1
  98. package/dist/utils/dco/agent-metadata.js.map +1 -1
  99. package/dist/utils/dco/claude-config.d.ts +18 -8
  100. package/dist/utils/dco/claude-config.d.ts.map +1 -1
  101. package/dist/utils/dco/claude-config.js +33 -79
  102. package/dist/utils/dco/claude-config.js.map +1 -1
  103. package/dist/utils/dco/dispatch.d.ts +59 -0
  104. package/dist/utils/dco/dispatch.d.ts.map +1 -0
  105. package/dist/utils/dco/dispatch.js +332 -0
  106. package/dist/utils/dco/dispatch.js.map +1 -0
  107. package/dist/utils/dco/git-config.d.ts.map +1 -1
  108. package/dist/utils/dco/git-config.js +5 -3
  109. package/dist/utils/dco/git-config.js.map +1 -1
  110. package/dist/utils/dco/github.d.ts +8 -0
  111. package/dist/utils/dco/github.d.ts.map +1 -1
  112. package/dist/utils/dco/github.js +11 -0
  113. package/dist/utils/dco/github.js.map +1 -1
  114. package/dist/utils/dco/paths.d.ts +1 -8
  115. package/dist/utils/dco/paths.d.ts.map +1 -1
  116. package/dist/utils/dco/paths.js +3 -12
  117. package/dist/utils/dco/paths.js.map +1 -1
  118. package/dist/utils/dco/render.d.ts +1 -1
  119. package/dist/utils/dco/render.d.ts.map +1 -1
  120. package/dist/utils/dco/render.js +4 -21
  121. package/dist/utils/dco/render.js.map +1 -1
  122. package/dist/utils/dco/resolve-agent.d.ts +54 -0
  123. package/dist/utils/dco/resolve-agent.d.ts.map +1 -0
  124. package/dist/utils/dco/resolve-agent.js +164 -0
  125. package/dist/utils/dco/resolve-agent.js.map +1 -0
  126. package/dist/utils/dco/setup.d.ts +10 -7
  127. package/dist/utils/dco/setup.d.ts.map +1 -1
  128. package/dist/utils/dco/setup.js +304 -202
  129. package/dist/utils/dco/setup.js.map +1 -1
  130. package/dist/utils/dco/ssh-key.d.ts +13 -0
  131. package/dist/utils/dco/ssh-key.d.ts.map +1 -1
  132. package/dist/utils/dco/ssh-key.js +21 -0
  133. package/dist/utils/dco/ssh-key.js.map +1 -1
  134. package/dist/utils/dco/status.d.ts +1 -1
  135. package/dist/utils/dco/status.d.ts.map +1 -1
  136. package/dist/utils/dco/status.js +3 -3
  137. package/dist/utils/dco/status.js.map +1 -1
  138. package/dist/utils/dco/verify.d.ts +114 -0
  139. package/dist/utils/dco/verify.d.ts.map +1 -0
  140. package/dist/utils/dco/verify.js +383 -0
  141. package/dist/utils/dco/verify.js.map +1 -0
  142. package/dist/utils/gitignore.d.ts +17 -0
  143. package/dist/utils/gitignore.d.ts.map +1 -0
  144. package/dist/utils/gitignore.js +40 -0
  145. package/dist/utils/gitignore.js.map +1 -0
  146. package/dist/utils/identity-heal.d.ts +16 -0
  147. package/dist/utils/identity-heal.d.ts.map +1 -0
  148. package/dist/utils/identity-heal.js +37 -0
  149. package/dist/utils/identity-heal.js.map +1 -0
  150. package/dist/utils/poll.d.ts +50 -0
  151. package/dist/utils/poll.d.ts.map +1 -0
  152. package/dist/utils/poll.js +73 -0
  153. package/dist/utils/poll.js.map +1 -0
  154. package/package.json +4 -3
  155. package/dist/utils/claim-polling.d.ts +0 -21
  156. package/dist/utils/claim-polling.d.ts.map +0 -1
  157. package/dist/utils/claim-polling.js.map +0 -1
  158. package/dist/utils/dco/fs-safe.d.ts +0 -14
  159. package/dist/utils/dco/fs-safe.d.ts.map +0 -1
  160. package/dist/utils/dco/fs-safe.js +0 -20
  161. package/dist/utils/dco/fs-safe.js.map +0 -1
@@ -1,14 +1,15 @@
1
1
  import { execFile } from "child_process";
2
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "fs";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { promisify } from "util";
6
+ import { ensureSecureDir, writeSecureFile, } from "../cli-kit/index.js";
6
7
  import { readAgentMetadata, writeAgentMetadata } from "./agent-metadata.js";
7
8
  import { buildAttributionBlock, updateClaudeSettings, upsertClaudeMdBlock, } from "./claude-config.js";
8
9
  import { detectSigningConflicts, getGitConfigValue, installTrailerHook, isGitRepo, setGitConfig, } from "./git-config.js";
9
10
  import { GhNotInstalledError, GhScopeError, getGhUser, githubNoreplyEmail, isGhAuthenticated, isGhInstalled, listSigningKeys, refreshAuthScope, signingKeyAlreadyUploaded, uploadSigningKey, SCOPE_REFRESH_COMMAND, } from "./github.js";
10
11
  import { isValidSlug, keysDir, signingKeyPath } from "./paths.js";
11
- import { convertEd25519ToSshKey } from "./ssh-key.js";
12
+ import { convertEd25519ToSshKey, sshFingerprintFromPublicKeyLine, } from "./ssh-key.js";
12
13
  const execFileAsync = promisify(execFile);
13
14
  function slugify(value) {
14
15
  return value
@@ -23,11 +24,14 @@ function slugify(value) {
23
24
  */
24
25
  function sanitizeIdentityValue(value) {
25
26
  // eslint-disable-next-line no-control-regex
26
- return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
27
+ return value
28
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
27
31
  }
28
32
  /**
29
33
  * Distinguishes a missing identity from a present-but-unparseable one. The
30
- * two need different advice: "run mcpi register" after a corrupt read would
34
+ * two need different advice: "run kya-os register" after a corrupt read would
31
35
  * overwrite a potentially recoverable key with a brand new identity.
32
36
  */
33
37
  function readIdentityFile(cwd) {
@@ -110,6 +114,35 @@ async function promptForAgentName() {
110
114
  ]);
111
115
  return { name: name.trim(), slug };
112
116
  }
117
+ /**
118
+ * Resolve the human committer's name/email: prefer git config, and only fall
119
+ * back to the authenticated GitHub profile for whichever field git is
120
+ * missing (never both at once, so a configured name survives a missing
121
+ * email or vice versa). Shared by both identity-resolution paths in
122
+ * `runDcoSetup`: this half of Step 2 is identical whether the agent side
123
+ * came from `.mcpi/` or from an explicit agent slug.
124
+ */
125
+ async function resolveHumanIdentity(cwd) {
126
+ const gitName = await getGitConfigValue("user.name", cwd);
127
+ const gitEmail = await getGitConfigValue("user.email", cwd);
128
+ let ghUser = null;
129
+ if ((!gitName || !gitEmail) &&
130
+ (await isGhInstalled()) &&
131
+ (await isGhAuthenticated())) {
132
+ ghUser = await getGhUser();
133
+ }
134
+ // Use || (not ??) so an explicitly-empty git value is treated as missing,
135
+ // consistent with the truthiness gate above that decides to fetch gh.
136
+ const resolvedName = gitName || (ghUser ? ghUser.name || ghUser.login : undefined);
137
+ const resolvedEmail = gitEmail || (ghUser ? githubNoreplyEmail(ghUser) : undefined);
138
+ if (!resolvedName || !resolvedEmail) {
139
+ throw new Error("Could not resolve your git identity. Set git config user.name and user.email, or authenticate gh.");
140
+ }
141
+ return {
142
+ name: sanitizeIdentityValue(resolvedName),
143
+ email: sanitizeIdentityValue(resolvedEmail),
144
+ };
145
+ }
113
146
  /**
114
147
  * Orchestrates the full DCO/attribution setup. Steps run in order; the three
115
148
  * foundation steps (preflight, identities, key material) abort the run when
@@ -160,217 +193,289 @@ export async function runDcoSetup(options = {}) {
160
193
  ...laterSteps,
161
194
  ]);
162
195
  }
163
- const identityRead = readIdentityFile(cwd);
164
- const identity = identityRead.status === "ok" ? identityRead.identity : null;
165
- let preflightFailure = null;
166
- if (identityRead.status === "corrupt") {
167
- preflightFailure =
168
- ".mcpi/identity.json exists but could not be parsed. Do NOT re-run `mcpi register` (it would overwrite the identity); inspect or restore the file first.";
169
- }
170
- else if (!identity?.privateKey || !identity?.did) {
171
- preflightFailure =
172
- identityRead.status === "ok"
173
- ? ".mcpi/identity.json is missing required fields (did, privateKey). Inspect or restore the file before continuing."
174
- : "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`.";
175
- }
176
- else if (await isIdentityTrackedByGit(cwd)) {
177
- preflightFailure =
178
- ".mcpi/identity.json is tracked by git. A committed identity may have been shipped by the repo author; refusing to configure signing with it. Remove it from git history and generate your own identity with `mcpi register`.";
179
- }
180
- if (preflightFailure || !identity?.privateKey || !identity?.did) {
181
- steps.push({
182
- id: "preflight",
183
- title: "Preflight checks",
184
- status: "failed",
185
- detail: preflightFailure ??
186
- "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`.",
187
- });
188
- return abort([
189
- { id: "identities", title: "Resolve agent and human identity" },
190
- { id: "key-material", title: "Write SSH signing key" },
191
- ...laterSteps,
192
- ]);
193
- }
194
- steps.push({ id: "preflight", title: "Preflight checks", status: "done" });
195
- // Step 2: resolve identities
196
+ // Steps 1-3 (preflight, identity resolution, key material) branch here and
197
+ // rejoin at Step 4: an explicit agent slug wires signing for an EXISTING
198
+ // registered agent and never reads, requires, or writes `.mcpi/` — the
199
+ // identity is derived from the slug alone (the same derivation
200
+ // `dco dispatch <slug>` uses) and the key must already exist on disk,
201
+ // never generated or converted here. `agent`/`human`/`keyMaterial`/
202
+ // `privateKeyPath`/`publicKeyPath` are populated by whichever branch runs;
203
+ // both either abort the whole run or leave all five set by the time
204
+ // Step 4 reads them.
196
205
  let agent;
197
206
  let human;
198
- try {
199
- const metadata = readAgentMetadata(cwd);
200
- if (metadata?.did && metadata.did !== identity.did) {
201
- throw new Error(`.mcpi/agent.json is for a different agent (${metadata.did}) than .mcpi/identity.json (${identity.did}). Fix or remove agent.json before continuing.`);
202
- }
203
- // Only trust agent.json for attribution when it is cryptographically bound
204
- // to this identity (its did matches identity.json). Unbound metadata (no
205
- // did, e.g. planted in an untrusted repo) is ignored so it cannot spoof the
206
- // agent name/slug/url; the resolved values are then persisted with the real
207
- // did, healing the binding for later runs.
208
- const boundMetadata = metadata && metadata.did === identity.did ? metadata : null;
209
- let name = boundMetadata?.name ?? process.env.AGENT_NAME;
210
- let slug = boundMetadata?.slug ?? process.env.AGENT_SLUG;
211
- if ((!name || !slug) && interactive && !options.dryRun) {
212
- const prompted = await promptForAgentName();
213
- name = name ?? prompted.name;
214
- slug = slug ?? prompted.slug;
215
- }
216
- if (!name || !slug) {
217
- throw new Error("Agent name/slug unknown. Run `mcpi register`, or set AGENT_NAME and AGENT_SLUG.");
218
- }
219
- // The interactive prompt validates its own input; values arriving from
220
- // agent.json or the environment must pass the same shape check because
221
- // the slug becomes a filename under the keys directory.
222
- if (!isValidSlug(slug)) {
223
- throw new Error(`Invalid agent slug "${slug}": must be lowercase letters, numbers, and dashes.`);
224
- }
225
- name = sanitizeIdentityValue(name);
226
- const metadataUrl = boundMetadata?.url
227
- ? sanitizeIdentityValue(boundMetadata.url)
228
- : undefined;
207
+ let keyMaterial;
208
+ let privateKeyPath;
209
+ let publicKeyPath;
210
+ if (options.agent) {
211
+ if (!isValidSlug(options.agent)) {
212
+ steps.push({
213
+ id: "preflight",
214
+ title: "Preflight checks",
215
+ status: "failed",
216
+ detail: `Invalid agent slug "${options.agent}": must be lowercase letters, numbers, and dashes.`,
217
+ });
218
+ return abort([
219
+ { id: "identities", title: "Resolve agent and human identity" },
220
+ { id: "key-material", title: "Write SSH signing key" },
221
+ ...laterSteps,
222
+ ]);
223
+ }
224
+ steps.push({ id: "preflight", title: "Preflight checks", status: "done" });
225
+ const slug = options.agent;
229
226
  agent = {
230
- did: identity.did,
231
- name,
227
+ did: `did:web:knowthat.ai:agents:${slug}`,
228
+ name: slug,
232
229
  slug,
233
- url: metadataUrl && isTrustedAgentUrl(metadataUrl)
234
- ? metadataUrl
235
- : `https://knowthat.ai/agents/${slug}`,
230
+ url: `https://knowthat.ai/agents/${slug}`,
236
231
  email: `${slug}@agents.knowthat.ai`,
237
232
  };
238
- // Resolve the human name and email INDEPENDENTLY, each preferring the
239
- // configured git value and only falling back to gh for the field that is
240
- // missing. Resolving them together would let a missing email drop a
241
- // configured name (or vice versa) in favor of the GitHub profile.
242
- // Reading gh here is unrelated to the key upload, so it stays available
243
- // even under --skip-github (which only suppresses the upload).
244
- const gitName = await getGitConfigValue("user.name", cwd);
245
- const gitEmail = await getGitConfigValue("user.email", cwd);
246
- let ghUser = null;
247
- if ((!gitName || !gitEmail) &&
248
- (await isGhInstalled()) &&
249
- (await isGhAuthenticated())) {
250
- ghUser = await getGhUser();
251
- }
252
- // Use || (not ??) so an explicitly-empty git value is treated as missing,
253
- // consistent with the truthiness gate above that decides to fetch gh.
254
- const resolvedName = gitName || (ghUser ? ghUser.name || ghUser.login : undefined);
255
- const resolvedEmail = gitEmail || (ghUser ? githubNoreplyEmail(ghUser) : undefined);
256
- if (!resolvedName || !resolvedEmail) {
257
- throw new Error("Could not resolve your git identity. Set git config user.name and user.email, or authenticate gh.");
258
- }
259
- human = {
260
- name: sanitizeIdentityValue(resolvedName),
261
- email: sanitizeIdentityValue(resolvedEmail),
262
- };
263
- result.agent = agent;
264
- result.human = human;
265
- // Persist resolved metadata when the bound record was incomplete (prompted,
266
- // from env, or unbound), so a later non-interactive run resolves the agent
267
- // without prompting AND the did binding is written for future runs.
268
- // Best-effort: a cache-write failure must not fail identity resolution.
269
- if (!options.dryRun &&
270
- (!boundMetadata?.name || !boundMetadata?.slug || !boundMetadata?.did)) {
271
- try {
272
- writeAgentMetadata(cwd, {
273
- name: agent.name,
274
- slug: agent.slug,
275
- url: agent.url,
276
- did: agent.did,
277
- });
278
- }
279
- catch (error) {
280
- result.metadataPersistWarning =
281
- error instanceof Error ? error.message : String(error);
282
- }
233
+ try {
234
+ human = await resolveHumanIdentity(cwd);
235
+ result.agent = agent;
236
+ result.human = human;
237
+ steps.push({
238
+ id: "identities",
239
+ title: "Resolve agent and human identity",
240
+ status: "done",
241
+ detail: `agent=${agent.name} (${agent.slug}), human=${human.name} <${human.email}>`,
242
+ });
283
243
  }
284
- steps.push({
285
- id: "identities",
286
- title: "Resolve agent and human identity",
287
- status: "done",
288
- detail: `agent=${agent.name} (${agent.slug}), human=${human.name} <${human.email}>`,
289
- });
290
- }
291
- catch (error) {
292
- steps.push({
293
- id: "identities",
294
- title: "Resolve agent and human identity",
295
- status: "failed",
296
- detail: error instanceof Error ? error.message : String(error),
297
- });
298
- return abort([
299
- { id: "key-material", title: "Write SSH signing key" },
300
- ...laterSteps,
301
- ]);
302
- }
303
- // Step 3: key material
304
- let keyMaterial;
305
- // Slug was validated in the identities step; signingKeyPath re-checks and
306
- // always yields a path inside the keys directory.
307
- const privateKeyPath = signingKeyPath(agent.slug);
308
- const publicKeyPath = `${privateKeyPath}.pub`;
309
- // Only the write branch below sets this; a failure before it (e.g. a bad
310
- // identity.json that fails conversion) must NOT delete pre-existing keys.
311
- let startedKeyWrite = false;
312
- try {
313
- keyMaterial = convertEd25519ToSshKey(identity.privateKey, `mcpi:${agent.slug}`, { expectedPublicKeyBase64: identity.publicKey });
314
- result.privateKeyPath = privateKeyPath;
315
- result.fingerprint = keyMaterial.fingerprint;
316
- // Compare BOTH files against the (now deterministic) expected material, so
317
- // a corrupt/truncated private key with an intact .pub is detected and
318
- // rewritten rather than left in place to fail signing forever.
319
- const privUpToDate = existsSync(privateKeyPath) &&
320
- readFileSync(privateKeyPath, "utf-8") === keyMaterial.privateKeyPem;
321
- const pubUpToDate = existsSync(publicKeyPath) &&
322
- readFileSync(publicKeyPath, "utf-8").trim() === keyMaterial.publicKeyLine;
323
- if (privUpToDate && pubUpToDate) {
244
+ catch (error) {
245
+ steps.push({
246
+ id: "identities",
247
+ title: "Resolve agent and human identity",
248
+ status: "failed",
249
+ detail: error instanceof Error ? error.message : String(error),
250
+ });
251
+ return abort([
252
+ { id: "key-material", title: "Write SSH signing key" },
253
+ ...laterSteps,
254
+ ]);
255
+ }
256
+ // Slug was validated above; signingKeyPath re-checks and always yields a
257
+ // path inside the keys directory.
258
+ privateKeyPath = signingKeyPath(agent.slug);
259
+ publicKeyPath = `${privateKeyPath}.pub`;
260
+ if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
324
261
  steps.push({
325
262
  id: "key-material",
326
263
  title: "Write SSH signing key",
327
- status: "done",
328
- detail: `Already present at ${privateKeyPath}`,
264
+ status: "failed",
265
+ detail: `No key for agent "${agent.slug}" at ${privateKeyPath}; run setup in the repo that registered it, or check the slug.`,
329
266
  });
267
+ return abort(laterSteps);
330
268
  }
331
- else if (options.dryRun) {
269
+ try {
270
+ const publicKeyLine = readFileSync(publicKeyPath, "utf-8").trim();
271
+ const fingerprint = sshFingerprintFromPublicKeyLine(publicKeyLine);
272
+ keyMaterial = { publicKeyLine, fingerprint };
273
+ result.privateKeyPath = privateKeyPath;
274
+ result.fingerprint = fingerprint;
332
275
  steps.push({
333
276
  id: "key-material",
334
277
  title: "Write SSH signing key",
335
- status: "would-change",
336
- detail: `Would write ${privateKeyPath} and ${publicKeyPath}`,
278
+ status: "done",
279
+ detail: `Using existing key at ${privateKeyPath} (${fingerprint})`,
337
280
  });
338
281
  }
339
- else {
340
- mkdirSync(keysDir(), { recursive: true, mode: 0o700 });
341
- startedKeyWrite = true;
342
- // mode on write avoids any window where the key exists with loose
343
- // permissions; the chmod covers overwrites of a pre-existing file,
344
- // where the mode option is ignored.
345
- writeFileSync(privateKeyPath, keyMaterial.privateKeyPem, { mode: 0o600 });
346
- chmodSync(privateKeyPath, 0o600);
347
- writeFileSync(publicKeyPath, keyMaterial.publicKeyLine + "\n", {
348
- mode: 0o644,
349
- });
350
- chmodSync(publicKeyPath, 0o644);
282
+ catch (error) {
351
283
  steps.push({
352
284
  id: "key-material",
353
285
  title: "Write SSH signing key",
354
- status: "done",
355
- detail: `${privateKeyPath} (${keyMaterial.fingerprint})`,
286
+ status: "failed",
287
+ detail: error instanceof Error ? error.message : String(error),
356
288
  });
289
+ return abort(laterSteps);
357
290
  }
358
291
  }
359
- catch (error) {
360
- // Clean up only a key pair THIS run started writing (a half-written pair);
361
- // a failure before the write (e.g. bad identity.json) must leave any
362
- // pre-existing valid keys intact so signing is not silently broken.
363
- if (!options.dryRun && startedKeyWrite) {
364
- rmSync(privateKeyPath, { force: true });
365
- rmSync(publicKeyPath, { force: true });
292
+ else {
293
+ const identityRead = readIdentityFile(cwd);
294
+ const identity = identityRead.status === "ok" ? identityRead.identity : null;
295
+ let preflightFailure = null;
296
+ if (identityRead.status === "corrupt") {
297
+ preflightFailure =
298
+ ".mcpi/identity.json exists but could not be parsed. Do NOT re-run `kya-os register` (it would overwrite the identity); inspect or restore the file first.";
299
+ }
300
+ else if (!identity?.privateKey || !identity?.did) {
301
+ preflightFailure =
302
+ identityRead.status === "ok"
303
+ ? ".mcpi/identity.json is missing required fields (did, privateKey). Inspect or restore the file before continuing."
304
+ : "No agent identity found in .mcpi/identity.json. Run `kya-os register` first, then re-run `dco setup`.";
305
+ }
306
+ else if (await isIdentityTrackedByGit(cwd)) {
307
+ preflightFailure =
308
+ ".mcpi/identity.json is tracked by git. A committed identity may have been shipped by the repo author; refusing to configure signing with it. Remove it from git history and generate your own identity with `kya-os register`.";
309
+ }
310
+ if (preflightFailure || !identity?.privateKey || !identity?.did) {
311
+ steps.push({
312
+ id: "preflight",
313
+ title: "Preflight checks",
314
+ status: "failed",
315
+ detail: preflightFailure ??
316
+ "No agent identity found in .mcpi/identity.json. Run `kya-os register` first, then re-run `dco setup`.",
317
+ });
318
+ return abort([
319
+ { id: "identities", title: "Resolve agent and human identity" },
320
+ { id: "key-material", title: "Write SSH signing key" },
321
+ ...laterSteps,
322
+ ]);
323
+ }
324
+ steps.push({ id: "preflight", title: "Preflight checks", status: "done" });
325
+ try {
326
+ const metadata = readAgentMetadata(cwd);
327
+ if (metadata?.did && metadata.did !== identity.did) {
328
+ throw new Error(`.mcpi/agent.json is for a different agent (${metadata.did}) than .mcpi/identity.json (${identity.did}). Fix or remove agent.json before continuing.`);
329
+ }
330
+ // Only trust agent.json for attribution when it is cryptographically bound
331
+ // to this identity (its did matches identity.json). Unbound metadata (no
332
+ // did, e.g. planted in an untrusted repo) is ignored so it cannot spoof the
333
+ // agent name/slug/url; the resolved values are then persisted with the real
334
+ // did, healing the binding for later runs.
335
+ const boundMetadata = metadata && metadata.did === identity.did ? metadata : null;
336
+ let name = boundMetadata?.name ?? process.env.AGENT_NAME;
337
+ let slug = boundMetadata?.slug ?? process.env.AGENT_SLUG;
338
+ if ((!name || !slug) && interactive && !options.dryRun) {
339
+ const prompted = await promptForAgentName();
340
+ name = name ?? prompted.name;
341
+ slug = slug ?? prompted.slug;
342
+ }
343
+ if (!name || !slug) {
344
+ throw new Error("Agent name/slug unknown. Run `kya-os register`, or set AGENT_NAME and AGENT_SLUG.");
345
+ }
346
+ // The interactive prompt validates its own input; values arriving from
347
+ // agent.json or the environment must pass the same shape check because
348
+ // the slug becomes a filename under the keys directory.
349
+ if (!isValidSlug(slug)) {
350
+ throw new Error(`Invalid agent slug "${slug}": must be lowercase letters, numbers, and dashes.`);
351
+ }
352
+ name = sanitizeIdentityValue(name);
353
+ const metadataUrl = boundMetadata?.url
354
+ ? sanitizeIdentityValue(boundMetadata.url)
355
+ : undefined;
356
+ agent = {
357
+ did: identity.did,
358
+ name,
359
+ slug,
360
+ url: metadataUrl && isTrustedAgentUrl(metadataUrl)
361
+ ? metadataUrl
362
+ : `https://knowthat.ai/agents/${slug}`,
363
+ email: `${slug}@agents.knowthat.ai`,
364
+ };
365
+ // Resolve the human name and email INDEPENDENTLY, each preferring the
366
+ // configured git value and only falling back to gh for the field that is
367
+ // missing. Resolving them together would let a missing email drop a
368
+ // configured name (or vice versa) in favor of the GitHub profile.
369
+ // Reading gh here is unrelated to the key upload, so it stays available
370
+ // even under --skip-github (which only suppresses the upload).
371
+ human = await resolveHumanIdentity(cwd);
372
+ result.agent = agent;
373
+ result.human = human;
374
+ // Persist resolved metadata when the bound record was incomplete (prompted,
375
+ // from env, or unbound), so a later non-interactive run resolves the agent
376
+ // without prompting AND the did binding is written for future runs.
377
+ // Best-effort: a cache-write failure must not fail identity resolution.
378
+ if (!options.dryRun &&
379
+ (!boundMetadata?.name || !boundMetadata?.slug || !boundMetadata?.did)) {
380
+ try {
381
+ writeAgentMetadata(cwd, {
382
+ name: agent.name,
383
+ slug: agent.slug,
384
+ url: agent.url,
385
+ did: agent.did,
386
+ });
387
+ }
388
+ catch (error) {
389
+ result.metadataPersistWarning =
390
+ error instanceof Error ? error.message : String(error);
391
+ }
392
+ }
393
+ steps.push({
394
+ id: "identities",
395
+ title: "Resolve agent and human identity",
396
+ status: "done",
397
+ detail: `agent=${agent.name} (${agent.slug}), human=${human.name} <${human.email}>`,
398
+ });
399
+ }
400
+ catch (error) {
401
+ steps.push({
402
+ id: "identities",
403
+ title: "Resolve agent and human identity",
404
+ status: "failed",
405
+ detail: error instanceof Error ? error.message : String(error),
406
+ });
407
+ return abort([
408
+ { id: "key-material", title: "Write SSH signing key" },
409
+ ...laterSteps,
410
+ ]);
411
+ }
412
+ // Slug was validated in the identities step; signingKeyPath re-checks and
413
+ // always yields a path inside the keys directory.
414
+ privateKeyPath = signingKeyPath(agent.slug);
415
+ publicKeyPath = `${privateKeyPath}.pub`;
416
+ // Only the write branch below sets this; a failure before it (e.g. a bad
417
+ // identity.json that fails conversion) must NOT delete pre-existing keys.
418
+ let startedKeyWrite = false;
419
+ try {
420
+ const converted = convertEd25519ToSshKey(identity.privateKey, `mcpi:${agent.slug}`, { expectedPublicKeyBase64: identity.publicKey });
421
+ keyMaterial = converted;
422
+ result.privateKeyPath = privateKeyPath;
423
+ result.fingerprint = converted.fingerprint;
424
+ // Compare BOTH files against the (now deterministic) expected material, so
425
+ // a corrupt/truncated private key with an intact .pub is detected and
426
+ // rewritten rather than left in place to fail signing forever.
427
+ const privUpToDate = existsSync(privateKeyPath) &&
428
+ readFileSync(privateKeyPath, "utf-8") === converted.privateKeyPem;
429
+ const pubUpToDate = existsSync(publicKeyPath) &&
430
+ readFileSync(publicKeyPath, "utf-8").trim() === converted.publicKeyLine;
431
+ if (privUpToDate && pubUpToDate) {
432
+ steps.push({
433
+ id: "key-material",
434
+ title: "Write SSH signing key",
435
+ status: "done",
436
+ detail: `Already present at ${privateKeyPath}`,
437
+ });
438
+ }
439
+ else if (options.dryRun) {
440
+ steps.push({
441
+ id: "key-material",
442
+ title: "Write SSH signing key",
443
+ status: "would-change",
444
+ detail: `Would write ${privateKeyPath} and ${publicKeyPath}`,
445
+ });
446
+ }
447
+ else {
448
+ ensureSecureDir(keysDir());
449
+ startedKeyWrite = true;
450
+ writeSecureFile(privateKeyPath, converted.privateKeyPem);
451
+ writeFileSync(publicKeyPath, converted.publicKeyLine + "\n", {
452
+ mode: 0o644,
453
+ });
454
+ chmodSync(publicKeyPath, 0o644);
455
+ steps.push({
456
+ id: "key-material",
457
+ title: "Write SSH signing key",
458
+ status: "done",
459
+ detail: `${privateKeyPath} (${converted.fingerprint})`,
460
+ });
461
+ }
462
+ }
463
+ catch (error) {
464
+ // Clean up only a key pair THIS run started writing (a half-written pair);
465
+ // a failure before the write (e.g. bad identity.json) must leave any
466
+ // pre-existing valid keys intact so signing is not silently broken.
467
+ if (!options.dryRun && startedKeyWrite) {
468
+ rmSync(privateKeyPath, { force: true });
469
+ rmSync(publicKeyPath, { force: true });
470
+ }
471
+ steps.push({
472
+ id: "key-material",
473
+ title: "Write SSH signing key",
474
+ status: "failed",
475
+ detail: error instanceof Error ? error.message : String(error),
476
+ });
477
+ return abort(laterSteps);
366
478
  }
367
- steps.push({
368
- id: "key-material",
369
- title: "Write SSH signing key",
370
- status: "failed",
371
- detail: error instanceof Error ? error.message : String(error),
372
- });
373
- return abort(laterSteps);
374
479
  }
375
480
  // Step 4: GitHub upload
376
481
  if (options.skipGithub) {
@@ -388,7 +493,7 @@ export async function runDcoSetup(options = {}) {
388
493
  id: "github-upload",
389
494
  title: "Upload signing key to GitHub",
390
495
  status: "action-required",
391
- detail: "GitHub CLI not found. Install from https://cli.github.com, run `gh auth login`, then re-run `mcpi dco setup`.",
496
+ detail: "GitHub CLI not found. Install from https://cli.github.com, run `gh auth login`, then re-run `dco setup`.",
392
497
  });
393
498
  }
394
499
  else if (!(await isGhAuthenticated())) {
@@ -396,7 +501,7 @@ export async function runDcoSetup(options = {}) {
396
501
  id: "github-upload",
397
502
  title: "Upload signing key to GitHub",
398
503
  status: "action-required",
399
- detail: "Not authenticated. Run `gh auth login`, then re-run `mcpi dco setup`.",
504
+ detail: "Not authenticated. Run `gh auth login`, then re-run `dco setup`.",
400
505
  });
401
506
  }
402
507
  else {
@@ -438,7 +543,7 @@ export async function runDcoSetup(options = {}) {
438
543
  id: "github-upload",
439
544
  title: "Upload signing key to GitHub",
440
545
  status: "action-required",
441
- detail: "Upload declined. Re-run `mcpi dco setup` when you are ready to upload the key.",
546
+ detail: "Upload declined. Re-run `dco setup` when you are ready to upload the key.",
442
547
  });
443
548
  }
444
549
  else {
@@ -458,7 +563,7 @@ export async function runDcoSetup(options = {}) {
458
563
  id: "github-upload",
459
564
  title: "Upload signing key to GitHub",
460
565
  status: "action-required",
461
- detail: `${error.message} Then re-run \`mcpi dco setup\`.`,
566
+ detail: `${error.message} Then re-run \`dco setup\`.`,
462
567
  });
463
568
  }
464
569
  else if (error instanceof GhNotInstalledError) {
@@ -626,12 +731,7 @@ export async function runDcoSetup(options = {}) {
626
731
  else {
627
732
  try {
628
733
  const block = buildAttributionBlock({
629
- agentName: agent.name,
630
- agentUrl: agent.url,
631
734
  includeTrailerInstructions: hookFallback,
632
- humanName: human.name,
633
- humanEmail: human.email,
634
- agentEmail: agent.email,
635
735
  });
636
736
  const md = upsertClaudeMdBlock(cwd, block, { dryRun: options.dryRun });
637
737
  const settings = updateClaudeSettings(cwd, { dryRun: options.dryRun });
@@ -680,7 +780,9 @@ export async function runDcoSetup(options = {}) {
680
780
  "Trailer instructions would be added to the CLAUDE.md attribution block instead.";
681
781
  }
682
782
  else {
683
- outcome = `Trailers were NOT added to CLAUDE.md (${options.skipClaude ? "--skip-claude" : "the Claude Code step did not complete"}); add Signed-off-by / Co-authored-by manually or re-run without --skip-claude.`;
783
+ outcome = `Trailers were NOT added to CLAUDE.md (${options.skipClaude
784
+ ? "--skip-claude"
785
+ : "the Claude Code step did not complete"}); add Signed-off-by / Co-authored-by manually or re-run without --skip-claude.`;
684
786
  }
685
787
  if (trailerStep) {
686
788
  trailerStep.detail = `${hookFallbackDetail} ${outcome}`;
@@ -701,7 +803,7 @@ export async function runDcoSetup(options = {}) {
701
803
  const verifyDir = mkdtempSync(join(tmpdir(), "mcpi-dco-verify-"));
702
804
  const payloadPath = join(verifyDir, "payload");
703
805
  try {
704
- writeFileSync(payloadPath, "mcpi dco verification payload\n");
806
+ writeFileSync(payloadPath, "dco verification payload\n");
705
807
  await execFileAsync("ssh-keygen", [
706
808
  "-Y",
707
809
  "sign",