@serviceme/devtools-core 2.0.5 → 2.0.6
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/dist/{device-BVcUUQ9t.mjs → device-Bn2NzajD.mjs} +2 -2
- package/dist/{device-BVcUUQ9t.mjs.map → device-Bn2NzajD.mjs.map} +1 -1
- package/dist/index.js +84 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +84 -27
- package/dist/index.mjs.map +1 -1
- package/dist/{submit-6zQPPrOJ.mjs → submit-Cg7wSa3x.mjs} +2 -2
- package/dist/{submit-6zQPPrOJ.mjs.map → submit-Cg7wSa3x.mjs.map} +1 -1
- package/dist/{toolbox-UG4dA2TI.mjs → toolbox-CbfxpNg-.mjs} +2 -2
- package/dist/{toolbox-UG4dA2TI.mjs.map → toolbox-CbfxpNg-.mjs.map} +1 -1
- package/dist/{userHome-DOMLiHy7.mjs → userHome-CcnAQPG1.mjs} +4 -2
- package/dist/userHome-CcnAQPG1.mjs.map +1 -0
- package/package.json +3 -3
- package/dist/device-uFAQxlNt.js +0 -874
- package/dist/device-uFAQxlNt.js.map +0 -1
- package/dist/submit-Dy0KTHzm.js +0 -98
- package/dist/submit-Dy0KTHzm.js.map +0 -1
- package/dist/toolbox-BV7vLGXZ.js +0 -614
- package/dist/toolbox-BV7vLGXZ.js.map +0 -1
- package/dist/userHome-CLlsXCvW.js +0 -518
- package/dist/userHome-CLlsXCvW.js.map +0 -1
- package/dist/userHome-DOMLiHy7.mjs.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { k as getDeviceJsonPath, q as getServicemeHome } from "./userHome-CcnAQPG1.mjs";
|
|
2
2
|
import * as fsp from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as os from "node:os";
|
|
@@ -790,4 +790,4 @@ function projectMetadata(stored) {
|
|
|
790
790
|
//#endregion
|
|
791
791
|
export { DeviceReenrollRequiresAuthError as a, deriveInstallationId as c, DeviceAuthHeaders as d, buildSignedHeaders as f, DEVICE_JSON_SCHEMA_VERSION as i, fingerprintSource as l, FsIdentityFileBackend as n, DeviceSecretVersionMismatchError as o, createDeviceRequestSignature as p, IdentityStore as r, Enroller as s, DeviceCore as t, randomInstallationId as u };
|
|
792
792
|
|
|
793
|
-
//# sourceMappingURL=device-
|
|
793
|
+
//# sourceMappingURL=device-Bn2NzajD.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"device-BVcUUQ9t.mjs","names":["delay"],"sources":["../src/device/deviceAuth.ts","../src/device/InstallationId.ts","../src/device/Enroller.ts","../src/device/types.ts","../src/device/IdentityStore.ts","../src/device/DeviceCore.ts"],"sourcesContent":["/**\n * deviceAuth — Device request signing helpers for device auth headers.\n *\n * **Boundary exception copy.** The single source of truth for the\n * `x-ms-device-*` header names + the signature algorithms now lives in\n * `@serviceme/devtools-shared` (`device-auth.ts`), consumed by the\n * extension signer and the server verifier. ADL-003 forbids\n * core → shared (and shared → core), so this module keeps a\n * byte-for-byte copy as the documented exception. Keep it in lock-step\n * with `packages/serviceme-shared/src/device-auth.ts`.\n *\n * See `docs/architecture/phase-5-device-header-spec.md` §5 for the wire\n * format. Legacy (v1) basis is `METHOD\\nPATH\\nTIMESTAMP\\nBODY\\nSECRET`\n * hashed with a secret-suffix SHA-256; v2 basis is\n * `METHOD\\nPATH?QUERY\\nTIMESTAMP\\nNONCE\\nBODY\\nSECRET` MACed with real\n * HMAC-SHA-256 keyed by the device secret and signed over the full\n * path-with-query. Both output lowercase hex.\n */\n\nimport { createHash, createHmac, randomUUID } from \"node:crypto\";\n\n/** Canonical header names — MUST match `@serviceme/devtools-shared`'s `DeviceAuthHeaders`. */\nexport const DeviceAuthHeaders = {\n\tdeviceId: \"x-ms-device-id\",\n\tdeviceSecret: \"x-ms-device-secret\",\n\tsignature: \"x-ms-device-signature\",\n\ttimestamp: \"x-ms-device-timestamp\",\n\tsecretVersion: \"x-ms-device-secret-version\",\n\t/** v2 only — algorithm self-declaration so the server can verify\n\t * both schemes during the legacy-client rollout window. */\n\tsigAlg: \"x-ms-device-sig-alg\",\n\t/** v2 only — one-time value folded into the signature basis and\n\t * checked against a server-side replay cache. */\n\tnonce: \"x-ms-device-nonce\",\n} as const;\n\n/** v2 signature algorithm identifier carried in `x-ms-device-sig-alg`. */\nexport const DEVICE_SIG_ALG_V2 = \"hmac-sha256-v2\";\n\nexport interface DeviceRequestSignatureParams {\n\tmethod: string;\n\tpath: string;\n\ttimestamp: number;\n\tbody: string;\n\tsecret: string;\n}\n\n/**\n * Legacy (v1) secret-suffix SHA-256 over the canonical basis. Retained\n * for backward compatibility with fielded verifiers/clients that\n * predate v2; new code should use `createDeviceRequestSignatureV2`.\n */\nexport function createDeviceRequestSignature(params: DeviceRequestSignatureParams): string {\n\tconst basis = [\n\t\tparams.method.toUpperCase(),\n\t\tparams.path,\n\t\tString(params.timestamp),\n\t\tparams.body,\n\t\tparams.secret,\n\t].join(\"\\n\");\n\treturn createHash(\"sha256\").update(basis).digest(\"hex\");\n}\n\nexport interface DeviceRequestSignatureV2Params {\n\tmethod: string;\n\t/** Full request path INCLUDING the query string. v2 signs the query\n\t * so GET parameters cannot be substituted or replayed independently\n\t * of the signed basis. */\n\tpath: string;\n\ttimestamp: number;\n\t/** One-time value; the server tracks seen nonces per device and\n\t * rejects replays within the timestamp window. */\n\tnonce: string;\n\tbody: string;\n\tsecret: string;\n}\n\n/**\n * Compute the v2 HMAC-SHA-256 hex digest over the canonical basis\n * (path-with-query + nonce).\n *\n * Server contract (`apps/server/src/lib/auth/device-signature-guard.ts`)\n * is line-for-line identical: same LF-joined basis, same lowercase hex\n * output. Any divergence breaks `device-signature-guard.test.ts`.\n */\nexport function createDeviceRequestSignatureV2(params: DeviceRequestSignatureV2Params): string {\n\tconst basis = [\n\t\tparams.method.toUpperCase(),\n\t\tparams.path,\n\t\tString(params.timestamp),\n\t\tparams.nonce,\n\t\tparams.body,\n\t\tparams.secret,\n\t].join(\"\\n\");\n\treturn createHmac(\"sha256\", params.secret).update(basis).digest(\"hex\");\n}\n\n/** Header map consumed by `fetch()` callers (CLI bridge, Extension). */\nexport interface DeviceSignedHeaders {\n\t[DeviceAuthHeaders.deviceId]: string;\n\t[DeviceAuthHeaders.deviceSecret]: string;\n\t[DeviceAuthHeaders.signature]: string;\n\t[DeviceAuthHeaders.timestamp]: string;\n\t[DeviceAuthHeaders.secretVersion]: string;\n\t[DeviceAuthHeaders.sigAlg]: string;\n\t[DeviceAuthHeaders.nonce]: string;\n}\n\nexport interface BuildSignedHeadersParams {\n\tmethod: string;\n\t/** Full request path INCLUDING the query string (v2 signs it). */\n\tpath: string;\n\tbody: string;\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\t/** Override for deterministic tests. */\n\ttimestamp?: number;\n\t/** Override for deterministic tests; defaults to a fresh random UUID. */\n\tnonce?: string;\n}\n\n/**\n * Build the v2 header map. The `path` parameter MUST include the exact\n * query string sent on the wire, and the `body` parameter MUST be the\n * exact byte sequence sent on the wire (no whitespace\n * re-canonicalization between client serialization and signature basis\n * construction).\n */\nexport function buildSignedHeaders(params: BuildSignedHeadersParams): DeviceSignedHeaders {\n\tconst timestamp = params.timestamp ?? Date.now();\n\tconst nonce = params.nonce ?? randomUUID();\n\tconst signature = createDeviceRequestSignatureV2({\n\t\tmethod: params.method,\n\t\tpath: params.path,\n\t\ttimestamp,\n\t\tnonce,\n\t\tbody: params.body,\n\t\tsecret: params.deviceSecret,\n\t});\n\treturn {\n\t\t[DeviceAuthHeaders.deviceId]: params.publicId,\n\t\t[DeviceAuthHeaders.deviceSecret]: params.deviceSecret,\n\t\t[DeviceAuthHeaders.signature]: signature,\n\t\t[DeviceAuthHeaders.timestamp]: String(timestamp),\n\t\t[DeviceAuthHeaders.secretVersion]: String(params.secretVersion),\n\t\t[DeviceAuthHeaders.sigAlg]: DEVICE_SIG_ALG_V2,\n\t\t[DeviceAuthHeaders.nonce]: nonce,\n\t};\n}\n","/**\n * InstallationId — Derive a stable per-machine identifier from\n * `os.hostname()` + `os.userInfo()`.\n *\n * Per `docs/architecture/phase-5-auth-device-toolbox.md § P5-2 (并入本文时对应 § P5-2 拆分)` B2,\n * `installationId` MUST survive Extension re-installs but vary across\n * machines. We compute a UUID v5-style hash over hostname + username +\n * platform so the result is:\n * - deterministic (same machine → same id)\n * - collision-resistant (SHA-256, 128-bit truncated)\n * - browser-safe (no PII survives — username never enters output)\n *\n * Note: this intentionally differs from `vscode.env.machineId`, which\n * is per-Extension-install and uses a different algorithm. The two\n * coexist: `installationId` is what gets sent to the server, while\n * `machineId` (raw `os.hostname()`) is for diagnostics.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `InstallationId.ts os.hostname() + os.userInfo() 哈希生成`\n * - 3.功能拆分.md B2 — installationId semantics\n */\n\nimport { createHash, randomUUID } from \"node:crypto\";\nimport * as os from \"node:os\";\n\n/** Hex-encoded SHA-256 input. Format: `<hostname>|<username>|<platform>|<nodeVersion>`. */\nfunction fingerprintMaterial(): string {\n\t// `os.userInfo()` is undefined-ish on Windows in some sandboxes; fall\n\t// back to a process env user, then to a constant placeholder. We never\n\t// emit the raw username to the caller — it only enters the hash.\n\tlet username = \"unknown\";\n\ttry {\n\t\tusername = os.userInfo().username;\n\t} catch {\n\t\tusername = process.env.USER ?? process.env.USERNAME ?? \"unknown\";\n\t}\n\treturn [\n\t\tos.hostname(),\n\t\tusername,\n\t\tos.platform(),\n\t\tos.arch(),\n\t\tprocess.versions.node ?? \"unknown\",\n\t].join(\"|\");\n}\n\n/**\n * Returns a deterministic installation id for the current machine.\n * Use this when you need an id that survives Extension reinstalls\n * but stays stable across restarts on the same machine.\n */\nexport function deriveInstallationId(): string {\n\tconst material = fingerprintMaterial();\n\tconst digest = createHash(\"sha256\").update(material).digest(\"hex\");\n\t// Take the first 32 hex chars (128 bits) and reformat as UUID v4-shape\n\t// so the output looks like a UUID to downstream consumers while\n\t// remaining a pure SHA-256 truncation.\n\treturn formatAsV4(digest.slice(0, 32));\n}\n\n/**\n * Returns a random installation id (UUID v4). Use this for fresh\n * installs when no fingerprint input is available (e.g. containerized\n * CI runners where `os.hostname()` is meaningless).\n */\nexport function randomInstallationId(): string {\n\treturn randomUUID();\n}\n\n/** SHA-256 fingerprint material exposed for tests + diagnostics. */\nexport function fingerprintSource(): string {\n\treturn fingerprintMaterial();\n}\n\nfunction formatAsV4(hex32: string): string {\n\t// Stamp version 4 + variant bits per RFC 4122 §4.4. The bits are\n\t// cosmetic — the underlying entropy is still SHA-256.\n\tconst chars = hex32.split(\"\");\n\t// Version nibble (position 12 in canonical UUID, index 13 of the 32-char string).\n\tconst versionIdx = 12;\n\tconst variantIdx = 16;\n\tconst versionChar = (parseInt(chars[versionIdx] ?? \"8\", 16) & 0x0) | 0x4;\n\tchars[versionIdx] = versionChar.toString(16);\n\t// Variant nibble: 10xx → first hex char of the 17th position.\n\tconst variantChar = (parseInt(chars[variantIdx] ?? \"8\", 16) & 0x3) | 0x8;\n\tchars[variantIdx] = variantChar.toString(16);\n\tconst formatted = chars.join(\"\");\n\treturn `${formatted.slice(0, 8)}-${formatted.slice(8, 12)}-${formatted.slice(12, 16)}-${formatted.slice(16, 20)}-${formatted.slice(20, 32)}`;\n}\n","/**\n * Enroller — State machine for `device.enroll` and `device.rotate-secret`.\n *\n * States per `2.需求澄清.md` §1.2:\n * anonymous → pending → claimed → expired\n *\n * - `anonymous` (initial): no device has ever enrolled. Server returns\n * a fresh `publicId` + secret.\n * - `pending`: enrollment HTTP call has been issued but the server\n * hasn't confirmed yet. In-flight state — never persisted.\n * - `claimed`: user has linked this device to their account (via\n * `/api/v1/devices/claim`). Sticky binding locks future re-enrolls\n * to the same `userId` (server-side matrix).\n * - `expired`: server returned a device-expiry error. Forces a fresh\n * enroll on next call.\n *\n * `--force` semantics: any non-anonymous state can be force-reset to\n * `anonymous` by wiping the local identity file. The next enroll will\n * be treated as a brand-new install by the server (no sticky binding).\n *\n * The Enroller is the **state machine**; the actual HTTP I/O is the\n * caller's responsibility (the `DeviceSyncClient` in Phase 5.4 wires\n * the server). This split keeps the Enroller unit-testable without\n * a live server.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `Enroller.ts anonymous → pending → claimed → expired`\n * - `2.需求澄清.md` §1.2 — binding-state machine\n */\n\nimport { randomBytes } from \"node:crypto\";\n\nimport type { DeviceBindingState, DeviceEnrollResult } from \"@serviceme/devtools-protocol\";\nimport type { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\n/** 32 bytes of HMAC secret material — matches the server's `device-registration.ts:73-80` generator. */\nconst SECRET_BYTES = 32;\n/** Server returns `publicId` as 32-char hex (16 bytes). Match the wire length. */\nconst PUBLIC_ID_BYTES = 16;\n\ntype RandomBytesFn = (size: number) => Buffer;\n\nconst defaultRandomBytes: RandomBytesFn = (size) => {\n\treturn randomBytes(size);\n};\n\nexport interface EnrollerOptions {\n\tidentityStore: IdentityStore;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\t/** Override the random source (tests). */\n\trandomBytes?: (size: number) => Buffer;\n\t/** Caller-supplied enroll HTTP function. Phase 5.4 wires the real one. */\n\tenrollRequest?: EnrollRequestFn;\n}\n\nexport type EnrollRequestFn = (input: {\n\tinstallationId: string;\n\tmachineId: string;\n\tplatform: string;\n\texisting: PersistedDeviceIdentity | null;\n\tforce: boolean;\n\trequireAuth: boolean;\n}) => Promise<EnrollResponse>;\n\nexport interface EnrollResponse {\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\tbindingState: DeviceBindingState;\n\texpiresAt?: string;\n}\n\n/** Sentinel error — re-enroll on a claimed device without auth. */\nexport class DeviceReenrollRequiresAuthError extends Error {\n\tconstructor(\n\t\tmessage = \"Re-enroll on a claimed device requires current device credentials or the bound user\"\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceReenrollRequiresAuthError\";\n\t}\n}\n\n/** Sentinel error — server returned a 410 / version-mismatch after rotation. */\nexport class DeviceSecretVersionMismatchError extends Error {\n\tconstructor(message = \"Device secret version mismatch — server has rotated past the local copy\") {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceSecretVersionMismatchError\";\n\t}\n}\n\nexport class Enroller {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly now: () => Date;\n\tprivate readonly random: (size: number) => Buffer;\n\tprivate readonly enrollRequest?: EnrollRequestFn;\n\tprivate inflight: Promise<DeviceEnrollResult> | null = null;\n\n\tconstructor(opts: EnrollerOptions) {\n\t\tthis.identity = opts.identityStore;\n\t\tthis.now = opts.now ?? (() => new Date());\n\t\tthis.random = opts.randomBytes ?? defaultRandomBytes;\n\t\tthis.enrollRequest = opts.enrollRequest;\n\t}\n\n\t/**\n\t * Read the current binding state without touching the disk.\n\t * Returns `anonymous` when no identity is stored.\n\t */\n\tasync currentState(): Promise<DeviceBindingState> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored?.bindingState ?? \"anonymous\";\n\t}\n\n\t/**\n\t * Drive the enrollment flow.\n\t *\n\t * @param force when true, drop the local identity and start fresh\n\t * (server treats this as a brand-new install).\n\t * @param requireAuth when true, refuse to silently re-enroll an\n\t * existing claimed device — throw\n\t * `DeviceReenrollRequiresAuthError` instead.\n\t */\n\t/**\n\t * Resolve when any in-flight enrollment completes. Returns immediately\n\t * when no enrollment is in progress. Allows callers (e.g. the extension's\n\t * `buildDeviceAuthHeaders`) to wait for a concurrent `syncDeviceInfo()`\n\t * enrollment before attempting to read the identity from the store.\n\t */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tif (this.inflight) {\n\t\t\tawait this.inflight;\n\t\t}\n\t}\n\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\t// Concurrency guard — multiple in-flight calls share the same promise.\n\t\tif (this.inflight) {\n\t\t\treturn this.inflight;\n\t\t}\n\t\tconst promise = this.runEnroll(opts);\n\t\tthis.inflight = promise;\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tif (this.inflight === promise) this.inflight = null;\n\t\t}\n\t}\n\n\t/** Test seam — surface the underlying identity store. */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers (e.g. the extension's `buildDeviceAuthHeaders`) to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.inflight !== null;\n\t}\n\n\tprivate async runEnroll(opts: {\n\t\tforce?: boolean;\n\t\trequireAuth?: boolean;\n\t}): Promise<DeviceEnrollResult> {\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tconst existing = opts.force ? null : current;\n\n\t\t\tif (!opts.force && current) {\n\t\t\t\tif (current.bindingState === \"expired\") {\n\t\t\t\t\t// Expired identities are forced to re-enroll as if they were new.\n\t\t\t\t} else if (\n\t\t\t\t\topts.requireAuth &&\n\t\t\t\t\t(current.bindingState === \"claimed\" || current.bindingState === \"pending\")\n\t\t\t\t) {\n\t\t\t\t\t// Caller asserted the device must be claimed, but local state\n\t\t\t\t\t// shows it's still in flight. This is a CLI-only guard — the\n\t\t\t\t\t// server is the final arbiter.\n\t\t\t\t\tthrow new DeviceReenrollRequiresAuthError();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst installationId = current?.installationId ?? deriveInstallationId();\n\t\t\tconst machineId = current?.machineId ?? \"unknown\";\n\t\t\tconst platform = current?.platform ?? \"unknown\";\n\n\t\t\tlet response: EnrollResponse;\n\t\t\tif (this.enrollRequest) {\n\t\t\t\tresponse = await this.enrollRequest({\n\t\t\t\t\tinstallationId,\n\t\t\t\t\tmachineId,\n\t\t\t\t\tplatform,\n\t\t\t\t\texisting,\n\t\t\t\t\tforce: Boolean(opts.force),\n\t\t\t\t\trequireAuth: Boolean(opts.requireAuth),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Test path / no live HTTP — synthesize a fresh identity. This\n\t\t\t\t// branch is what unit tests exercise; production wires\n\t\t\t\t// `enrollRequest` in Phase 5.4.\n\t\t\t\tresponse = synthesizeEnrollResponse(this.random, existing);\n\t\t\t}\n\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\tversion: current?.version ?? 1,\n\t\t\t\tinstallationId,\n\t\t\t\tmachineId,\n\t\t\t\tplatform,\n\t\t\t\thostname: current?.hostname,\n\t\t\t\tpublicId: response.publicId,\n\t\t\t\tsecretVersion: response.secretVersion,\n\t\t\t\tbindingState: response.bindingState,\n\t\t\t\tdeviceSecret: response.deviceSecret,\n\t\t\t\tpreviousDeviceSecret: existing?.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt:\n\t\t\t\t\topts.force || response.secretVersion === (existing?.secretVersion ?? 0) + 1\n\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t: existing?.previousSecretExpiresAt,\n\t\t\t\tlastEnrollAt: this.now().toISOString(),\n\t\t\t\tlastSyncAt: existing?.lastSyncAt,\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tbindingState: written.bindingState,\n\t\t\texpiresAt: deriveExpiresAt(written, this.now),\n\t\t};\n\t}\n\n\t/**\n\t * Rotate the HMAC secret. Keeps the previous secret for the grace\n\t * window (default 7 days per `2.需求澄清.md` §1.2) — the\n\t * `previousSecretExpiresAt` is stamped on the persisted identity.\n\t */\n\tasync rotateSecret(\n\t\topts: { gracePeriodDays?: number } = {}\n\t): Promise<{ publicId: string; secretVersion: number; gracePeriodDays: number }> {\n\t\tconst gracePeriodDays = opts.gracePeriodDays ?? 7;\n\t\tconst now = this.now();\n\t\tconst newSecret = this.random(SECRET_BYTES).toString(\"hex\");\n\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot rotate-secret without a prior enrollment\");\n\t\t\t}\n\t\t\tconst graceExpiresAt = new Date(now.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tdeviceSecret: newSecret,\n\t\t\t\tpreviousDeviceSecret: current.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt: graceExpiresAt.toISOString(),\n\t\t\t\tsecretVersion: current.secretVersion + 1,\n\t\t\t\tlastEnrollAt: now.toISOString(),\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tsecretVersion: written.secretVersion,\n\t\t\tgracePeriodDays,\n\t\t};\n\t}\n\n\t/**\n\t * Mark the device as `expired`. Used when the server returns a\n\t * device-expiry response; the next `enroll()` call forces a fresh\n\t * round-trip.\n\t */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\t// Nothing to expire.\n\t\t\t\treturn { next: current ?? (await emptyIdentity(this.random)), result: undefined };\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = { ...current, bindingState: \"expired\" };\n\t\t\treturn { next };\n\t\t});\n\t}\n\n\t/**\n\t * Mark the device as `claimed`. Called by the bridge after a\n\t * successful `device.claim` server response.\n\t */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot mark-claimed without a prior enrollment\");\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tbindingState: \"claimed\",\n\t\t\t\tlastSyncAt: this.now().toISOString(),\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\t}\n}\n\nfunction deriveExpiresAt(_identity: PersistedDeviceIdentity, _now: () => Date): string | undefined {\n\t// No explicit expiry on the server today (per phase-5-device-header-spec.md\n\t// §9 #1: 7-day grace is rotation-only). The shape is here for forward\n\t// compatibility — when the server adds a per-device expiry, this\n\t// pulls the value from the response without changing the call site.\n\treturn undefined;\n}\n\nfunction synthesizeEnrollResponse(\n\trandom: RandomBytesFn,\n\texisting: PersistedDeviceIdentity | null\n): EnrollResponse {\n\tconst publicId = existing?.publicId ?? random(PUBLIC_ID_BYTES).toString(\"hex\");\n\tconst secretVersion = (existing?.secretVersion ?? 0) + 1;\n\treturn {\n\t\tpublicId,\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tsecretVersion,\n\t\tbindingState: existing?.bindingState === \"claimed\" ? \"claimed\" : \"anonymous\",\n\t};\n}\n\nasync function emptyIdentity(random: RandomBytesFn): Promise<PersistedDeviceIdentity> {\n\treturn {\n\t\tversion: 1,\n\t\tinstallationId: deriveInstallationId(),\n\t\tmachineId: \"unknown\",\n\t\tplatform: \"unknown\",\n\t\tpublicId: random(PUBLIC_ID_BYTES).toString(\"hex\"),\n\t\tsecretVersion: 1,\n\t\tbindingState: \"anonymous\",\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tlastEnrollAt: new Date().toISOString(),\n\t};\n}\n","/**\n * Internal types for the device domain.\n *\n * These types are NOT re-exported from the protocol package — they are\n * implementation details of the IdentityStore + Enroller. Public data\n * models (the bridge wire shape) live in `@serviceme/devtools-protocol`'s\n * `device.ts`.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `device/types.ts`\n */\n\nimport type { DeviceBindingState } from \"@serviceme/devtools-protocol\";\n\n/**\n * Schema version of the on-disk `device.json` file. Bumped when the\n * shape changes incompatibly. IdentityStore checks this on read and\n * either migrates (versions ≤ 1) or refuses (versions > supported).\n */\nexport const DEVICE_JSON_SCHEMA_VERSION = 1;\n\n/** Internal representation of the persisted identity file. */\nexport interface PersistedDeviceIdentity {\n\tversion: number;\n\t/** Stable per-machine id (UUID v4 shape) — survives secret rotates. */\n\tinstallationId: string;\n\t/** Raw `os.hostname()` for diagnostics. */\n\tmachineId: string;\n\t/** Platform string (e.g. \"darwin\"). */\n\tplatform: string;\n\t/** Optional hostname override for environments where `os.hostname()` is unstable. */\n\thostname?: string;\n\t/** Public, non-secret id returned by the server. 32-char hex. */\n\tpublicId: string;\n\t/** Monotonic secret version counter, starts at 1 after first enroll. */\n\tsecretVersion: number;\n\t/** Current binding state — drives the re-enroll matrix. */\n\tbindingState: DeviceBindingState;\n\t/** HMAC secret (32 bytes hex-encoded = 64 chars). Persisted per `device-header-spec.md` §3.1. */\n\tdeviceSecret: string;\n\t/** Optional: previous secret retained during the grace window (rotation). */\n\tpreviousDeviceSecret?: string;\n\t/** Optional: ISO timestamp at which the previous secret stops being accepted. */\n\tpreviousSecretExpiresAt?: string;\n\t/** ISO timestamp of the most recent successful enroll / rotate. */\n\tlastEnrollAt: string;\n\t/** Optional ISO timestamp of the most recent server sync. */\n\tlastSyncAt?: string;\n\t/** Optional human-readable message for the last sync error. */\n\tlastSyncError?: string;\n}\n\n/** Result of a single atomic write. */\nexport interface AtomicWriteResult {\n\tbytesWritten: number;\n\t/** Path to the temp file (post-rename it no longer exists; useful for diagnostics). */\n\ttmpPath: string;\n}\n\n/** Hook called before/after every identity write — used by tests to assert concurrency safety. */\nexport interface IdentityStoreHooks {\n\tbeforeWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n\tafterWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n}\n","/**\n * IdentityStore — Atomic JSON persistence for the device identity file.\n *\n * Stores the `PersistedDeviceIdentity` (incl. the HMAC secret cleartext)\n * at `~/.serviceme/device.json` (per `phase-5-device-header-spec.md`\n * §3.1). Writes are atomic via `write-tmp + fsync + rename`, matching\n * the `SkillStore` / `ToolboxStore` precedent. Concurrent writes are\n * serialized with a mkdir-based file lock (POSIX-atomic) — proper\n * cross-process locking is deferred to Phase 6+ per the open spec.\n *\n * The file mode is `0600` (owner read/write only) so the cleartext\n * secret stays safe at rest. On Windows the mode hint is a no-op\n * (Windows uses ACLs) but `writeFile` still succeeds.\n *\n * Migration — IdentityStore auto-detects a v0-shape (pre-Phase-5.2)\n * file written by the Extension's old `globalState` blob:\n * { version: 1, claimed: false, publicKeyFingerprint: null }\n * In that case the file is migrated forward to the v1 schema on the\n * next write (the data fields are empty and a fresh enroll is required).\n * The full Extension `globalState` → JSON migration happens in the\n * Phase 5.5 adapter (`apps/extension/.../DeviceService.ts`) since the\n * adapter holds the live `globalState` access.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `IdentityStore.ts 持久化到 ~/.config/serviceme/device.json, 原子写`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1, §2.5\n */\n\nimport * as fsp from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\n\nimport { getDeviceJsonPath, getServicemeHome } from \"../paths/userHome\";\n\nimport {\n\ttype AtomicWriteResult,\n\tDEVICE_JSON_SCHEMA_VERSION,\n\ttype IdentityStoreHooks,\n\ttype PersistedDeviceIdentity,\n} from \"./types\";\n\nconst FILE_MODE = 0o600;\nconst LOCK_DIR_MODE = 0o700;\nconst DEFAULT_LOCK_TIMEOUT_MS = 5000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\n// `mkdir` (lock acquisition) and writing the pid file are two separate\n// syscalls, so there's a brief window where the lock dir exists but the\n// pid file doesn't yet. A grace period keeps a concurrent acquirer from\n// mistaking that window for an abandoned lock (see `isStaleLock`).\nconst LOCK_STALE_GRACE_MS = 200;\nconst TMP_SUFFIX = \".tmp\";\n\n/**\n * Minimal interface for reading + writing the persisted identity file.\n * Default impl uses `getDeviceJsonPath()` (which honors `SERVICEME_HOME`),\n * but tests can substitute a custom path for isolation.\n */\nexport interface IdentityFileBackend {\n\tread(filePath: string): Promise<PersistedDeviceIdentity | null>;\n\twrite(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult>;\n\texists(filePath: string): Promise<boolean>;\n\tdelete(filePath: string): Promise<void>;\n\tlistDir?(dir: string): Promise<string[]>;\n}\n\nexport interface IdentityStoreOptions {\n\tfilePath?: string;\n\thooks?: IdentityStoreHooks;\n\tlockTimeoutMs?: number;\n\tlockRetryMs?: number;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\tbackend?: IdentityFileBackend;\n}\n\n/**\n * Default file backend — uses `node:fs/promises` with the canonical\n * tmp-then-rename atomic-write pattern.\n */\nexport class FsIdentityFileBackend implements IdentityFileBackend {\n\tasync exists(filePath: string): Promise<boolean> {\n\t\ttry {\n\t\t\tawait fsp.access(filePath);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync read(filePath: string): Promise<PersistedDeviceIdentity | null> {\n\t\ttry {\n\t\t\tconst buf = await fsp.readFile(filePath, \"utf8\");\n\t\t\tconst parsed = JSON.parse(buf) as unknown;\n\t\t\treturn migratePersistedIdentity(parsed);\n\t\t} catch (err) {\n\t\t\tif (isNodeError(err) && err.code === \"ENOENT\") return null;\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\tasync write(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait fsp.mkdir(path.dirname(filePath), { recursive: true });\n\t\tconst tmpPath = `${filePath}${TMP_SUFFIX}`;\n\t\tconst bytes = Buffer.from(JSON.stringify(payload, null, \"\\t\"), \"utf8\");\n\t\t// Ensure tmp is fresh (in case a previous run died mid-write).\n\t\tawait fsp.rm(tmpPath, { force: true });\n\t\tconst handle = await fsp.open(tmpPath, \"w\", FILE_MODE);\n\t\ttry {\n\t\t\tawait handle.writeFile(bytes);\n\t\t\tawait handle.sync();\n\t\t} finally {\n\t\t\tawait handle.close();\n\t\t}\n\t\tawait fsp.rename(tmpPath, filePath);\n\t\t// Best-effort chmod for filesystems that ignore mode on create (Windows).\n\t\tawait fsp.chmod(filePath, FILE_MODE).catch(() => undefined);\n\t\treturn { bytesWritten: bytes.byteLength, tmpPath };\n\t}\n\n\tasync delete(filePath: string): Promise<void> {\n\t\tawait fsp.rm(filePath, { force: true });\n\t}\n}\n\n/**\n * Reconcile an unknown on-disk shape into the current `PersistedDeviceIdentity`.\n *\n * - v1 IdentityStore files (current shape) pass through unchanged.\n * - v0 bootstrap files (`{ version: 1, claimed: false, publicKeyFingerprint: null }`)\n * are recognized by their placeholder keys and discarded; the next\n * enroll writes a fresh identity.\n * - Anything else throws — refuse to silently drop user data.\n */\nfunction migratePersistedIdentity(parsed: unknown): PersistedDeviceIdentity | null {\n\tif (!isRecord(parsed)) {\n\t\tthrow new Error(\"device.json: top-level must be an object\");\n\t}\n\tconst version = parsed.version;\n\tif (version === DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 placeholder shape carries `claimed` /\n\t\t// `publicKeyFingerprint` but no real device fields. Recognize\n\t\t// the marker and return null so the next enroll writes fresh data.\n\t\tif (\n\t\t\tparsed.publicId === undefined &&\n\t\t\tparsed.deviceSecret === undefined &&\n\t\t\t(\"claimed\" in parsed || \"publicKeyFingerprint\" in parsed)\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\t// Trust the schema — the writer is also us.\n\t\treturn parsed as unknown as PersistedDeviceIdentity;\n\t}\n\tif (typeof version === \"number\" && version < DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 bootstrap shape — the file is empty placeholder\n\t\t// data; nothing to migrate. Return null to signal \"no identity\".\n\t\treturn null;\n\t}\n\tthrow new Error(`device.json: unsupported schema version ${String(version)}`);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null;\n}\n\nfunction isNodeError(value: unknown): value is NodeJS.ErrnoException {\n\treturn value instanceof Error && typeof (value as { code?: unknown }).code === \"string\";\n}\n\nconst LOCK_PID_FILE = \"pid\";\n\n/**\n * Check whether a process is still alive (best-effort, cross-platform).\n * Returns `false` for any PID we cannot verify as alive.\n */\nfunction isProcessAlive(pid: number): boolean {\n\ttry {\n\t\t// signal 0 — permission check only, never actually sent\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * mkdir-based advisory file lock with stale-lock recovery.\n *\n * POSIX mkdir is atomic; on Windows modern filesystems (NTFS) it's also\n * atomic at the API level. Sufficient for single-host, single-user\n * scenarios (which is the SERVICEME threat model).\n *\n * Stale lock recovery: a `pid` file inside the lock directory records the\n * owner's PID. On `EEXIST`, if the recorded PID is no longer alive, the\n * lock directory is forcibly removed and acquisition retried immediately.\n * This prevents permanent lockout when a process crashes without calling\n * `release()`.\n */\nclass FileLock {\n\tprivate readonly dirPath: string;\n\tprivate readonly pidFilePath: string;\n\tprivate readonly timeoutMs: number;\n\tprivate readonly retryMs: number;\n\tprivate acquired = false;\n\n\tconstructor(filePath: string, timeoutMs: number, retryMs: number) {\n\t\tthis.dirPath = `${filePath}.lock`;\n\t\tthis.pidFilePath = path.join(this.dirPath, LOCK_PID_FILE);\n\t\tthis.timeoutMs = timeoutMs;\n\t\tthis.retryMs = retryMs;\n\t}\n\n\tasync acquire(): Promise<void> {\n\t\tconst start = Date.now();\n\t\twhile (true) {\n\t\t\ttry {\n\t\t\t\tawait fsp.mkdir(this.dirPath, { mode: LOCK_DIR_MODE });\n\t\t\t\t// Write PID so a future acquirer can detect if we crash.\n\t\t\t\tawait fsp.writeFile(this.pidFilePath, String(process.pid), \"utf8\").catch(() => undefined);\n\t\t\t\tthis.acquired = true;\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tif (!isNodeError(err) || err.code !== \"EEXIST\") {\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t\t// Lock directory exists — check for stale owner.\n\t\t\t\tconst stale = await this.isStaleLock();\n\t\t\t\tif (stale) {\n\t\t\t\t\tawait fsp.rm(this.dirPath, { recursive: true, force: true });\n\t\t\t\t\t// Retry immediately without counting this iteration against timeout.\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (Date.now() - start >= this.timeoutMs) {\n\t\t\t\t\tthrow new Error(`IdentityStore lock acquisition timed out for ${this.dirPath}`);\n\t\t\t\t}\n\t\t\t\tawait delay(this.retryMs);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async isStaleLock(): Promise<boolean> {\n\t\tlet pidStr: string;\n\t\ttry {\n\t\t\tpidStr = await fsp.readFile(this.pidFilePath, \"utf8\");\n\t\t} catch {\n\t\t\t// The pid file may not exist yet because another acquirer just\n\t\t\t// created the lock dir and hasn't finished writing its pid file\n\t\t\t// (mkdir + writeFile is not atomic). Give it a short grace window\n\t\t\t// before concluding the owner crashed between mkdir and writeFile.\n\t\t\ttry {\n\t\t\t\tconst stat = await fsp.stat(this.dirPath);\n\t\t\t\treturn Date.now() - stat.mtimeMs > LOCK_STALE_GRACE_MS;\n\t\t\t} catch {\n\t\t\t\t// Lock dir disappeared concurrently (e.g. released mid-check) —\n\t\t\t\t// not stale, just gone; the caller's next mkdir will succeed.\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\tconst pid = Number.parseInt(pidStr.trim(), 10);\n\t\tif (!Number.isFinite(pid) || pid <= 0) return true; // malformed pid file → treat as stale\n\t\treturn !isProcessAlive(pid);\n\t}\n\n\tasync release(): Promise<void> {\n\t\tif (!this.acquired) return;\n\t\tthis.acquired = false;\n\t\tawait fsp.rm(this.dirPath, { recursive: true, force: true });\n\t}\n}\n\nexport class IdentityStore {\n\tprivate readonly filePath: string;\n\tprivate readonly backend: IdentityFileBackend;\n\tprivate readonly hooks: IdentityStoreHooks;\n\tprivate readonly lockTimeoutMs: number;\n\tprivate readonly lockRetryMs: number;\n\n\tconstructor(opts: IdentityStoreOptions = {}) {\n\t\tthis.filePath = opts.filePath ?? getDeviceJsonPath();\n\t\tthis.backend = opts.backend ?? new FsIdentityFileBackend();\n\t\tthis.hooks = opts.hooks ?? {};\n\t\tthis.lockTimeoutMs = opts.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n\t\tthis.lockRetryMs = opts.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;\n\t}\n\n\t/** Absolute path to the underlying JSON file (test seam). */\n\tgetFilePath(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** True when the JSON file already exists on disk. */\n\tasync exists(): Promise<boolean> {\n\t\treturn this.backend.exists(this.filePath);\n\t}\n\n\t/** Read the persisted identity; returns `null` when no identity is stored. */\n\tasync read(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.backend.read(this.filePath);\n\t}\n\n\t/**\n\t * Atomically write the given identity. Concurrent writers are\n\t * serialized via the file lock; the read-modify-write happens\n\t * inside the lock so callers can't see a partial state.\n\t */\n\tasync write(next: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait this.hooks.beforeWrite?.(next);\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tconst result = await this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/**\n\t * Read-modify-write under the same lock. The mutator receives the\n\t * current identity (or `null` on first call) and returns the\n\t * replacement. Throwing inside the mutator aborts the write.\n\t */\n\tasync mutate<T>(\n\t\tmutator: (\n\t\t\tcurrent: PersistedDeviceIdentity | null\n\t\t) => Promise<{ next: PersistedDeviceIdentity; result?: T }>\n\t): Promise<{ result: T | undefined; written: PersistedDeviceIdentity }> {\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst current = await this.backend.read(this.filePath);\n\t\t\tconst { next, result } = await mutator(current);\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tawait this.hooks.beforeWrite?.(stamped);\n\t\t\tawait this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn { result, written: stamped };\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/** Wipe the persisted identity (used by `device.enroll --force`). */\n\tasync clear(): Promise<void> {\n\t\tawait this.backend.delete(this.filePath);\n\t}\n\n\t/**\n\t * Resolve the installation metadata for the current machine.\n\t * Pure helper — no I/O, just `os.*` calls.\n\t */\n\tresolveInstallationMetadata(): Pick<\n\t\tPersistedDeviceIdentity,\n\t\t\"installationId\" | \"machineId\" | \"platform\"\n\t> {\n\t\tconst machineId = os.hostname();\n\t\tconst platform = os.platform();\n\t\t// installationId is derived by `InstallationId.ts` — pass the\n\t\t// caller's already-computed value via `material` so we don't\n\t\t// recompute the SHA twice in a row.\n\t\treturn {\n\t\t\tinstallationId: \"\", // intentionally empty; caller fills via deriveInstallationId()\n\t\t\tmachineId,\n\t\t\tplatform,\n\t\t};\n\t}\n\n\t/**\n\t * Ensure the parent directory exists (`~/.serviceme/`). Idempotent.\n\t * Useful when the bootstrap phase5 placeholder wasn't run yet.\n\t */\n\tasync ensureHome(): Promise<void> {\n\t\tawait fsp.mkdir(getServicemeHome(), { recursive: true });\n\t\tawait fsp.mkdir(path.dirname(this.filePath), { recursive: true });\n\t}\n}\n","/**\n * DeviceCore — Main entry for the device domain.\n *\n * Aggregates `IdentityStore` + `Enroller` + signer helpers into a single\n * surface that the CLI / Extension / Bridge can call. Pure orchestration\n * — no HTTP of its own (the actual `POST /api/v1/devices/enroll` lives\n * behind `EnrollerOptions.enrollRequest`, wired in Phase 5.4).\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `DeviceCore.ts 主入口`\n * - ADL-003 — data model in `@serviceme/devtools-protocol`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1\n */\n\nimport type {\n\tDeviceEnrollResult,\n\tDeviceIdentityState,\n\tDeviceMetadata,\n\tDeviceRotateSecretResult,\n\tDeviceStatus,\n} from \"@serviceme/devtools-protocol\";\nimport { buildSignedHeaders, DeviceAuthHeaders, type DeviceSignedHeaders } from \"./deviceAuth\";\nimport { Enroller, type EnrollRequestFn } from \"./Enroller\";\nimport { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\nexport interface DeviceCoreOptions {\n\tidentityStore?: IdentityStore;\n\tenrollRequest?: EnrollRequestFn;\n\tnow?: () => Date;\n\t/** Optional override for `deriveInstallationId` (used by tests for determinism). */\n\tresolveInstallationId?: () => string;\n}\n\nexport class DeviceCore {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly enroller: Enroller;\n\tprivate readonly resolveInstallationId: () => string;\n\n\tconstructor(opts: DeviceCoreOptions = {}) {\n\t\tthis.identity = opts.identityStore ?? new IdentityStore();\n\t\tthis.enroller = new Enroller({\n\t\t\tidentityStore: this.identity,\n\t\t\tenrollRequest: opts.enrollRequest,\n\t\t\tnow: opts.now,\n\t\t});\n\t\tthis.resolveInstallationId = opts.resolveInstallationId ?? deriveInstallationId;\n\t}\n\n\t/** Read-only snapshot of the device status (matches `device.status` wire shape). */\n\tasync status(): Promise<DeviceStatus> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored\n\t\t\t? {\n\t\t\t\t\tbindingState: stored.bindingState,\n\t\t\t\t\tidentity: projectIdentity(stored),\n\t\t\t\t\tmetadata: projectMetadata(stored),\n\t\t\t\t\tlastSyncAt: stored.lastSyncAt,\n\t\t\t\t\tlastSyncError: stored.lastSyncError,\n\t\t\t\t}\n\t\t\t: { bindingState: \"anonymous\" };\n\t}\n\n\t/** Enroll (or re-enroll) the device. */\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\treturn this.enroller.enroll(opts);\n\t}\n\n\t/** Wait for any in-flight enrollment to finish. Use before `buildSignedHeaders` so that a concurrent `syncDeviceInfo` enrollment has time to write the identity to the store. */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tawait this.enroller.waitForEnrollment();\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.enroller.isEnrolling();\n\t}\n\n\t/** Rotate the HMAC secret while keeping the previous one for the grace window. */\n\tasync rotateSecret(opts: { gracePeriodDays?: number } = {}): Promise<DeviceRotateSecretResult> {\n\t\tconst result = await this.enroller.rotateSecret(opts);\n\t\tconst stored = await this.identity.read();\n\t\treturn {\n\t\t\t...result,\n\t\t\tgracePeriodEndsAt: stored?.previousSecretExpiresAt,\n\t\t};\n\t}\n\n\t/** Build the v2 signed header map for an outbound request. The path\n\t * MUST include the query string (v2 signs it). */\n\tasync buildSignedHeaders(input: {\n\t\tmethod: string;\n\t\tpath: string;\n\t\tbody: string;\n\t}): Promise<DeviceSignedHeaders | null> {\n\t\tconst stored = await this.identity.read();\n\t\tif (!stored) return null;\n\t\treturn buildSignedHeaders({\n\t\t\tmethod: input.method,\n\t\t\tpath: input.path,\n\t\t\tbody: input.body,\n\t\t\tpublicId: stored.publicId,\n\t\t\tdeviceSecret: stored.deviceSecret,\n\t\t\tsecretVersion: stored.secretVersion,\n\t\t});\n\t}\n\n\t/** Raw stored identity (CLI/extension internal use). Test seam too. */\n\tasync readIdentity(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.identity.read();\n\t}\n\n\t/** Wipe the local identity (the `--force` path before re-enroll). */\n\tasync clear(): Promise<void> {\n\t\tawait this.identity.clear();\n\t}\n\n\t/** Mark the device as claimed (called by the bridge after a successful claim). */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.enroller.markClaimed();\n\t}\n\n\t/** Mark the device as expired (server returned an expiry response). */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.enroller.markExpired();\n\t}\n\n\t/** Expose the identity store (CLI uses it for direct file access in tests). */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** Expose the enroller (CLI uses it for state inspection). */\n\tgetEnroller(): Enroller {\n\t\treturn this.enroller;\n\t}\n\n\t/** Header name constants — re-exported from `deviceAuth.ts`. */\n\tgetHeaderNames(): typeof DeviceAuthHeaders {\n\t\treturn DeviceAuthHeaders;\n\t}\n\n\t/** Compute the installation id for the current machine. */\n\tgetInstallationId(): string {\n\t\treturn this.resolveInstallationId();\n\t}\n}\n\nfunction projectIdentity(stored: PersistedDeviceIdentity): DeviceIdentityState {\n\treturn {\n\t\tpublicId: stored.publicId,\n\t\tsecretVersion: stored.secretVersion,\n\t\tbindingState: stored.bindingState,\n\t};\n}\n\nfunction projectMetadata(stored: PersistedDeviceIdentity): DeviceMetadata {\n\treturn {\n\t\tinstallationId: stored.installationId,\n\t\tmachineId: stored.machineId,\n\t\tplatform: stored.platform,\n\t\thostname: stored.hostname,\n\t};\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"device-Bn2NzajD.mjs","names":["delay"],"sources":["../src/device/deviceAuth.ts","../src/device/InstallationId.ts","../src/device/Enroller.ts","../src/device/types.ts","../src/device/IdentityStore.ts","../src/device/DeviceCore.ts"],"sourcesContent":["/**\n * deviceAuth — Device request signing helpers for device auth headers.\n *\n * **Boundary exception copy.** The single source of truth for the\n * `x-ms-device-*` header names + the signature algorithms now lives in\n * `@serviceme/devtools-shared` (`device-auth.ts`), consumed by the\n * extension signer and the server verifier. ADL-003 forbids\n * core → shared (and shared → core), so this module keeps a\n * byte-for-byte copy as the documented exception. Keep it in lock-step\n * with `packages/serviceme-shared/src/device-auth.ts`.\n *\n * See `docs/architecture/phase-5-device-header-spec.md` §5 for the wire\n * format. Legacy (v1) basis is `METHOD\\nPATH\\nTIMESTAMP\\nBODY\\nSECRET`\n * hashed with a secret-suffix SHA-256; v2 basis is\n * `METHOD\\nPATH?QUERY\\nTIMESTAMP\\nNONCE\\nBODY\\nSECRET` MACed with real\n * HMAC-SHA-256 keyed by the device secret and signed over the full\n * path-with-query. Both output lowercase hex.\n */\n\nimport { createHash, createHmac, randomUUID } from \"node:crypto\";\n\n/** Canonical header names — MUST match `@serviceme/devtools-shared`'s `DeviceAuthHeaders`. */\nexport const DeviceAuthHeaders = {\n\tdeviceId: \"x-ms-device-id\",\n\tdeviceSecret: \"x-ms-device-secret\",\n\tsignature: \"x-ms-device-signature\",\n\ttimestamp: \"x-ms-device-timestamp\",\n\tsecretVersion: \"x-ms-device-secret-version\",\n\t/** v2 only — algorithm self-declaration so the server can verify\n\t * both schemes during the legacy-client rollout window. */\n\tsigAlg: \"x-ms-device-sig-alg\",\n\t/** v2 only — one-time value folded into the signature basis and\n\t * checked against a server-side replay cache. */\n\tnonce: \"x-ms-device-nonce\",\n} as const;\n\n/** v2 signature algorithm identifier carried in `x-ms-device-sig-alg`. */\nexport const DEVICE_SIG_ALG_V2 = \"hmac-sha256-v2\";\n\nexport interface DeviceRequestSignatureParams {\n\tmethod: string;\n\tpath: string;\n\ttimestamp: number;\n\tbody: string;\n\tsecret: string;\n}\n\n/**\n * Legacy (v1) secret-suffix SHA-256 over the canonical basis. Retained\n * for backward compatibility with fielded verifiers/clients that\n * predate v2; new code should use `createDeviceRequestSignatureV2`.\n */\nexport function createDeviceRequestSignature(params: DeviceRequestSignatureParams): string {\n\tconst basis = [\n\t\tparams.method.toUpperCase(),\n\t\tparams.path,\n\t\tString(params.timestamp),\n\t\tparams.body,\n\t\tparams.secret,\n\t].join(\"\\n\");\n\treturn createHash(\"sha256\").update(basis).digest(\"hex\");\n}\n\nexport interface DeviceRequestSignatureV2Params {\n\tmethod: string;\n\t/** Full request path INCLUDING the query string. v2 signs the query\n\t * so GET parameters cannot be substituted or replayed independently\n\t * of the signed basis. */\n\tpath: string;\n\ttimestamp: number;\n\t/** One-time value; the server tracks seen nonces per device and\n\t * rejects replays within the timestamp window. */\n\tnonce: string;\n\tbody: string;\n\tsecret: string;\n}\n\n/**\n * Compute the v2 HMAC-SHA-256 hex digest over the canonical basis\n * (path-with-query + nonce).\n *\n * Server contract (`apps/server/src/lib/auth/device-signature-guard.ts`)\n * is line-for-line identical: same LF-joined basis, same lowercase hex\n * output. Any divergence breaks `device-signature-guard.test.ts`.\n */\nexport function createDeviceRequestSignatureV2(params: DeviceRequestSignatureV2Params): string {\n\tconst basis = [\n\t\tparams.method.toUpperCase(),\n\t\tparams.path,\n\t\tString(params.timestamp),\n\t\tparams.nonce,\n\t\tparams.body,\n\t\tparams.secret,\n\t].join(\"\\n\");\n\treturn createHmac(\"sha256\", params.secret).update(basis).digest(\"hex\");\n}\n\n/** Header map consumed by `fetch()` callers (CLI bridge, Extension). */\nexport interface DeviceSignedHeaders {\n\t[DeviceAuthHeaders.deviceId]: string;\n\t[DeviceAuthHeaders.deviceSecret]: string;\n\t[DeviceAuthHeaders.signature]: string;\n\t[DeviceAuthHeaders.timestamp]: string;\n\t[DeviceAuthHeaders.secretVersion]: string;\n\t[DeviceAuthHeaders.sigAlg]: string;\n\t[DeviceAuthHeaders.nonce]: string;\n}\n\nexport interface BuildSignedHeadersParams {\n\tmethod: string;\n\t/** Full request path INCLUDING the query string (v2 signs it). */\n\tpath: string;\n\tbody: string;\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\t/** Override for deterministic tests. */\n\ttimestamp?: number;\n\t/** Override for deterministic tests; defaults to a fresh random UUID. */\n\tnonce?: string;\n}\n\n/**\n * Build the v2 header map. The `path` parameter MUST include the exact\n * query string sent on the wire, and the `body` parameter MUST be the\n * exact byte sequence sent on the wire (no whitespace\n * re-canonicalization between client serialization and signature basis\n * construction).\n */\nexport function buildSignedHeaders(params: BuildSignedHeadersParams): DeviceSignedHeaders {\n\tconst timestamp = params.timestamp ?? Date.now();\n\tconst nonce = params.nonce ?? randomUUID();\n\tconst signature = createDeviceRequestSignatureV2({\n\t\tmethod: params.method,\n\t\tpath: params.path,\n\t\ttimestamp,\n\t\tnonce,\n\t\tbody: params.body,\n\t\tsecret: params.deviceSecret,\n\t});\n\treturn {\n\t\t[DeviceAuthHeaders.deviceId]: params.publicId,\n\t\t[DeviceAuthHeaders.deviceSecret]: params.deviceSecret,\n\t\t[DeviceAuthHeaders.signature]: signature,\n\t\t[DeviceAuthHeaders.timestamp]: String(timestamp),\n\t\t[DeviceAuthHeaders.secretVersion]: String(params.secretVersion),\n\t\t[DeviceAuthHeaders.sigAlg]: DEVICE_SIG_ALG_V2,\n\t\t[DeviceAuthHeaders.nonce]: nonce,\n\t};\n}\n","/**\n * InstallationId — Derive a stable per-machine identifier from\n * `os.hostname()` + `os.userInfo()`.\n *\n * Per `docs/architecture/phase-5-auth-device-toolbox.md § P5-2 (并入本文时对应 § P5-2 拆分)` B2,\n * `installationId` MUST survive Extension re-installs but vary across\n * machines. We compute a UUID v5-style hash over hostname + username +\n * platform so the result is:\n * - deterministic (same machine → same id)\n * - collision-resistant (SHA-256, 128-bit truncated)\n * - browser-safe (no PII survives — username never enters output)\n *\n * Note: this intentionally differs from `vscode.env.machineId`, which\n * is per-Extension-install and uses a different algorithm. The two\n * coexist: `installationId` is what gets sent to the server, while\n * `machineId` (raw `os.hostname()`) is for diagnostics.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `InstallationId.ts os.hostname() + os.userInfo() 哈希生成`\n * - 3.功能拆分.md B2 — installationId semantics\n */\n\nimport { createHash, randomUUID } from \"node:crypto\";\nimport * as os from \"node:os\";\n\n/** Hex-encoded SHA-256 input. Format: `<hostname>|<username>|<platform>|<nodeVersion>`. */\nfunction fingerprintMaterial(): string {\n\t// `os.userInfo()` is undefined-ish on Windows in some sandboxes; fall\n\t// back to a process env user, then to a constant placeholder. We never\n\t// emit the raw username to the caller — it only enters the hash.\n\tlet username = \"unknown\";\n\ttry {\n\t\tusername = os.userInfo().username;\n\t} catch {\n\t\tusername = process.env.USER ?? process.env.USERNAME ?? \"unknown\";\n\t}\n\treturn [\n\t\tos.hostname(),\n\t\tusername,\n\t\tos.platform(),\n\t\tos.arch(),\n\t\tprocess.versions.node ?? \"unknown\",\n\t].join(\"|\");\n}\n\n/**\n * Returns a deterministic installation id for the current machine.\n * Use this when you need an id that survives Extension reinstalls\n * but stays stable across restarts on the same machine.\n */\nexport function deriveInstallationId(): string {\n\tconst material = fingerprintMaterial();\n\tconst digest = createHash(\"sha256\").update(material).digest(\"hex\");\n\t// Take the first 32 hex chars (128 bits) and reformat as UUID v4-shape\n\t// so the output looks like a UUID to downstream consumers while\n\t// remaining a pure SHA-256 truncation.\n\treturn formatAsV4(digest.slice(0, 32));\n}\n\n/**\n * Returns a random installation id (UUID v4). Use this for fresh\n * installs when no fingerprint input is available (e.g. containerized\n * CI runners where `os.hostname()` is meaningless).\n */\nexport function randomInstallationId(): string {\n\treturn randomUUID();\n}\n\n/** SHA-256 fingerprint material exposed for tests + diagnostics. */\nexport function fingerprintSource(): string {\n\treturn fingerprintMaterial();\n}\n\nfunction formatAsV4(hex32: string): string {\n\t// Stamp version 4 + variant bits per RFC 4122 §4.4. The bits are\n\t// cosmetic — the underlying entropy is still SHA-256.\n\tconst chars = hex32.split(\"\");\n\t// Version nibble (position 12 in canonical UUID, index 13 of the 32-char string).\n\tconst versionIdx = 12;\n\tconst variantIdx = 16;\n\tconst versionChar = (parseInt(chars[versionIdx] ?? \"8\", 16) & 0x0) | 0x4;\n\tchars[versionIdx] = versionChar.toString(16);\n\t// Variant nibble: 10xx → first hex char of the 17th position.\n\tconst variantChar = (parseInt(chars[variantIdx] ?? \"8\", 16) & 0x3) | 0x8;\n\tchars[variantIdx] = variantChar.toString(16);\n\tconst formatted = chars.join(\"\");\n\treturn `${formatted.slice(0, 8)}-${formatted.slice(8, 12)}-${formatted.slice(12, 16)}-${formatted.slice(16, 20)}-${formatted.slice(20, 32)}`;\n}\n","/**\n * Enroller — State machine for `device.enroll` and `device.rotate-secret`.\n *\n * States per `2.需求澄清.md` §1.2:\n * anonymous → pending → claimed → expired\n *\n * - `anonymous` (initial): no device has ever enrolled. Server returns\n * a fresh `publicId` + secret.\n * - `pending`: enrollment HTTP call has been issued but the server\n * hasn't confirmed yet. In-flight state — never persisted.\n * - `claimed`: user has linked this device to their account (via\n * `/api/v1/devices/claim`). Sticky binding locks future re-enrolls\n * to the same `userId` (server-side matrix).\n * - `expired`: server returned a device-expiry error. Forces a fresh\n * enroll on next call.\n *\n * `--force` semantics: any non-anonymous state can be force-reset to\n * `anonymous` by wiping the local identity file. The next enroll will\n * be treated as a brand-new install by the server (no sticky binding).\n *\n * The Enroller is the **state machine**; the actual HTTP I/O is the\n * caller's responsibility (the `DeviceSyncClient` in Phase 5.4 wires\n * the server). This split keeps the Enroller unit-testable without\n * a live server.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `Enroller.ts anonymous → pending → claimed → expired`\n * - `2.需求澄清.md` §1.2 — binding-state machine\n */\n\nimport { randomBytes } from \"node:crypto\";\n\nimport type { DeviceBindingState, DeviceEnrollResult } from \"@serviceme/devtools-protocol\";\nimport type { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\n/** 32 bytes of HMAC secret material — matches the server's `device-registration.ts:73-80` generator. */\nconst SECRET_BYTES = 32;\n/** Server returns `publicId` as 32-char hex (16 bytes). Match the wire length. */\nconst PUBLIC_ID_BYTES = 16;\n\ntype RandomBytesFn = (size: number) => Buffer;\n\nconst defaultRandomBytes: RandomBytesFn = (size) => {\n\treturn randomBytes(size);\n};\n\nexport interface EnrollerOptions {\n\tidentityStore: IdentityStore;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\t/** Override the random source (tests). */\n\trandomBytes?: (size: number) => Buffer;\n\t/** Caller-supplied enroll HTTP function. Phase 5.4 wires the real one. */\n\tenrollRequest?: EnrollRequestFn;\n}\n\nexport type EnrollRequestFn = (input: {\n\tinstallationId: string;\n\tmachineId: string;\n\tplatform: string;\n\texisting: PersistedDeviceIdentity | null;\n\tforce: boolean;\n\trequireAuth: boolean;\n}) => Promise<EnrollResponse>;\n\nexport interface EnrollResponse {\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\tbindingState: DeviceBindingState;\n\texpiresAt?: string;\n}\n\n/** Sentinel error — re-enroll on a claimed device without auth. */\nexport class DeviceReenrollRequiresAuthError extends Error {\n\tconstructor(\n\t\tmessage = \"Re-enroll on a claimed device requires current device credentials or the bound user\"\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceReenrollRequiresAuthError\";\n\t}\n}\n\n/** Sentinel error — server returned a 410 / version-mismatch after rotation. */\nexport class DeviceSecretVersionMismatchError extends Error {\n\tconstructor(message = \"Device secret version mismatch — server has rotated past the local copy\") {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceSecretVersionMismatchError\";\n\t}\n}\n\nexport class Enroller {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly now: () => Date;\n\tprivate readonly random: (size: number) => Buffer;\n\tprivate readonly enrollRequest?: EnrollRequestFn;\n\tprivate inflight: Promise<DeviceEnrollResult> | null = null;\n\n\tconstructor(opts: EnrollerOptions) {\n\t\tthis.identity = opts.identityStore;\n\t\tthis.now = opts.now ?? (() => new Date());\n\t\tthis.random = opts.randomBytes ?? defaultRandomBytes;\n\t\tthis.enrollRequest = opts.enrollRequest;\n\t}\n\n\t/**\n\t * Read the current binding state without touching the disk.\n\t * Returns `anonymous` when no identity is stored.\n\t */\n\tasync currentState(): Promise<DeviceBindingState> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored?.bindingState ?? \"anonymous\";\n\t}\n\n\t/**\n\t * Drive the enrollment flow.\n\t *\n\t * @param force when true, drop the local identity and start fresh\n\t * (server treats this as a brand-new install).\n\t * @param requireAuth when true, refuse to silently re-enroll an\n\t * existing claimed device — throw\n\t * `DeviceReenrollRequiresAuthError` instead.\n\t */\n\t/**\n\t * Resolve when any in-flight enrollment completes. Returns immediately\n\t * when no enrollment is in progress. Allows callers (e.g. the extension's\n\t * `buildDeviceAuthHeaders`) to wait for a concurrent `syncDeviceInfo()`\n\t * enrollment before attempting to read the identity from the store.\n\t */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tif (this.inflight) {\n\t\t\tawait this.inflight;\n\t\t}\n\t}\n\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\t// Concurrency guard — multiple in-flight calls share the same promise.\n\t\tif (this.inflight) {\n\t\t\treturn this.inflight;\n\t\t}\n\t\tconst promise = this.runEnroll(opts);\n\t\tthis.inflight = promise;\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tif (this.inflight === promise) this.inflight = null;\n\t\t}\n\t}\n\n\t/** Test seam — surface the underlying identity store. */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers (e.g. the extension's `buildDeviceAuthHeaders`) to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.inflight !== null;\n\t}\n\n\tprivate async runEnroll(opts: {\n\t\tforce?: boolean;\n\t\trequireAuth?: boolean;\n\t}): Promise<DeviceEnrollResult> {\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tconst existing = opts.force ? null : current;\n\n\t\t\tif (!opts.force && current) {\n\t\t\t\tif (current.bindingState === \"expired\") {\n\t\t\t\t\t// Expired identities are forced to re-enroll as if they were new.\n\t\t\t\t} else if (\n\t\t\t\t\topts.requireAuth &&\n\t\t\t\t\t(current.bindingState === \"claimed\" || current.bindingState === \"pending\")\n\t\t\t\t) {\n\t\t\t\t\t// Caller asserted the device must be claimed, but local state\n\t\t\t\t\t// shows it's still in flight. This is a CLI-only guard — the\n\t\t\t\t\t// server is the final arbiter.\n\t\t\t\t\tthrow new DeviceReenrollRequiresAuthError();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst installationId = current?.installationId ?? deriveInstallationId();\n\t\t\tconst machineId = current?.machineId ?? \"unknown\";\n\t\t\tconst platform = current?.platform ?? \"unknown\";\n\n\t\t\tlet response: EnrollResponse;\n\t\t\tif (this.enrollRequest) {\n\t\t\t\tresponse = await this.enrollRequest({\n\t\t\t\t\tinstallationId,\n\t\t\t\t\tmachineId,\n\t\t\t\t\tplatform,\n\t\t\t\t\texisting,\n\t\t\t\t\tforce: Boolean(opts.force),\n\t\t\t\t\trequireAuth: Boolean(opts.requireAuth),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Test path / no live HTTP — synthesize a fresh identity. This\n\t\t\t\t// branch is what unit tests exercise; production wires\n\t\t\t\t// `enrollRequest` in Phase 5.4.\n\t\t\t\tresponse = synthesizeEnrollResponse(this.random, existing);\n\t\t\t}\n\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\tversion: current?.version ?? 1,\n\t\t\t\tinstallationId,\n\t\t\t\tmachineId,\n\t\t\t\tplatform,\n\t\t\t\thostname: current?.hostname,\n\t\t\t\tpublicId: response.publicId,\n\t\t\t\tsecretVersion: response.secretVersion,\n\t\t\t\tbindingState: response.bindingState,\n\t\t\t\tdeviceSecret: response.deviceSecret,\n\t\t\t\tpreviousDeviceSecret: existing?.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt:\n\t\t\t\t\topts.force || response.secretVersion === (existing?.secretVersion ?? 0) + 1\n\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t: existing?.previousSecretExpiresAt,\n\t\t\t\tlastEnrollAt: this.now().toISOString(),\n\t\t\t\tlastSyncAt: existing?.lastSyncAt,\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tbindingState: written.bindingState,\n\t\t\texpiresAt: deriveExpiresAt(written, this.now),\n\t\t};\n\t}\n\n\t/**\n\t * Rotate the HMAC secret. Keeps the previous secret for the grace\n\t * window (default 7 days per `2.需求澄清.md` §1.2) — the\n\t * `previousSecretExpiresAt` is stamped on the persisted identity.\n\t */\n\tasync rotateSecret(\n\t\topts: { gracePeriodDays?: number } = {}\n\t): Promise<{ publicId: string; secretVersion: number; gracePeriodDays: number }> {\n\t\tconst gracePeriodDays = opts.gracePeriodDays ?? 7;\n\t\tconst now = this.now();\n\t\tconst newSecret = this.random(SECRET_BYTES).toString(\"hex\");\n\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot rotate-secret without a prior enrollment\");\n\t\t\t}\n\t\t\tconst graceExpiresAt = new Date(now.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tdeviceSecret: newSecret,\n\t\t\t\tpreviousDeviceSecret: current.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt: graceExpiresAt.toISOString(),\n\t\t\t\tsecretVersion: current.secretVersion + 1,\n\t\t\t\tlastEnrollAt: now.toISOString(),\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tsecretVersion: written.secretVersion,\n\t\t\tgracePeriodDays,\n\t\t};\n\t}\n\n\t/**\n\t * Mark the device as `expired`. Used when the server returns a\n\t * device-expiry response; the next `enroll()` call forces a fresh\n\t * round-trip.\n\t */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\t// Nothing to expire.\n\t\t\t\treturn { next: current ?? (await emptyIdentity(this.random)), result: undefined };\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = { ...current, bindingState: \"expired\" };\n\t\t\treturn { next };\n\t\t});\n\t}\n\n\t/**\n\t * Mark the device as `claimed`. Called by the bridge after a\n\t * successful `device.claim` server response.\n\t */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot mark-claimed without a prior enrollment\");\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tbindingState: \"claimed\",\n\t\t\t\tlastSyncAt: this.now().toISOString(),\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\t}\n}\n\nfunction deriveExpiresAt(_identity: PersistedDeviceIdentity, _now: () => Date): string | undefined {\n\t// No explicit expiry on the server today (per phase-5-device-header-spec.md\n\t// §9 #1: 7-day grace is rotation-only). The shape is here for forward\n\t// compatibility — when the server adds a per-device expiry, this\n\t// pulls the value from the response without changing the call site.\n\treturn undefined;\n}\n\nfunction synthesizeEnrollResponse(\n\trandom: RandomBytesFn,\n\texisting: PersistedDeviceIdentity | null\n): EnrollResponse {\n\tconst publicId = existing?.publicId ?? random(PUBLIC_ID_BYTES).toString(\"hex\");\n\tconst secretVersion = (existing?.secretVersion ?? 0) + 1;\n\treturn {\n\t\tpublicId,\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tsecretVersion,\n\t\tbindingState: existing?.bindingState === \"claimed\" ? \"claimed\" : \"anonymous\",\n\t};\n}\n\nasync function emptyIdentity(random: RandomBytesFn): Promise<PersistedDeviceIdentity> {\n\treturn {\n\t\tversion: 1,\n\t\tinstallationId: deriveInstallationId(),\n\t\tmachineId: \"unknown\",\n\t\tplatform: \"unknown\",\n\t\tpublicId: random(PUBLIC_ID_BYTES).toString(\"hex\"),\n\t\tsecretVersion: 1,\n\t\tbindingState: \"anonymous\",\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tlastEnrollAt: new Date().toISOString(),\n\t};\n}\n","/**\n * Internal types for the device domain.\n *\n * These types are NOT re-exported from the protocol package — they are\n * implementation details of the IdentityStore + Enroller. Public data\n * models (the bridge wire shape) live in `@serviceme/devtools-protocol`'s\n * `device.ts`.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `device/types.ts`\n */\n\nimport type { DeviceBindingState } from \"@serviceme/devtools-protocol\";\n\n/**\n * Schema version of the on-disk `device.json` file. Bumped when the\n * shape changes incompatibly. IdentityStore checks this on read and\n * either migrates (versions ≤ 1) or refuses (versions > supported).\n */\nexport const DEVICE_JSON_SCHEMA_VERSION = 1;\n\n/** Internal representation of the persisted identity file. */\nexport interface PersistedDeviceIdentity {\n\tversion: number;\n\t/** Stable per-machine id (UUID v4 shape) — survives secret rotates. */\n\tinstallationId: string;\n\t/** Raw `os.hostname()` for diagnostics. */\n\tmachineId: string;\n\t/** Platform string (e.g. \"darwin\"). */\n\tplatform: string;\n\t/** Optional hostname override for environments where `os.hostname()` is unstable. */\n\thostname?: string;\n\t/** Public, non-secret id returned by the server. 32-char hex. */\n\tpublicId: string;\n\t/** Monotonic secret version counter, starts at 1 after first enroll. */\n\tsecretVersion: number;\n\t/** Current binding state — drives the re-enroll matrix. */\n\tbindingState: DeviceBindingState;\n\t/** HMAC secret (32 bytes hex-encoded = 64 chars). Persisted per `device-header-spec.md` §3.1. */\n\tdeviceSecret: string;\n\t/** Optional: previous secret retained during the grace window (rotation). */\n\tpreviousDeviceSecret?: string;\n\t/** Optional: ISO timestamp at which the previous secret stops being accepted. */\n\tpreviousSecretExpiresAt?: string;\n\t/** ISO timestamp of the most recent successful enroll / rotate. */\n\tlastEnrollAt: string;\n\t/** Optional ISO timestamp of the most recent server sync. */\n\tlastSyncAt?: string;\n\t/** Optional human-readable message for the last sync error. */\n\tlastSyncError?: string;\n}\n\n/** Result of a single atomic write. */\nexport interface AtomicWriteResult {\n\tbytesWritten: number;\n\t/** Path to the temp file (post-rename it no longer exists; useful for diagnostics). */\n\ttmpPath: string;\n}\n\n/** Hook called before/after every identity write — used by tests to assert concurrency safety. */\nexport interface IdentityStoreHooks {\n\tbeforeWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n\tafterWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n}\n","/**\n * IdentityStore — Atomic JSON persistence for the device identity file.\n *\n * Stores the `PersistedDeviceIdentity` (incl. the HMAC secret cleartext)\n * at `~/.serviceme/device.json` (per `phase-5-device-header-spec.md`\n * §3.1). Writes are atomic via `write-tmp + fsync + rename`, matching\n * the `SkillStore` / `ToolboxStore` precedent. Concurrent writes are\n * serialized with a mkdir-based file lock (POSIX-atomic) — proper\n * cross-process locking is deferred to Phase 6+ per the open spec.\n *\n * The file mode is `0600` (owner read/write only) so the cleartext\n * secret stays safe at rest. On Windows the mode hint is a no-op\n * (Windows uses ACLs) but `writeFile` still succeeds.\n *\n * Migration — IdentityStore auto-detects a v0-shape (pre-Phase-5.2)\n * file written by the Extension's old `globalState` blob:\n * { version: 1, claimed: false, publicKeyFingerprint: null }\n * In that case the file is migrated forward to the v1 schema on the\n * next write (the data fields are empty and a fresh enroll is required).\n * The full Extension `globalState` → JSON migration happens in the\n * Phase 5.5 adapter (`apps/extension/.../DeviceService.ts`) since the\n * adapter holds the live `globalState` access.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `IdentityStore.ts 持久化到 ~/.config/serviceme/device.json, 原子写`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1, §2.5\n */\n\nimport * as fsp from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\n\nimport { getDeviceJsonPath, getServicemeHome } from \"../paths/userHome\";\n\nimport {\n\ttype AtomicWriteResult,\n\tDEVICE_JSON_SCHEMA_VERSION,\n\ttype IdentityStoreHooks,\n\ttype PersistedDeviceIdentity,\n} from \"./types\";\n\nconst FILE_MODE = 0o600;\nconst LOCK_DIR_MODE = 0o700;\nconst DEFAULT_LOCK_TIMEOUT_MS = 5000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\n// `mkdir` (lock acquisition) and writing the pid file are two separate\n// syscalls, so there's a brief window where the lock dir exists but the\n// pid file doesn't yet. A grace period keeps a concurrent acquirer from\n// mistaking that window for an abandoned lock (see `isStaleLock`).\nconst LOCK_STALE_GRACE_MS = 200;\nconst TMP_SUFFIX = \".tmp\";\n\n/**\n * Minimal interface for reading + writing the persisted identity file.\n * Default impl uses `getDeviceJsonPath()` (which honors `SERVICEME_HOME`),\n * but tests can substitute a custom path for isolation.\n */\nexport interface IdentityFileBackend {\n\tread(filePath: string): Promise<PersistedDeviceIdentity | null>;\n\twrite(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult>;\n\texists(filePath: string): Promise<boolean>;\n\tdelete(filePath: string): Promise<void>;\n\tlistDir?(dir: string): Promise<string[]>;\n}\n\nexport interface IdentityStoreOptions {\n\tfilePath?: string;\n\thooks?: IdentityStoreHooks;\n\tlockTimeoutMs?: number;\n\tlockRetryMs?: number;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\tbackend?: IdentityFileBackend;\n}\n\n/**\n * Default file backend — uses `node:fs/promises` with the canonical\n * tmp-then-rename atomic-write pattern.\n */\nexport class FsIdentityFileBackend implements IdentityFileBackend {\n\tasync exists(filePath: string): Promise<boolean> {\n\t\ttry {\n\t\t\tawait fsp.access(filePath);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync read(filePath: string): Promise<PersistedDeviceIdentity | null> {\n\t\ttry {\n\t\t\tconst buf = await fsp.readFile(filePath, \"utf8\");\n\t\t\tconst parsed = JSON.parse(buf) as unknown;\n\t\t\treturn migratePersistedIdentity(parsed);\n\t\t} catch (err) {\n\t\t\tif (isNodeError(err) && err.code === \"ENOENT\") return null;\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\tasync write(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait fsp.mkdir(path.dirname(filePath), { recursive: true });\n\t\tconst tmpPath = `${filePath}${TMP_SUFFIX}`;\n\t\tconst bytes = Buffer.from(JSON.stringify(payload, null, \"\\t\"), \"utf8\");\n\t\t// Ensure tmp is fresh (in case a previous run died mid-write).\n\t\tawait fsp.rm(tmpPath, { force: true });\n\t\tconst handle = await fsp.open(tmpPath, \"w\", FILE_MODE);\n\t\ttry {\n\t\t\tawait handle.writeFile(bytes);\n\t\t\tawait handle.sync();\n\t\t} finally {\n\t\t\tawait handle.close();\n\t\t}\n\t\tawait fsp.rename(tmpPath, filePath);\n\t\t// Best-effort chmod for filesystems that ignore mode on create (Windows).\n\t\tawait fsp.chmod(filePath, FILE_MODE).catch(() => undefined);\n\t\treturn { bytesWritten: bytes.byteLength, tmpPath };\n\t}\n\n\tasync delete(filePath: string): Promise<void> {\n\t\tawait fsp.rm(filePath, { force: true });\n\t}\n}\n\n/**\n * Reconcile an unknown on-disk shape into the current `PersistedDeviceIdentity`.\n *\n * - v1 IdentityStore files (current shape) pass through unchanged.\n * - v0 bootstrap files (`{ version: 1, claimed: false, publicKeyFingerprint: null }`)\n * are recognized by their placeholder keys and discarded; the next\n * enroll writes a fresh identity.\n * - Anything else throws — refuse to silently drop user data.\n */\nfunction migratePersistedIdentity(parsed: unknown): PersistedDeviceIdentity | null {\n\tif (!isRecord(parsed)) {\n\t\tthrow new Error(\"device.json: top-level must be an object\");\n\t}\n\tconst version = parsed.version;\n\tif (version === DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 placeholder shape carries `claimed` /\n\t\t// `publicKeyFingerprint` but no real device fields. Recognize\n\t\t// the marker and return null so the next enroll writes fresh data.\n\t\tif (\n\t\t\tparsed.publicId === undefined &&\n\t\t\tparsed.deviceSecret === undefined &&\n\t\t\t(\"claimed\" in parsed || \"publicKeyFingerprint\" in parsed)\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\t// Trust the schema — the writer is also us.\n\t\treturn parsed as unknown as PersistedDeviceIdentity;\n\t}\n\tif (typeof version === \"number\" && version < DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 bootstrap shape — the file is empty placeholder\n\t\t// data; nothing to migrate. Return null to signal \"no identity\".\n\t\treturn null;\n\t}\n\tthrow new Error(`device.json: unsupported schema version ${String(version)}`);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null;\n}\n\nfunction isNodeError(value: unknown): value is NodeJS.ErrnoException {\n\treturn value instanceof Error && typeof (value as { code?: unknown }).code === \"string\";\n}\n\nconst LOCK_PID_FILE = \"pid\";\n\n/**\n * Check whether a process is still alive (best-effort, cross-platform).\n * Returns `false` for any PID we cannot verify as alive.\n */\nfunction isProcessAlive(pid: number): boolean {\n\ttry {\n\t\t// signal 0 — permission check only, never actually sent\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * mkdir-based advisory file lock with stale-lock recovery.\n *\n * POSIX mkdir is atomic; on Windows modern filesystems (NTFS) it's also\n * atomic at the API level. Sufficient for single-host, single-user\n * scenarios (which is the SERVICEME threat model).\n *\n * Stale lock recovery: a `pid` file inside the lock directory records the\n * owner's PID. On `EEXIST`, if the recorded PID is no longer alive, the\n * lock directory is forcibly removed and acquisition retried immediately.\n * This prevents permanent lockout when a process crashes without calling\n * `release()`.\n */\nclass FileLock {\n\tprivate readonly dirPath: string;\n\tprivate readonly pidFilePath: string;\n\tprivate readonly timeoutMs: number;\n\tprivate readonly retryMs: number;\n\tprivate acquired = false;\n\n\tconstructor(filePath: string, timeoutMs: number, retryMs: number) {\n\t\tthis.dirPath = `${filePath}.lock`;\n\t\tthis.pidFilePath = path.join(this.dirPath, LOCK_PID_FILE);\n\t\tthis.timeoutMs = timeoutMs;\n\t\tthis.retryMs = retryMs;\n\t}\n\n\tasync acquire(): Promise<void> {\n\t\tconst start = Date.now();\n\t\twhile (true) {\n\t\t\ttry {\n\t\t\t\tawait fsp.mkdir(this.dirPath, { mode: LOCK_DIR_MODE });\n\t\t\t\t// Write PID so a future acquirer can detect if we crash.\n\t\t\t\tawait fsp.writeFile(this.pidFilePath, String(process.pid), \"utf8\").catch(() => undefined);\n\t\t\t\tthis.acquired = true;\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tif (!isNodeError(err) || err.code !== \"EEXIST\") {\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t\t// Lock directory exists — check for stale owner.\n\t\t\t\tconst stale = await this.isStaleLock();\n\t\t\t\tif (stale) {\n\t\t\t\t\tawait fsp.rm(this.dirPath, { recursive: true, force: true });\n\t\t\t\t\t// Retry immediately without counting this iteration against timeout.\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (Date.now() - start >= this.timeoutMs) {\n\t\t\t\t\tthrow new Error(`IdentityStore lock acquisition timed out for ${this.dirPath}`);\n\t\t\t\t}\n\t\t\t\tawait delay(this.retryMs);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async isStaleLock(): Promise<boolean> {\n\t\tlet pidStr: string;\n\t\ttry {\n\t\t\tpidStr = await fsp.readFile(this.pidFilePath, \"utf8\");\n\t\t} catch {\n\t\t\t// The pid file may not exist yet because another acquirer just\n\t\t\t// created the lock dir and hasn't finished writing its pid file\n\t\t\t// (mkdir + writeFile is not atomic). Give it a short grace window\n\t\t\t// before concluding the owner crashed between mkdir and writeFile.\n\t\t\ttry {\n\t\t\t\tconst stat = await fsp.stat(this.dirPath);\n\t\t\t\treturn Date.now() - stat.mtimeMs > LOCK_STALE_GRACE_MS;\n\t\t\t} catch {\n\t\t\t\t// Lock dir disappeared concurrently (e.g. released mid-check) —\n\t\t\t\t// not stale, just gone; the caller's next mkdir will succeed.\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\tconst pid = Number.parseInt(pidStr.trim(), 10);\n\t\tif (!Number.isFinite(pid) || pid <= 0) return true; // malformed pid file → treat as stale\n\t\treturn !isProcessAlive(pid);\n\t}\n\n\tasync release(): Promise<void> {\n\t\tif (!this.acquired) return;\n\t\tthis.acquired = false;\n\t\tawait fsp.rm(this.dirPath, { recursive: true, force: true });\n\t}\n}\n\nexport class IdentityStore {\n\tprivate readonly filePath: string;\n\tprivate readonly backend: IdentityFileBackend;\n\tprivate readonly hooks: IdentityStoreHooks;\n\tprivate readonly lockTimeoutMs: number;\n\tprivate readonly lockRetryMs: number;\n\n\tconstructor(opts: IdentityStoreOptions = {}) {\n\t\tthis.filePath = opts.filePath ?? getDeviceJsonPath();\n\t\tthis.backend = opts.backend ?? new FsIdentityFileBackend();\n\t\tthis.hooks = opts.hooks ?? {};\n\t\tthis.lockTimeoutMs = opts.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n\t\tthis.lockRetryMs = opts.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;\n\t}\n\n\t/** Absolute path to the underlying JSON file (test seam). */\n\tgetFilePath(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** True when the JSON file already exists on disk. */\n\tasync exists(): Promise<boolean> {\n\t\treturn this.backend.exists(this.filePath);\n\t}\n\n\t/** Read the persisted identity; returns `null` when no identity is stored. */\n\tasync read(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.backend.read(this.filePath);\n\t}\n\n\t/**\n\t * Atomically write the given identity. Concurrent writers are\n\t * serialized via the file lock; the read-modify-write happens\n\t * inside the lock so callers can't see a partial state.\n\t */\n\tasync write(next: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait this.hooks.beforeWrite?.(next);\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tconst result = await this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/**\n\t * Read-modify-write under the same lock. The mutator receives the\n\t * current identity (or `null` on first call) and returns the\n\t * replacement. Throwing inside the mutator aborts the write.\n\t */\n\tasync mutate<T>(\n\t\tmutator: (\n\t\t\tcurrent: PersistedDeviceIdentity | null\n\t\t) => Promise<{ next: PersistedDeviceIdentity; result?: T }>\n\t): Promise<{ result: T | undefined; written: PersistedDeviceIdentity }> {\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst current = await this.backend.read(this.filePath);\n\t\t\tconst { next, result } = await mutator(current);\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tawait this.hooks.beforeWrite?.(stamped);\n\t\t\tawait this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn { result, written: stamped };\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/** Wipe the persisted identity (used by `device.enroll --force`). */\n\tasync clear(): Promise<void> {\n\t\tawait this.backend.delete(this.filePath);\n\t}\n\n\t/**\n\t * Resolve the installation metadata for the current machine.\n\t * Pure helper — no I/O, just `os.*` calls.\n\t */\n\tresolveInstallationMetadata(): Pick<\n\t\tPersistedDeviceIdentity,\n\t\t\"installationId\" | \"machineId\" | \"platform\"\n\t> {\n\t\tconst machineId = os.hostname();\n\t\tconst platform = os.platform();\n\t\t// installationId is derived by `InstallationId.ts` — pass the\n\t\t// caller's already-computed value via `material` so we don't\n\t\t// recompute the SHA twice in a row.\n\t\treturn {\n\t\t\tinstallationId: \"\", // intentionally empty; caller fills via deriveInstallationId()\n\t\t\tmachineId,\n\t\t\tplatform,\n\t\t};\n\t}\n\n\t/**\n\t * Ensure the parent directory exists (`~/.serviceme/`). Idempotent.\n\t * Useful when the bootstrap phase5 placeholder wasn't run yet.\n\t */\n\tasync ensureHome(): Promise<void> {\n\t\tawait fsp.mkdir(getServicemeHome(), { recursive: true });\n\t\tawait fsp.mkdir(path.dirname(this.filePath), { recursive: true });\n\t}\n}\n","/**\n * DeviceCore — Main entry for the device domain.\n *\n * Aggregates `IdentityStore` + `Enroller` + signer helpers into a single\n * surface that the CLI / Extension / Bridge can call. Pure orchestration\n * — no HTTP of its own (the actual `POST /api/v1/devices/enroll` lives\n * behind `EnrollerOptions.enrollRequest`, wired in Phase 5.4).\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `DeviceCore.ts 主入口`\n * - ADL-003 — data model in `@serviceme/devtools-protocol`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1\n */\n\nimport type {\n\tDeviceEnrollResult,\n\tDeviceIdentityState,\n\tDeviceMetadata,\n\tDeviceRotateSecretResult,\n\tDeviceStatus,\n} from \"@serviceme/devtools-protocol\";\nimport { buildSignedHeaders, DeviceAuthHeaders, type DeviceSignedHeaders } from \"./deviceAuth\";\nimport { Enroller, type EnrollRequestFn } from \"./Enroller\";\nimport { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\nexport interface DeviceCoreOptions {\n\tidentityStore?: IdentityStore;\n\tenrollRequest?: EnrollRequestFn;\n\tnow?: () => Date;\n\t/** Optional override for `deriveInstallationId` (used by tests for determinism). */\n\tresolveInstallationId?: () => string;\n}\n\nexport class DeviceCore {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly enroller: Enroller;\n\tprivate readonly resolveInstallationId: () => string;\n\n\tconstructor(opts: DeviceCoreOptions = {}) {\n\t\tthis.identity = opts.identityStore ?? new IdentityStore();\n\t\tthis.enroller = new Enroller({\n\t\t\tidentityStore: this.identity,\n\t\t\tenrollRequest: opts.enrollRequest,\n\t\t\tnow: opts.now,\n\t\t});\n\t\tthis.resolveInstallationId = opts.resolveInstallationId ?? deriveInstallationId;\n\t}\n\n\t/** Read-only snapshot of the device status (matches `device.status` wire shape). */\n\tasync status(): Promise<DeviceStatus> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored\n\t\t\t? {\n\t\t\t\t\tbindingState: stored.bindingState,\n\t\t\t\t\tidentity: projectIdentity(stored),\n\t\t\t\t\tmetadata: projectMetadata(stored),\n\t\t\t\t\tlastSyncAt: stored.lastSyncAt,\n\t\t\t\t\tlastSyncError: stored.lastSyncError,\n\t\t\t\t}\n\t\t\t: { bindingState: \"anonymous\" };\n\t}\n\n\t/** Enroll (or re-enroll) the device. */\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\treturn this.enroller.enroll(opts);\n\t}\n\n\t/** Wait for any in-flight enrollment to finish. Use before `buildSignedHeaders` so that a concurrent `syncDeviceInfo` enrollment has time to write the identity to the store. */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tawait this.enroller.waitForEnrollment();\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.enroller.isEnrolling();\n\t}\n\n\t/** Rotate the HMAC secret while keeping the previous one for the grace window. */\n\tasync rotateSecret(opts: { gracePeriodDays?: number } = {}): Promise<DeviceRotateSecretResult> {\n\t\tconst result = await this.enroller.rotateSecret(opts);\n\t\tconst stored = await this.identity.read();\n\t\treturn {\n\t\t\t...result,\n\t\t\tgracePeriodEndsAt: stored?.previousSecretExpiresAt,\n\t\t};\n\t}\n\n\t/** Build the v2 signed header map for an outbound request. The path\n\t * MUST include the query string (v2 signs it). */\n\tasync buildSignedHeaders(input: {\n\t\tmethod: string;\n\t\tpath: string;\n\t\tbody: string;\n\t}): Promise<DeviceSignedHeaders | null> {\n\t\tconst stored = await this.identity.read();\n\t\tif (!stored) return null;\n\t\treturn buildSignedHeaders({\n\t\t\tmethod: input.method,\n\t\t\tpath: input.path,\n\t\t\tbody: input.body,\n\t\t\tpublicId: stored.publicId,\n\t\t\tdeviceSecret: stored.deviceSecret,\n\t\t\tsecretVersion: stored.secretVersion,\n\t\t});\n\t}\n\n\t/** Raw stored identity (CLI/extension internal use). Test seam too. */\n\tasync readIdentity(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.identity.read();\n\t}\n\n\t/** Wipe the local identity (the `--force` path before re-enroll). */\n\tasync clear(): Promise<void> {\n\t\tawait this.identity.clear();\n\t}\n\n\t/** Mark the device as claimed (called by the bridge after a successful claim). */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.enroller.markClaimed();\n\t}\n\n\t/** Mark the device as expired (server returned an expiry response). */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.enroller.markExpired();\n\t}\n\n\t/** Expose the identity store (CLI uses it for direct file access in tests). */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** Expose the enroller (CLI uses it for state inspection). */\n\tgetEnroller(): Enroller {\n\t\treturn this.enroller;\n\t}\n\n\t/** Header name constants — re-exported from `deviceAuth.ts`. */\n\tgetHeaderNames(): typeof DeviceAuthHeaders {\n\t\treturn DeviceAuthHeaders;\n\t}\n\n\t/** Compute the installation id for the current machine. */\n\tgetInstallationId(): string {\n\t\treturn this.resolveInstallationId();\n\t}\n}\n\nfunction projectIdentity(stored: PersistedDeviceIdentity): DeviceIdentityState {\n\treturn {\n\t\tpublicId: stored.publicId,\n\t\tsecretVersion: stored.secretVersion,\n\t\tbindingState: stored.bindingState,\n\t};\n}\n\nfunction projectMetadata(stored: PersistedDeviceIdentity): DeviceMetadata {\n\treturn {\n\t\tinstallationId: stored.installationId,\n\t\tmachineId: stored.machineId,\n\t\tplatform: stored.platform,\n\t\thostname: stored.hostname,\n\t};\n}\n"],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -781,6 +781,31 @@ function isDirectLinkRoot(workspaceDir) {
|
|
|
781
781
|
* suffix convention for agent files. File entries keep the SOURCE basename
|
|
782
782
|
* (flat agents must carry the `.agent.md` suffix to be recognized).
|
|
783
783
|
*
|
|
784
|
+
* Plugins that declare NO `extensions` (agent-plugins.org convention
|
|
785
|
+
* shape, e.g. cloud-arch-diagram-plugin) keep ALL their content inside
|
|
786
|
+
* the plugin dir under the same top-level conventions (`skills/`,
|
|
787
|
+
* `agents/*.agent.md`). The schema-less served manifest makes VS Code
|
|
788
|
+
* convention-scan exactly those paths, so they are projected wholesale
|
|
789
|
+
* (see copyConventionContent) even though nothing references them —
|
|
790
|
+
* without this such a plugin loads as an empty shell (manifest +
|
|
791
|
+
* mcp.json only).
|
|
792
|
+
*
|
|
793
|
+
* FORMAT DISCOVERY (verified in the VS Code workbench bundle): the
|
|
794
|
+
* format is picked from the LAYOUT, in this order — (1) root plugin.json
|
|
795
|
+
* with an agent-plugins.org `$schema` → format 3 (spec component paths,
|
|
796
|
+
* agents under `com.github.copilot/agents/`); (2) `.plugin/plugin.json`
|
|
797
|
+
* present → format 2 (Claude-style); (3) `.claude-plugin/plugin.json`
|
|
798
|
+
* → format 1; (4) manifest-less fallback → format 0. Our projection is
|
|
799
|
+
* always format 2: schema-less root + `.plugin/plugin.json`. Format 2
|
|
800
|
+
* convention paths are the TOP-LEVEL dirs — `commands/`, `skills/`,
|
|
801
|
+
* `agents/`, `rules/`, `automations/`, `hooks/hooks.json` — and MCP
|
|
802
|
+
* servers come from the `.mcp.json` FILE (dot-prefixed) or a
|
|
803
|
+
* `mcpServers` manifest field; bare `mcp.json` is a format-3-only path
|
|
804
|
+
* and would silently register NOTHING under format 2. `scripts/` is no
|
|
805
|
+
* discovery dir but is runtime support for MCP/hook commands: stdio
|
|
806
|
+
* servers run with the plugin root as defaultCwd and `${PLUGIN_ROOT}`
|
|
807
|
+
* resolves to the projection root, so their scripts must be present.
|
|
808
|
+
*
|
|
784
809
|
* The served `plugin.json` is deliberately SCHEMA-LESS and identity-only
|
|
785
810
|
* (see SERVED_SPEC_FIELDS): a schema-bearing manifest switches VS Code to
|
|
786
811
|
* the Agent Plugins spec component paths, which read agents from
|
|
@@ -897,6 +922,41 @@ function servedPluginManifest(raw) {
|
|
|
897
922
|
return served;
|
|
898
923
|
}
|
|
899
924
|
/**
|
|
925
|
+
* Copy the convention-based content directories VS Code recognizes for a
|
|
926
|
+
* schema-less (format 2, `.plugin/`-manifest) local plugin: the top-level
|
|
927
|
+
* `skills/`, `agents/`, `commands/`, `rules/` and `automations/` trees
|
|
928
|
+
* plus `hooks/` (whose `hooks/hooks.json` format 2 reads directly).
|
|
929
|
+
* `scripts/` is copied too — not a discovery dir, but the runtime support
|
|
930
|
+
* location MCP/hook commands execute from (defaultCwd = plugin root,
|
|
931
|
+
* `${PLUGIN_ROOT}` → projection root). Plugins following the
|
|
932
|
+
* agent-plugins.org convention carry ALL their content in these dirs with
|
|
933
|
+
* no `extensions` composition in plugin.json — and the schema-less served
|
|
934
|
+
* manifest routes VS Code to a convention scan of exactly these paths, so
|
|
935
|
+
* a projection without them has zero capabilities.
|
|
936
|
+
*
|
|
937
|
+
* Runs BEFORE the `extensions` loop: declared entries re-copy over with
|
|
938
|
+
* plugin-dir-first source resolution, so an explicitly declared entry at
|
|
939
|
+
* a convention path converges to the same bytes instead of being clobbered
|
|
940
|
+
* by a repo-root composition source.
|
|
941
|
+
*/
|
|
942
|
+
const CONVENTION_DIRS = [
|
|
943
|
+
"skills",
|
|
944
|
+
"agents",
|
|
945
|
+
"commands",
|
|
946
|
+
"rules",
|
|
947
|
+
"automations",
|
|
948
|
+
"hooks",
|
|
949
|
+
"scripts"
|
|
950
|
+
];
|
|
951
|
+
async function copyConventionContent(pluginDir, targetDir, result) {
|
|
952
|
+
for (const dir of CONVENTION_DIRS) {
|
|
953
|
+
const source = node_path.join(pluginDir, dir);
|
|
954
|
+
if (!(await node_fs_promises.stat(source).catch(() => void 0))?.isDirectory()) continue;
|
|
955
|
+
await copyProjectionEntry(source, node_path.join(targetDir, dir));
|
|
956
|
+
if (!result.entries.includes(dir)) result.entries.push(dir);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
900
960
|
* Build (or refresh) the loadable projection at `targetDir`. Idempotent:
|
|
901
961
|
* the previous projection is replaced wholesale so re-registering after a
|
|
902
962
|
* checkout pull re-points every entry. Returns `materialized: false` when
|
|
@@ -922,6 +982,7 @@ async function materializePluginProjection(input) {
|
|
|
922
982
|
recursive: true
|
|
923
983
|
});
|
|
924
984
|
await node_fs_promises.mkdir(targetDir, { recursive: true });
|
|
985
|
+
await copyConventionContent(pluginDir, targetDir, result);
|
|
925
986
|
for (const ref of collectRefs(raw.extensions)) {
|
|
926
987
|
const source = await resolveSource(ref.entry, pluginDir, repoRoot);
|
|
927
988
|
if (!source) {
|
|
@@ -950,7 +1011,10 @@ async function materializePluginProjection(input) {
|
|
|
950
1011
|
const pluginMarkerDir = inside(PLUGIN_MARKER_DIR);
|
|
951
1012
|
await node_fs_promises.mkdir(pluginMarkerDir, { recursive: true });
|
|
952
1013
|
await node_fs_promises.writeFile(node_path.join(pluginMarkerDir, "plugin.json"), `${JSON.stringify(servedPluginManifest(raw), null, 2)}\n`);
|
|
953
|
-
if ((await node_fs_promises.stat(node_path.join(pluginDir, "mcp.json")).catch(() => void 0))?.isFile())
|
|
1014
|
+
if ((await node_fs_promises.stat(node_path.join(pluginDir, "mcp.json")).catch(() => void 0))?.isFile()) {
|
|
1015
|
+
await copyProjectionEntry(node_path.join(pluginDir, "mcp.json"), inside("mcp.json"));
|
|
1016
|
+
await copyProjectionEntry(node_path.join(pluginDir, "mcp.json"), inside(".mcp.json"));
|
|
1017
|
+
}
|
|
954
1018
|
const marker = {
|
|
955
1019
|
servicemeMaterialized: true,
|
|
956
1020
|
pluginDir,
|
|
@@ -2909,7 +2973,7 @@ async function resolveWorkspaceContentPlan(input) {
|
|
|
2909
2973
|
*/
|
|
2910
2974
|
async function resolvePluginEntriesLenient(input) {
|
|
2911
2975
|
const namespace = input.manifest.extensions[AWESOME_COPILOT_NAMESPACE];
|
|
2912
|
-
if (!namespace || typeof namespace !== "object") return resolveConventionEntries(
|
|
2976
|
+
if (!namespace || typeof namespace !== "object") return resolveConventionEntries(node_path.join(input.repoRoot, "plugins", input.pluginId), input.repositoryId, input.pluginId, void 0, input.manifest);
|
|
2913
2977
|
const entries = [];
|
|
2914
2978
|
for (const [kind, manifestKey] of Object.entries(PATH_KINDS)) {
|
|
2915
2979
|
if (kind === "mcp" || kind === "hook") continue;
|
|
@@ -2946,16 +3010,18 @@ function selectedKind(artifacts, kind) {
|
|
|
2946
3010
|
* skills/<name>/SKILL.md · agents/<name>.agent.md ·
|
|
2947
3011
|
* rules/<name>.instructions.md · commands/<name>.prompt.md ·
|
|
2948
3012
|
* hooks/hooks.json
|
|
2949
|
-
*
|
|
2950
|
-
*
|
|
3013
|
+
* Scoped to the plugin dir ONLY. Repo-root content is never attributed
|
|
3014
|
+
* to the package: awesome-copilot style root layouts are covered by the
|
|
3015
|
+
* declared-path branch (namespace manifests), and the legacy no-manifest
|
|
3016
|
+
* flow has its own plugin-id-named root lookups. Used when a manifest
|
|
3017
|
+
* carries no `com.github.awesome-copilot` namespace at all.
|
|
2951
3018
|
*/
|
|
2952
|
-
async function resolveConventionEntries(
|
|
3019
|
+
async function resolveConventionEntries(pluginDir, repositoryId, pluginId, artifacts, manifest) {
|
|
2953
3020
|
const entries = [];
|
|
2954
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2955
3021
|
const wants = artifacts === void 0 || Object.keys(artifacts).length === 0 ? () => true : (kind) => artifacts[kind] === true;
|
|
2956
|
-
const push = async (kind,
|
|
3022
|
+
const push = async (kind, subdir, options = {}) => {
|
|
2957
3023
|
if (!wants(kind)) return;
|
|
2958
|
-
const dir = node_path.join(
|
|
3024
|
+
const dir = node_path.join(pluginDir, subdir);
|
|
2959
3025
|
const dirents = await node_fs_promises.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
2960
3026
|
for (const dirent of dirents) {
|
|
2961
3027
|
let sourcePath;
|
|
@@ -2964,27 +3030,18 @@ async function resolveConventionEntries(repoRoot, pluginDir, repositoryId, plugi
|
|
|
2964
3030
|
sourcePath = node_path.join(dir, dirent.name);
|
|
2965
3031
|
} else if (options.files !== false && dirent.isFile()) sourcePath = node_path.join(dir, dirent.name);
|
|
2966
3032
|
else continue;
|
|
2967
|
-
if (seen.has(sourcePath)) continue;
|
|
2968
|
-
seen.add(sourcePath);
|
|
2969
3033
|
entries.push(await buildEntry(repositoryId, pluginId, kind, sourcePath, !options.dirs, manifest));
|
|
2970
3034
|
}
|
|
2971
3035
|
};
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
}
|
|
2982
|
-
const byTarget = /* @__PURE__ */ new Map();
|
|
2983
|
-
for (const entry of entries) {
|
|
2984
|
-
const key = `${entry.kind}:${entry.name}`;
|
|
2985
|
-
if (!byTarget.has(key)) byTarget.set(key, entry);
|
|
2986
|
-
}
|
|
2987
|
-
return [...byTarget.values()];
|
|
3036
|
+
await push("skill", "skills", {
|
|
3037
|
+
dirs: true,
|
|
3038
|
+
files: false
|
|
3039
|
+
});
|
|
3040
|
+
await push("agent", "agents");
|
|
3041
|
+
await push("instruction", "rules");
|
|
3042
|
+
await push("prompt", "commands");
|
|
3043
|
+
await push("hook", "hooks");
|
|
3044
|
+
return entries;
|
|
2988
3045
|
}
|
|
2989
3046
|
/**
|
|
2990
3047
|
* Repo-root-relative source for a declared plugin path, applying the
|
|
@@ -3005,7 +3062,7 @@ async function resolveDeclaredRelative(repoRoot, relative) {
|
|
|
3005
3062
|
}
|
|
3006
3063
|
async function resolvePluginManifestEntries(repoRoot, repositoryId, pluginId, artifacts, pluginManifest) {
|
|
3007
3064
|
const namespace = pluginManifest.extensions[AWESOME_COPILOT_NAMESPACE];
|
|
3008
|
-
if (!namespace || typeof namespace !== "object") return resolveConventionEntries(
|
|
3065
|
+
if (!namespace || typeof namespace !== "object") return resolveConventionEntries(node_path.join(repoRoot, "plugins", pluginId), repositoryId, pluginId, artifacts, pluginManifest);
|
|
3009
3066
|
const entries = [];
|
|
3010
3067
|
for (const [kind, manifestKey] of Object.entries(PATH_KINDS)) {
|
|
3011
3068
|
if (!selectedKind(artifacts, kind)) continue;
|