@nurix/nustack 0.23.12 → 0.23.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/commands/agent.js +1 -53
  2. package/dist/commands/check.js +73 -0
  3. package/dist/commands/deploy.js +2 -1
  4. package/dist/commands/doctor.js +2 -1
  5. package/dist/commands/harness.js +127 -48
  6. package/dist/commands/init.js +103 -3
  7. package/dist/commands/login.js +9 -2
  8. package/dist/commands/mcp.js +7 -0
  9. package/dist/commands/project-sync.js +2 -1
  10. package/dist/commands/read.js +19 -0
  11. package/dist/commands/records.js +10 -0
  12. package/dist/commands/review.js +3 -2
  13. package/dist/commands/sessions.js +4 -1
  14. package/dist/commands/sync.js +2 -1
  15. package/dist/index.js +4 -0
  16. package/dist/lib/agent/claudeHooks.js +4 -1
  17. package/dist/lib/agent/desktopDoor.js +55 -0
  18. package/dist/lib/auth_grant.js +15 -3
  19. package/dist/lib/auth_recovery.js +10 -3
  20. package/dist/lib/etna/manager.js +24 -6
  21. package/dist/lib/etna/profile.js +6 -0
  22. package/dist/lib/etna/reconcile.js +6 -0
  23. package/dist/lib/gate/bulkReadRelay.js +354 -0
  24. package/dist/lib/gate/hook.js +30 -3
  25. package/dist/lib/gate/outline.js +12 -0
  26. package/dist/lib/gate/settings.js +24 -1
  27. package/dist/lib/gate/tierOne.js +22 -0
  28. package/dist/lib/harness/codes.js +24 -0
  29. package/dist/lib/harness/hookCheck.js +325 -0
  30. package/dist/lib/harness/hookEntry.js +27 -0
  31. package/dist/lib/harness/legacyMount.js +231 -0
  32. package/dist/lib/harness/mount.js +199 -131
  33. package/dist/lib/harness/storeWaivers.js +43 -0
  34. package/dist/lib/harness/vendor.js +29 -0
  35. package/dist/lib/harness/versionsClient.js +54 -0
  36. package/dist/lib/local_store/database.js +18 -0
  37. package/dist/lib/local_store/routing.js +26 -0
  38. package/dist/lib/nustack_client.js +35 -2
  39. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  40. package/dist/lib/onboarding/binding.js +46 -2
  41. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  42. package/dist/lib/onboarding/manifest_writer.js +19 -3
  43. package/dist/lib/onboarding/session_hook.js +76 -0
  44. package/dist/lib/origin_guard.js +43 -0
  45. package/dist/lib/project_contract.js +104 -0
  46. package/dist/lib/records/door.js +26 -19
  47. package/dist/lib/records/drain.js +10 -4
  48. package/dist/lib/records/refusalLog.js +30 -1
  49. package/dist/lib/session-engine/supervisor.js +6 -18
  50. package/dist/lib/sessions/client.js +0 -12
  51. package/dist/program.js +27 -4
  52. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  53. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  54. package/dist/vendor/harness/digest/index.js +38 -0
  55. package/dist/vendor/harness/lib/findings.js +32 -0
  56. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  57. package/dist/vendor/harness/lib/paths.js +31 -0
  58. package/dist/vendor/harness/mount/cache.js +65 -0
  59. package/dist/vendor/harness/mount/exclude.js +77 -0
  60. package/dist/vendor/harness/mount/files.js +38 -0
  61. package/dist/vendor/harness/mount/index.js +352 -0
  62. package/dist/vendor/harness/rules/changeRules.js +162 -0
  63. package/dist/vendor/harness/rules/context.js +38 -0
  64. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  65. package/dist/vendor/harness/rules/index.js +109 -0
  66. package/dist/vendor/harness/rules/node.js +144 -0
  67. package/dist/vendor/harness/rules/outputGate.js +28 -0
  68. package/dist/vendor/harness/rules/ownership.js +178 -0
  69. package/dist/vendor/harness/rules/packageTable.js +113 -0
  70. package/dist/vendor/harness/rules/plans.js +96 -0
  71. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  72. package/package.json +5 -5
@@ -6,6 +6,9 @@ import { readHeadCommit } from "../git-sync/core/repository.js";
6
6
  import { writeProjectManifests } from "./manifest_writer.js";
7
7
  import { installToolkit } from "../etna/manager.js";
8
8
  import { serializeProfileForBinding } from "../etna/profile.js";
9
+ import { contractFieldsFromSummary, readProjectContract } from "../project_contract.js";
10
+ import { hasEtnaMountHook, installHarnessMountHook } from "./session_hook.js";
11
+ import { installCommitTrailerHook, trailerHookLine } from "./commit_trailer_hook.js";
9
12
  import { SYNC_PROTOCOL_VERSION } from "../git-sync/whitelist/types.js";
10
13
  export function computeRepositoryFingerprint(repository) {
11
14
  return createHash("sha256").update(repository.commonDir).digest("hex");
@@ -49,7 +52,19 @@ async function maybeRunTelemetry(input, binding) {
49
52
  return;
50
53
  await ensureBindingTelemetry({ baseUrl: input.baseUrl, store: input.store, binding }).catch(() => { });
51
54
  }
55
+ export async function onboardingContractFields(root, baseUrl, profile) {
56
+ const held = await readProjectContract(root);
57
+ const fromSummary = profile.contractFields ?? contractFieldsFromSummary(null);
58
+ return {
59
+ origin: baseUrl,
60
+ door: baseUrl,
61
+ harness: fromSummary.harness,
62
+ branchPolicy: fromSummary.branchPolicy ?? null,
63
+ docsRoot: held.docsRoot ?? "docs",
64
+ };
65
+ }
52
66
  export async function runRepositoryOnboarding(input) {
67
+ const root = input.repository.repositoryRoot;
53
68
  const baselineCommitId = input.baselineCommitId !== undefined ? input.baselineCommitId : readHeadCommit(input.repository);
54
69
  const established = await establishBinding({
55
70
  store: input.store,
@@ -64,8 +79,7 @@ export async function runRepositoryOnboarding(input) {
64
79
  etnaProfileJson: serializeProfileForBinding(input.profile),
65
80
  runTelemetry: input.runTelemetry,
66
81
  });
67
- const install = await installToolkit(input.store, established.binding.id, input.profile, input.repository.repositoryRoot, input.toolkitDeps ?? {});
68
- const projectManifest = await writeProjectManifests(input.repository.repositoryRoot, {
82
+ const projectManifest = await writeProjectManifests(root, {
69
83
  projectId: input.projectId,
70
84
  organizationId: input.organizationId,
71
85
  toolkit: {
@@ -74,7 +88,33 @@ export async function runRepositoryOnboarding(input) {
74
88
  rendererVersion: input.profile.rendererVersion,
75
89
  placementMapVersion: input.profile.placementMapVersion,
76
90
  },
91
+ contract: await onboardingContractFields(root, input.baseUrl, input.profile),
92
+ });
93
+ const notices = [];
94
+ const etnaMounts = await hasEtnaMountHook(root);
95
+ let sessionHook = "skipped_etna_mount";
96
+ if (etnaMounts) {
97
+ notices.push("etna's mount hook is in .claude/settings.json, so NuStack installed no session-start hook and mounted nothing: remove etna's SessionStart entry (.claude/hooks/mount.mjs), then re-run `nustack init`.");
98
+ }
99
+ else {
100
+ sessionHook = await installHarnessMountHook(root);
101
+ if (sessionHook === "skipped_unparsable")
102
+ notices.push(".claude/settings.json does not parse as JSON, so the session-start mount hook was not installed; the file was left untouched.");
103
+ }
104
+ const install = await installToolkit(input.store, established.binding.id, input.profile, root, {
105
+ ...(input.toolkitDeps ?? {}),
106
+ skipMount: etnaMounts,
107
+ writeContract: "when-unmodified",
108
+ projectId: input.projectId,
109
+ globals: { nustackUrl: input.baseUrl },
77
110
  });
111
+ const mount = install.mount ?? { status: "failed", code: "HARNESS_UNAVAILABLE", message: install.reason ?? "no harness to mount" };
112
+ if (mount.status === "failed")
113
+ notices.push(`harness not mounted (${mount.code}): ${mount.message ?? ""} — the next session start retries it.`);
114
+ const trailerHook = await installCommitTrailerHook(root);
115
+ const trailerLine = trailerHookLine(trailerHook);
116
+ if (trailerLine)
117
+ notices.push(trailerLine);
78
118
  return {
79
119
  flow: input.flow,
80
120
  projectId: input.projectId,
@@ -86,6 +126,10 @@ export async function runRepositoryOnboarding(input) {
86
126
  reused: !established.created,
87
127
  install,
88
128
  projectManifest,
129
+ sessionHook,
130
+ mount,
131
+ trailerHook,
132
+ notices,
89
133
  noPush: true,
90
134
  };
91
135
  }
@@ -0,0 +1,66 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ export const TRAILER_HOOK_MARKER = "# nustack:managed commit-msg v1";
6
+ export const CHANGE_TRAILER_KEY = "NuStack-Change";
7
+ export const TRAILER_HOOK_BODY = `#!/bin/sh
8
+ ${TRAILER_HOOK_MARKER}
9
+ # Appends a ${CHANGE_TRAILER_KEY}: <32 lowercase hex> trailer, the change id NuStack's harness records key a
10
+ # commit by. Written by the nustack CLI (nustack init, nustack harness mount); an amend or a rebase keeps the id.
11
+ msg="$1"
12
+ [ -f "$msg" ] || exit 0
13
+ case "$(sed -n '1p' "$msg")" in
14
+ "fixup! "*|"squash! "*) exit 0 ;;
15
+ esac
16
+ grep -qi '^${CHANGE_TRAILER_KEY}:' "$msg" && exit 0
17
+ id=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')
18
+ [ \${#id} -eq 32 ] || exit 0
19
+ git interpret-trailers --in-place --trailer "${CHANGE_TRAILER_KEY}: $id" "$msg"
20
+ `;
21
+ function git(root, args) {
22
+ try {
23
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function isManagedTrailerHook(text) {
30
+ return text.split("\n").slice(0, 2).some((line) => line.trim() === TRAILER_HOOK_MARKER);
31
+ }
32
+ export async function installCommitTrailerHook(root) {
33
+ const commonDir = git(root, ["rev-parse", "--git-common-dir"]);
34
+ if (!commonDir)
35
+ return "skipped_not_git";
36
+ const hooksPath = git(root, ["config", "--get", "core.hooksPath"]);
37
+ if (hooksPath)
38
+ return "skipped_hooks_path";
39
+ const hooksDir = path.join(path.isAbsolute(commonDir) ? commonDir : path.join(root, commonDir), "hooks");
40
+ const hookFile = path.join(hooksDir, "commit-msg");
41
+ if (existsSync(hookFile)) {
42
+ const held = await readFile(hookFile, "utf8").catch(() => "");
43
+ if (!isManagedTrailerHook(held))
44
+ return "skipped_foreign_hook";
45
+ if (held === TRAILER_HOOK_BODY) {
46
+ await chmod(hookFile, 0o755);
47
+ return "installed";
48
+ }
49
+ }
50
+ await mkdir(hooksDir, { recursive: true });
51
+ await writeFile(hookFile, TRAILER_HOOK_BODY, { encoding: "utf8", mode: 0o755 });
52
+ await chmod(hookFile, 0o755);
53
+ return "installed";
54
+ }
55
+ export function trailerHookLine(value) {
56
+ switch (value) {
57
+ case "skipped_hooks_path":
58
+ return "NuStack-Change trailer hook not installed: core.hooksPath is set, so this repository's own hook manager owns commit-msg.";
59
+ case "skipped_foreign_hook":
60
+ return "NuStack-Change trailer hook not installed: a commit-msg hook NuStack did not write is already there.";
61
+ case "skipped_not_git":
62
+ return "NuStack-Change trailer hook not installed: this folder is not a git repository.";
63
+ default:
64
+ return null;
65
+ }
66
+ }
@@ -5,8 +5,8 @@ import { legacyAppJsonPath, readBindingMarker, projectJsonPath } from "../projec
5
5
  import { manifestPath } from "../manifest.js";
6
6
  const CURRENT_BINDING_PROTOCOL_VERSION = 1;
7
7
  const CURRENT_DOCUMENT_PROTOCOL_VERSION = 1;
8
- function buildProjectManifest(identity) {
9
- return {
8
+ function buildProjectManifest(identity, existing) {
9
+ const owned = {
10
10
  schemaVersion: 2,
11
11
  workspaceId: identity.projectId,
12
12
  organizationId: identity.organizationId,
@@ -19,6 +19,22 @@ function buildProjectManifest(identity) {
19
19
  placementMapVersion: identity.toolkit.placementMapVersion,
20
20
  },
21
21
  };
22
+ const supplied = Object.fromEntries(Object.entries(identity.contract ?? {}).filter(([, value]) => value !== undefined));
23
+ return { ...(existing ?? {}), ...owned, ...supplied };
24
+ }
25
+ async function readExistingContract(root) {
26
+ const filePath = projectJsonPath(root);
27
+ if (!existsSync(filePath))
28
+ return null;
29
+ try {
30
+ const parsed = JSON.parse(await readFile(filePath, "utf8"));
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
32
+ return null;
33
+ return parsed.schemaVersion === 2 ? parsed : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
22
38
  }
23
39
  async function atomicWrite(target, content) {
24
40
  const prior = existsSync(target) ? await readFile(target, "utf8") : null;
@@ -54,7 +70,7 @@ export async function writeProjectManifests(root, identity, hooks = {}) {
54
70
  toolkit: { name: identity.toolkit.name, version: identity.toolkit.version },
55
71
  };
56
72
  delete nextService.nustackApp;
57
- const projectManifest = buildProjectManifest(identity);
73
+ const projectManifest = buildProjectManifest(identity, await readExistingContract(root));
58
74
  const projectDir = path.dirname(projectJsonPath(root));
59
75
  await mkdir(projectDir, { recursive: true });
60
76
  const projectPrior = await atomicWrite(projectJsonPath(root), JSON.stringify(projectManifest, null, 2) + "\n");
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const SETTINGS_PATH = ".claude/settings.json";
5
+ export const HARNESS_MOUNT_MARKER = "nustack:managed:harness-mount";
6
+ export const HARNESS_MOUNT_COMMAND = `nustack harness mount "$CLAUDE_PROJECT_DIR" >/dev/null || true # ${HARNESS_MOUNT_MARKER}`;
7
+ const ETNA_MOUNT_HOOK = /\.claude\/hooks\/mount\.mjs/;
8
+ const DEFAULT_INDENT = 2;
9
+ function isObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ function commandsOf(entry) {
13
+ if (!isObject(entry) || !Array.isArray(entry.hooks))
14
+ return [];
15
+ return entry.hooks.flatMap((hook) => (isObject(hook) && typeof hook.command === "string" ? [hook.command] : []));
16
+ }
17
+ function sessionStartEntries(settings) {
18
+ const hooks = settings.hooks;
19
+ if (!isObject(hooks) || !Array.isArray(hooks.SessionStart))
20
+ return [];
21
+ return hooks.SessionStart;
22
+ }
23
+ function isHarnessMountEntry(entry) {
24
+ return commandsOf(entry).some((command) => command.trimEnd().endsWith(`# ${HARNESS_MOUNT_MARKER}`));
25
+ }
26
+ function managedEntry() {
27
+ return { matcher: "", hooks: [{ type: "command", command: HARNESS_MOUNT_COMMAND }] };
28
+ }
29
+ function detectIndent(raw) {
30
+ const indented = /\n(\x20+)"/.exec(raw);
31
+ return indented?.[1] ? indented[1].length : DEFAULT_INDENT;
32
+ }
33
+ async function readSettings(root) {
34
+ const file = path.join(root, SETTINGS_PATH);
35
+ if (!existsSync(file))
36
+ return { kind: "absent" };
37
+ const raw = await readFile(file, "utf8").catch(() => null);
38
+ if (raw === null)
39
+ return { kind: "unparsable" };
40
+ if (raw.trim() === "")
41
+ return { kind: "parsed", settings: {}, indent: DEFAULT_INDENT };
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ return isObject(parsed) ? { kind: "parsed", settings: parsed, indent: detectIndent(raw) } : { kind: "unparsable" };
45
+ }
46
+ catch {
47
+ return { kind: "unparsable" };
48
+ }
49
+ }
50
+ export async function hasEtnaMountHook(root) {
51
+ const read = await readSettings(root);
52
+ if (read.kind !== "parsed")
53
+ return false;
54
+ return sessionStartEntries(read.settings).some((entry) => commandsOf(entry).some((command) => ETNA_MOUNT_HOOK.test(command)));
55
+ }
56
+ export async function installHarnessMountHook(root) {
57
+ const read = await readSettings(root);
58
+ if (read.kind === "unparsable")
59
+ return "skipped_unparsable";
60
+ const settings = read.kind === "parsed" ? read.settings : {};
61
+ const indent = read.kind === "parsed" ? read.indent : DEFAULT_INDENT;
62
+ const entries = sessionStartEntries(settings);
63
+ if (entries.some((entry) => commandsOf(entry).some((command) => ETNA_MOUNT_HOOK.test(command))))
64
+ return "skipped_etna_mount";
65
+ const wanted = managedEntry();
66
+ const index = entries.findIndex(isHarnessMountEntry);
67
+ if (index >= 0 && JSON.stringify(entries[index]) === JSON.stringify(wanted))
68
+ return "present";
69
+ const nextEntries = index >= 0 ? entries.map((entry, at) => (at === index ? wanted : entry)) : [...entries, wanted];
70
+ const hooks = isObject(settings.hooks) ? settings.hooks : {};
71
+ const next = { ...settings, hooks: { ...hooks, SessionStart: nextEntries } };
72
+ const file = path.join(root, SETTINGS_PATH);
73
+ await mkdir(path.dirname(file), { recursive: true });
74
+ await writeFile(file, `${JSON.stringify(next, null, indent)}\n`, "utf8");
75
+ return "installed";
76
+ }
@@ -0,0 +1,43 @@
1
+ import { HarnessError, HARNESS_CODES } from "./harness/codes.js";
2
+ export const PLATFORM_DEFAULT_ORIGIN = "https://apps.nustack.ai";
3
+ export const contractOrigins = new Set();
4
+ export function originOf(url) {
5
+ try {
6
+ return new URL(url).origin;
7
+ }
8
+ catch {
9
+ return url.trim().replace(/\/+$/, "");
10
+ }
11
+ }
12
+ export function isPlatformDefault(url) {
13
+ return originOf(url) === originOf(PLATFORM_DEFAULT_ORIGIN);
14
+ }
15
+ export function recordContractOrigin(url) {
16
+ if (!isPlatformDefault(url))
17
+ contractOrigins.add(originOf(url));
18
+ }
19
+ export function isContractOrigin(url) {
20
+ return contractOrigins.has(originOf(url));
21
+ }
22
+ export function originMismatchError(origin, credentialOrigin, credentialKind) {
23
+ const held = credentialKind === "pat"
24
+ ? "a personal access token (NUSTACK_TOKEN) is never sent to an origin a repository names"
25
+ : credentialKind === "none"
26
+ ? "this machine is not signed in to it, and a sign-in never opens for an origin a repository names"
27
+ : `this machine's sign-in is for ${credentialOrigin ?? "another origin"}`;
28
+ return new HarnessError(HARNESS_CODES.ORIGIN_MISMATCH, `This repository's .nustack/workspace.json names ${origin} as its NuStack origin, but ${held}. ` +
29
+ `To use ${origin}, state it outside the repository: \`nustack login --nustack-url ${origin}\`, or set NUSTACK_URL=${origin}.`);
30
+ }
31
+ export function refusedContractOrigin(credentialOrigin, origin) {
32
+ const candidates = origin === undefined ? [...contractOrigins] : isContractOrigin(origin) ? [originOf(origin)] : [];
33
+ const held = credentialOrigin === null ? null : originOf(credentialOrigin);
34
+ return candidates.find((candidate) => candidate !== held) ?? null;
35
+ }
36
+ export function isOriginMismatch(error) {
37
+ return error instanceof HarnessError && error.code === HARNESS_CODES.ORIGIN_MISMATCH;
38
+ }
39
+ export function nullUnlessOriginRefused(error) {
40
+ if (isOriginMismatch(error))
41
+ throw error;
42
+ return null;
43
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const CONTRACT_PATH = ".nustack/workspace.json";
5
+ export const LEGACY_HARNESS_MANIFEST_PATH = ".claude/harness.json";
6
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
7
+ function parseObject(text) {
8
+ if (text === null)
9
+ return null;
10
+ try {
11
+ const parsed = JSON.parse(text);
12
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function contractObject(text) {
19
+ const parsed = parseObject(text);
20
+ return parsed !== null && parsed.schemaVersion === 2 ? parsed : null;
21
+ }
22
+ function nonEmpty(value) {
23
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
24
+ }
25
+ function urlOf(value) {
26
+ const trimmed = nonEmpty(value);
27
+ return trimmed === null ? null : trimmed.replace(/\/+$/, "");
28
+ }
29
+ export function docsRootDeclaration(value) {
30
+ if (typeof value !== "string")
31
+ return null;
32
+ const trimmed = value.trim().replace(/[\\/]+$/, "");
33
+ if (trimmed === "" || /^([A-Za-z]:)?[\\/]/.test(trimmed))
34
+ return null;
35
+ return trimmed.split(/[\\/]/).includes("..") ? null : trimmed;
36
+ }
37
+ export function harnessOf(value) {
38
+ if (typeof value !== "object" || value === null || Array.isArray(value))
39
+ return null;
40
+ const harness = value;
41
+ if (harness.track === "latest")
42
+ return { track: "latest" };
43
+ const pin = nonEmpty(harness.pin);
44
+ const digest = typeof harness.digest === "string" ? harness.digest.trim() : "";
45
+ return pin !== null && DIGEST_PATTERN.test(digest) ? { pin, digest } : null;
46
+ }
47
+ function doorOf(holder) {
48
+ if (holder === null || !Object.hasOwn(holder, "door"))
49
+ return undefined;
50
+ return urlOf(holder.door);
51
+ }
52
+ function legacyMount(legacy) {
53
+ const mount = legacy?.mount;
54
+ return typeof mount === "object" && mount !== null && !Array.isArray(mount) ? mount : null;
55
+ }
56
+ export function parseProjectContract(contractText, legacyText) {
57
+ const contract = contractObject(contractText);
58
+ const legacy = parseObject(legacyText);
59
+ const contractDoor = doorOf(contract);
60
+ const legacyDoor = doorOf(legacyMount(legacy));
61
+ return {
62
+ projectId: contract === null ? null : nonEmpty(contract.workspaceId),
63
+ harness: contract === null ? null : harnessOf(contract.harness),
64
+ door: contractDoor !== undefined ? contractDoor : legacyDoor,
65
+ doorSource: contractDoor !== undefined ? "contract" : legacyDoor !== undefined ? "legacy" : null,
66
+ origin: contract === null ? null : urlOf(contract.origin),
67
+ docsRoot: (contract === null ? null : docsRootDeclaration(contract.docsRoot)) ?? docsRootDeclaration(legacy?.docsRoot),
68
+ branchPolicy: contract?.branchPolicy === "trunk" || contract?.branchPolicy === "branch" ? contract.branchPolicy : null,
69
+ contractPresent: contract !== null,
70
+ legacyPresent: legacyText !== null,
71
+ legacyVersion: nonEmpty(legacy?.version),
72
+ };
73
+ }
74
+ function readTextSync(file) {
75
+ if (!existsSync(file))
76
+ return null;
77
+ try {
78
+ return readFileSync(file, "utf8");
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ async function readText(file) {
85
+ if (!existsSync(file))
86
+ return null;
87
+ return readFile(file, "utf8").catch(() => null);
88
+ }
89
+ export async function readProjectContract(root) {
90
+ return parseProjectContract(await readText(path.join(root, CONTRACT_PATH)), await readText(path.join(root, LEGACY_HARNESS_MANIFEST_PATH)));
91
+ }
92
+ export function readProjectContractSync(root) {
93
+ return parseProjectContract(readTextSync(path.join(root, CONTRACT_PATH)), readTextSync(path.join(root, LEGACY_HARNESS_MANIFEST_PATH)));
94
+ }
95
+ export function contractFieldsFromSummary(summary) {
96
+ if (summary === null)
97
+ return { harness: { track: "latest" }, branchPolicy: null };
98
+ const harness = summary.harness;
99
+ const branchPolicy = summary.branchPolicy === "trunk" || summary.branchPolicy === "branch" ? summary.branchPolicy : null;
100
+ if (harness?.track === "pinned" && typeof harness.digest === "string" && DIGEST_PATTERN.test(harness.digest)) {
101
+ return { harness: { pin: harness.version, digest: harness.digest }, branchPolicy };
102
+ }
103
+ return { harness: { track: "latest" }, branchPolicy };
104
+ }
@@ -1,8 +1,7 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { resolveNuStackUrl } from "../nustack_client.js";
1
+ import { assertCredentialOrigin, resolveNuStackOrigin } from "../nustack_client.js";
5
2
  import { resolveProjectId } from "../project_context.js";
3
+ import { readProjectContract } from "../project_contract.js";
4
+ import { originOf } from "../origin_guard.js";
6
5
  export const RECORD_KINDS = ["obligation", "gate_event", "changelog", "memory", "backlog", "proposal", "triage"];
7
6
  export const LEGACY_KIND_TRANSLATION = { "backlog.remove": "backlog", bookkeeping: "obligation" };
8
7
  export const RECORDS_BATCH_CAP = 200;
@@ -23,33 +22,41 @@ export function translatedKindOf(kind) {
23
22
  return isLegacyKind(kind) ? LEGACY_KIND_TRANSLATION[kind] : kind;
24
23
  }
25
24
  export async function readMountDoor(root) {
26
- const manifest = path.join(root, ".claude", "harness.json");
27
- if (!existsSync(manifest))
28
- return undefined;
29
- try {
30
- const parsed = JSON.parse(await readFile(manifest, "utf8"));
31
- if (!parsed.mount || !("door" in parsed.mount))
32
- return undefined;
33
- const door = parsed.mount.door;
34
- return typeof door === "string" && door.trim() !== "" ? door.trim().replace(/\/+$/, "") : null;
35
- }
36
- catch {
37
- return undefined;
38
- }
25
+ return (await readProjectContract(root)).door;
39
26
  }
40
27
  export async function resolveDoor(root, globals, projectFlag) {
41
28
  const project = await resolveProjectId(root, projectFlag ? { project: projectFlag } : {});
42
29
  if (!project.ok)
43
30
  return { kind: "store", projectId: null, root, reason: "this folder is not bound to a Project" };
44
- const door = await readMountDoor(root);
31
+ const contract = await readProjectContract(root);
32
+ const door = contract.door;
45
33
  if (door === null)
46
34
  return { kind: "store", projectId: project.projectId, root, reason: "the harness manifest configures no door" };
47
- return { kind: "door", projectId: project.projectId, doorUrl: door ?? resolveNuStackUrl(globals), isPlatformDefault: door === undefined, root };
35
+ let origin;
36
+ try {
37
+ origin = resolveNuStackOrigin(globals, root).url;
38
+ }
39
+ catch (error) {
40
+ return { kind: "store", projectId: project.projectId, root, reason: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ if (door !== undefined && contract.doorSource === "contract" && originOf(door) !== originOf(origin)) {
43
+ const reason = `the contract's door ${door} is not on the resolved origin ${origin}; records stay local`;
44
+ process.stderr.write(`nustack: ${reason}\n`);
45
+ return { kind: "store", projectId: project.projectId, root, reason };
46
+ }
47
+ return { kind: "door", projectId: project.projectId, doorUrl: door ?? origin, isPlatformDefault: door === undefined, root };
48
48
  }
49
49
  export function retryDelayMs(attempts) {
50
50
  return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), RETRY_CEILING_MS);
51
51
  }
52
52
  export async function postRecords(doorUrl, projectId, request, bearer) {
53
+ try {
54
+ await assertCredentialOrigin(doorUrl);
55
+ }
56
+ catch (error) {
57
+ const code = typeof error.code === "string" ? error.code : null;
58
+ return { ok: false, status: 0, code, message: error instanceof Error ? error.message : String(error) };
59
+ }
53
60
  let response;
54
61
  try {
55
62
  response = await fetch(`${doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}/records`, {
@@ -3,6 +3,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unl
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ensureAccessToken } from "../auth_grant.js";
6
+ import { isOriginMismatch } from "../origin_guard.js";
6
7
  import { recordsDrainLockPath } from "../local_store/paths.js";
7
8
  import { resolveNuStackUrl } from "../nustack_client.js";
8
9
  import { RECORDS_BATCH_CAP, postRecords, resolveDoor, retryDelayMs } from "./door.js";
@@ -84,14 +85,19 @@ async function drainPass(store, globals, result, shouldIgnoreBackoff) {
84
85
  if (first.row.credential === "device")
85
86
  bearer = deviceToken;
86
87
  else {
87
- if (personToken === undefined)
88
- personToken = (await ensureAccessToken().catch(() => null))?.accessToken ?? null;
88
+ if (personToken === undefined) {
89
+ personToken = await ensureAccessToken().then((auth) => auth?.accessToken ?? null, (error) => {
90
+ if (isOriginMismatch(error))
91
+ result.originRefusal = error.message;
92
+ return null;
93
+ });
94
+ }
89
95
  bearer = personToken;
90
96
  }
91
97
  for (let start = 0; start < list.length; start += RECORDS_BATCH_CAP) {
92
98
  const batch = list.slice(start, start + RECORDS_BATCH_CAP);
93
99
  if (bearer === null) {
94
- backoffAll(store, batch, "this device is signed out", result);
100
+ backoffAll(store, batch, result.originRefusal ?? "this device is signed out", result);
95
101
  continue;
96
102
  }
97
103
  await sendBatch(store, batch, first.row.kind, bearer, result);
@@ -140,7 +146,7 @@ async function sendBatch(store, batch, kind, bearer, result) {
140
146
  }
141
147
  }
142
148
  export async function drainRecords(store, globals, options = {}) {
143
- const result = { sent: 0, refused: 0, lastError: null };
149
+ const result = { sent: 0, refused: 0, lastError: null, originRefusal: null };
144
150
  pruneSynced(store, Date.now());
145
151
  for (let pass = 0; pass < MAX_PASSES; pass += 1) {
146
152
  options.onPass?.();
@@ -2,7 +2,36 @@ import { randomUUID } from "node:crypto";
2
2
  import { RECORD_SCHEMA_VERSION } from "./door.js";
3
3
  import { idempotencyKeyOf } from "./naturalKey.js";
4
4
  import { writeRecords } from "./recordsTable.js";
5
- export const REFUSAL_GATE_IDS = ["records-validation", "records-unknown-kind", "agent-owned-obligation", "secret-scan"];
5
+ export const REFUSAL_GATE_IDS = [
6
+ "records-validation",
7
+ "records-unknown-kind",
8
+ "agent-owned-obligation",
9
+ "secret-scan",
10
+ "ownership",
11
+ "plan-gate",
12
+ "ledger",
13
+ "frozen-record",
14
+ "changelog",
15
+ "changelog-entry",
16
+ "package-table",
17
+ "protected-file",
18
+ "output-gate",
19
+ "branch-worktree",
20
+ "live-command",
21
+ "commit-claim",
22
+ "foreign-claim",
23
+ "open-denials",
24
+ "exit-stop",
25
+ "queue",
26
+ "completion-integrity",
27
+ "changelog-presence",
28
+ "commit-gate",
29
+ "file-gate",
30
+ "bash-write-screen",
31
+ "harness-checks",
32
+ "hook.rule_unavailable",
33
+ "hook.deadline",
34
+ ];
6
35
  const TEXT_CAP = 20_000;
7
36
  const clip = (text) => (text.length > TEXT_CAP ? `${text.slice(0, TEXT_CAP - 1)}…` : text || "-");
8
37
  export function logRefusals(store, entries, context) {
@@ -1,13 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
2
  import { createInterface } from "node:readline";
5
3
  import { ClaudeSession } from "./claudeAdapter.js";
6
4
  import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
7
5
  import { inspectSession, reclaimSession } from "./prune.js";
8
6
  import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
9
7
  import { createSessionWorktree } from "./worktree.js";
10
- import { mountHarness } from "../harness/mount.js";
8
+ import { readProjectContract } from "../project_contract.js";
11
9
  function defaultProbeClaude(env) {
12
10
  return new Promise((resolve) => {
13
11
  execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
@@ -41,14 +39,7 @@ export class SessionSupervisor {
41
39
  this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
42
40
  }
43
41
  async boundProjectOf(projectPath) {
44
- try {
45
- const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
46
- const id = parsed?.workspaceId;
47
- return typeof id === "string" && id.trim() ? id.trim() : null;
48
- }
49
- catch {
50
- return null;
51
- }
42
+ return (await readProjectContract(projectPath)).projectId;
52
43
  }
53
44
  async moveSessionRow(record, to) {
54
45
  const client = this.options.sessions;
@@ -133,17 +124,14 @@ export class SessionSupervisor {
133
124
  }
134
125
  }
135
126
  async mountInto(sessionId, root, projectId) {
136
- const lookup = this.options.sessions?.harnessManifestUrl;
137
- if (!lookup)
127
+ const mount = this.options.mountHarness;
128
+ if (!mount)
138
129
  return;
130
+ const log = this.options.log ?? ((line) => process.stderr.write(`${line}\n`));
139
131
  try {
140
- const manifestUrl = await lookup.call(this.options.sessions, projectId);
141
- if (!manifestUrl)
142
- return;
143
- await (this.options.mountHarness ?? mountHarness)(root, manifestUrl);
132
+ await mount(root, { projectId, writeContract: "never", runtime: "claude-code", log: () => { } });
144
133
  }
145
134
  catch (error) {
146
- const log = this.options.log ?? ((line) => process.stderr.write(`${line}\n`));
147
135
  log(`nustack: harness mount skipped for session ${sessionId} - ${error instanceof Error ? error.message : String(error)}`);
148
136
  }
149
137
  }
@@ -34,18 +34,6 @@ export function makeSessionsClient(baseUrl, accessToken) {
34
34
  async setVendorSessionId(projectId, sessionId, vendorSessionId) {
35
35
  return this.transitionSession(projectId, sessionId, "running", { vendorSessionId });
36
36
  },
37
- async harnessManifestUrl(projectId) {
38
- const safeId = assertSafeOpaqueId("The Project id", projectId);
39
- const response = await globalThis.fetch(`${baseUrl}/api/v2/projects/${encodeURIComponent(safeId)}`, {
40
- headers,
41
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
42
- });
43
- const payload = (await response.json().catch(() => null));
44
- if (!response.ok)
45
- throw new Error(`project summary fetch failed: HTTP ${response.status}`);
46
- const url = payload?.data?.harness?.manifestUrl;
47
- return typeof url === "string" && url ? url : null;
48
- },
49
37
  };
50
38
  }
51
39
  export function sessionsClientFromEnv(env = process.env) {