granttap-mcp 0.7.1 → 0.7.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/README.md CHANGED
@@ -51,12 +51,11 @@ Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
51
51
  Claims have TTLs; a colliding claim is rejected before it is recorded so agents
52
52
  can choose different work or contact the owner before escalating to Needs You.
53
53
 
54
- Both directions are scoped to the calling execution. The provider hook already
55
- runs inside the agent's own session and sees the exact call, so GrantTap
56
- attributes every `notify` to the session that really made it and publishes the
57
- event only for that execution — a model that learned another session's id
58
- cannot act in its name. `granttap://mesh/current` therefore carries no Project
59
- data: an attributed call returns this execution's opaque
54
+ For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
55
+ own session and sees the exact call. GrantTap attributes every `notify` to the
56
+ session that really made it and publishes the event only for that execution —
57
+ a model that learned another session's id cannot act in its name.
58
+ `granttap://mesh/current` therefore carries no Project data: an attributed call returns this execution's opaque
60
59
  `granttap://mesh/<capability>` URI, and only that URI serves its Project, Task,
61
60
  owners, claims, dependencies, and relevant events. Ownership transfer stays out
62
61
  of the tool surface entirely: `HANDOFF_ACCEPTED` and `HANDOFF_REJECTED` are
@@ -66,8 +65,11 @@ The first executable handoff path is Claude Code ↔ Codex across linked
66
65
  computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
67
66
  requires local phone authorization, creates a separate target branch/worktree,
68
67
  starts the target execution, and returns a receipt bound to the exact capsule.
69
- Cursor and Grok Build use the same provider-neutral schema and discovery model;
70
- unsupported remote-start paths fail closed instead of claiming parity.
68
+ Cursor uses the same provider-neutral schema and trusted caller attribution.
69
+ Grok Build remains Experimental and observable where available, but does not
70
+ yet expose a trusted caller hook, so agent-authored scoped Mesh events are not
71
+ offered for it. Unsupported remote-start paths fail closed instead of claiming
72
+ parity.
71
73
 
72
74
  A capsule carries facts, not files. A handoff from a checkout with uncommitted
73
75
  changes is refused — "This task has uncommitted changes. Commit or checkpoint
package/SECURITY.md CHANGED
@@ -20,6 +20,24 @@ credential used for authenticated WebSocket and APNs registration.
20
20
  Provider credentials remain in each provider's own local authentication flow.
21
21
  GrantTap does not send them to the relay.
22
22
 
23
+ Installed Claude Code, Codex, and Cursor hooks deny direct agent tool calls
24
+ that name GrantTap's local pairing, key, or policy files, including calls that
25
+ would otherwise be auto-accepted or run in provider bypass mode. The denial
26
+ covers the config directory itself, any glob over it, and every file not on a
27
+ short access-mode list, so a file added later is protected the day it appears.
28
+ Only helper logs and bounded runtime diagnostics are agent-readable, and they
29
+ remain read-only. Raw Mesh state and hook-attribution records are never exposed
30
+ to agent tools; `workspaces/` and `worktrees/` remain normal editable agent
31
+ workspace. The guard denies only what it positively recognised as a path into
32
+ that directory, and a fault inside the guard itself is printed and allowed
33
+ through instead of blocking every tool call on the machine: an outage of every
34
+ agent is a worse failure than the file it was watching. This is
35
+ best-effort self-protection, not an operating-system security boundary: a
36
+ process running as the same macOS user can use an unobserved tool, shell
37
+ obfuscation, or direct filesystem access outside those hooks. Protect the user
38
+ account and disk; use a trusted terminal for intentional maintenance. Moving
39
+ remaining local secret material into Keychain is the stronger future boundary.
40
+
23
41
  ## Cryptographic boundary
24
42
 
25
43
  - Every computer pairing is generated locally with unique Curve25519 keys.
@@ -67,6 +85,10 @@ acceptance includes a SHA-256 receipt over a canonical capsule and the source,
67
85
  target, Task identity, and acceptance time. Stale claims expire, replayed event
68
86
  IDs are ignored, and destination routing is explicit.
69
87
 
88
+ Repository remotes included in Project snapshots are reduced to lowercase
89
+ host/path form. Userinfo, passwords or tokens, query strings, fragments, and
90
+ local-path remotes are never persisted as `baseRemote`.
91
+
70
92
  ## Runtime controls
71
93
 
72
94
  Provider hooks enforce policy on the computer. Global provider deny always
@@ -87,5 +109,16 @@ expiry, actor, Project scope, and operation, and a revoked or disabled endpoint
87
109
  fails closed. Invite creation, actor enablement, Project scope, and revocation
88
110
  stay in the iPhone UI and the trusted CLI, never in a model-callable tool.
89
111
 
112
+ ## Local helper installation
113
+
114
+ The background helper is a user LaunchAgent, and `launchctl bootstrap` replaces
115
+ whatever job already holds that label. GrantTap therefore refuses to load a
116
+ LaunchAgent whose plist lives under the operating system's temporary directory:
117
+ a test, probe, or agent run with a temporary `HOME` must never evict the
118
+ installed helper and leave launchd restarting a program whose files were already
119
+ cleaned up. Only `GRANTTAP_TEST_FAKE_LAUNCHCTL=1`, which states that `launchctl`
120
+ on `PATH` is a stub, lifts that refusal.
121
+
122
+
90
123
  Relay vulnerabilities should be reported through the
91
124
  [GrantTap relay advisory form](https://github.com/sergii-ziborov/granttap-relay/security/advisories/new).
@@ -25,6 +25,7 @@ import {
25
25
  } from "../config";
26
26
  import { recordAttributedCall } from "../mesh/call-scope";
27
27
  import { classifyAction } from "../policy";
28
+ import { protectedGrantTapAccess } from "../self-protection";
28
29
 
29
30
  async function readStdin(): Promise<string> {
30
31
  const chunks: Buffer[] = [];
@@ -50,6 +51,18 @@ async function main(): Promise<void> {
50
51
  return;
51
52
  }
52
53
 
54
+ const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
55
+ if (protectedAccess) {
56
+ process.stdout.write(JSON.stringify({
57
+ hookSpecificOutput: {
58
+ hookEventName: "PreToolUse",
59
+ permissionDecision: "deny",
60
+ permissionDecisionReason: protectedAccess.reason,
61
+ },
62
+ }));
63
+ return;
64
+ }
65
+
53
66
  if (!isProviderEnabled("claude")) return;
54
67
 
55
68
  // Attribute this exact GrantTap call to the session that made it. The MCP
@@ -25,6 +25,7 @@ import {
25
25
  } from "../config";
26
26
  import { recordAttributedCall } from "../mesh/call-scope";
27
27
  import { classifyAction } from "../policy";
28
+ import { protectedGrantTapAccess } from "../self-protection";
28
29
 
29
30
  async function readStdin(): Promise<string> {
30
31
  const chunks: Buffer[] = [];
@@ -51,6 +52,12 @@ async function main(): Promise<void> {
51
52
  return;
52
53
  }
53
54
 
55
+ const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
56
+ if (protectedAccess) {
57
+ process.stdout.write(denyOutput(protectedAccess.reason));
58
+ return;
59
+ }
60
+
54
61
  if (!isProviderEnabled("codex")) return;
55
62
 
56
63
  // See claude-hook.ts: the MCP server trusts this attribution, never the model.
@@ -2,6 +2,7 @@
2
2
  /** Deny-only Codex PreToolUse policy for exact per-chat capability switches. */
3
3
  import type { HookInput } from "../adapters";
4
4
  import { blockedSessionCapability, isProviderEnabled } from "../config";
5
+ import { protectedGrantTapAccess } from "../self-protection";
5
6
 
6
7
  async function readStdin(): Promise<string> {
7
8
  const chunks: Buffer[] = [];
@@ -10,13 +11,24 @@ async function readStdin(): Promise<string> {
10
11
  }
11
12
 
12
13
  async function main(): Promise<void> {
13
- if (!isProviderEnabled("codex")) return;
14
14
  let input: HookInput;
15
15
  try {
16
16
  input = JSON.parse(await readStdin()) as HookInput;
17
17
  } catch {
18
18
  return;
19
19
  }
20
+ const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
21
+ if (protectedAccess) {
22
+ process.stdout.write(JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: "PreToolUse",
25
+ permissionDecision: "deny",
26
+ permissionDecisionReason: protectedAccess.reason,
27
+ },
28
+ }));
29
+ return;
30
+ }
31
+ if (!isProviderEnabled("codex")) return;
20
32
  const blocked = blockedSessionCapability(
21
33
  input.session_id,
22
34
  input.tool_name,
@@ -16,6 +16,7 @@ import {
16
16
  shouldAutoAcceptCursorShell,
17
17
  } from "../config";
18
18
  import { cursorRootSessionId } from "../sessions/cursor";
19
+ import { protectedGrantTapAccess } from "../self-protection";
19
20
 
20
21
  async function readStdin(): Promise<string> {
21
22
  const chunks: Buffer[] = [];
@@ -31,6 +32,14 @@ function nativeAsk(message: string): void {
31
32
  }));
32
33
  }
33
34
 
35
+ function deny(message: string): void {
36
+ process.stdout.write(JSON.stringify({
37
+ permission: "deny", continue: false,
38
+ user_message: message, agent_message: message,
39
+ userMessage: message, agentMessage: message,
40
+ }));
41
+ }
42
+
34
43
  async function main(): Promise<void> {
35
44
  let input: CursorHookInput;
36
45
  try {
@@ -39,6 +48,13 @@ async function main(): Promise<void> {
39
48
  nativeAsk("GrantTap could not read this shell call; use Cursor approval.");
40
49
  return;
41
50
  }
51
+ const protectedAccess = protectedGrantTapAccess(
52
+ input.tool_name, input.tool_input, input.command,
53
+ );
54
+ if (protectedAccess) {
55
+ deny(protectedAccess.reason);
56
+ return;
57
+ }
42
58
  if (!isProviderEnabled("cursor")) {
43
59
  nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
44
60
  return;
@@ -51,14 +67,7 @@ async function main(): Promise<void> {
51
67
  input.tool_input ?? { command: input.command },
52
68
  );
53
69
  if (blocked) {
54
- process.stdout.write(JSON.stringify({
55
- permission: "deny",
56
- continue: false,
57
- user_message: blocked.reason,
58
- agent_message: blocked.reason,
59
- userMessage: blocked.reason,
60
- agentMessage: blocked.reason,
61
- }));
70
+ deny(blocked.reason);
62
71
  return;
63
72
  }
64
73
  if (!sessionId || isGatingSkipped(sessionId)) {
@@ -19,6 +19,7 @@ import {
19
19
  } from "../cursor-mcp-policy";
20
20
  import { recordAttributedCall } from "../mesh/call-scope";
21
21
  import { cursorRootSessionId } from "../sessions/cursor";
22
+ import { protectedGrantTapAccess } from "../self-protection";
22
23
 
23
24
  async function readStdin(): Promise<string> {
24
25
  const chunks: Buffer[] = [];
@@ -42,6 +43,17 @@ async function main(): Promise<void> {
42
43
  nativeAsk("GrantTap could not correlate this MCP call; use Cursor approval.");
43
44
  return;
44
45
  }
46
+ const protectedAccess = protectedGrantTapAccess(
47
+ input.tool_name, input.tool_input, input.command,
48
+ );
49
+ if (protectedAccess) {
50
+ process.stdout.write(JSON.stringify({
51
+ permission: "deny",
52
+ user_message: protectedAccess.reason,
53
+ agent_message: protectedAccess.reason,
54
+ }));
55
+ return;
56
+ }
45
57
  if (!isProviderEnabled("cursor")) {
46
58
  nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
47
59
  return;
@@ -23,6 +23,7 @@ import { delimiter, dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { spawnSync } from "node:child_process";
25
25
  import type { CodingAgent } from "../../../packages/protocol/schema";
26
+ import { refusesLiveLaunchd } from "./launchd-safety";
26
27
  import { configDir } from "./config";
27
28
 
28
29
  const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -299,18 +300,15 @@ export function inspectMonitorHelper(): MonitorIntegrationStatus {
299
300
  return { configured: true, running: active.status === 0 };
300
301
  }
301
302
 
302
- /** Absolute pin for this machine LaunchAgent must never drift to granttap-mcp + Cursor helpers. */
303
- export const DEFAULT_PINNED_MONITOR_BIN = "/Users/serhiirihgt/dev/nodvox/bin/granttap.mjs";
304
- export const DEFAULT_PINNED_MONITOR_ROOT = "/Users/serhiirihgt/dev/nodvox";
305
-
306
- export function pinnedMonitorBin(): string {
303
+ /** Optional explicit development pin; public installs use this package by default. */
304
+ export function pinnedMonitorBin(): string | undefined {
307
305
  const override = process.env.GRANTTAP_PINNED_MONITOR_BIN?.trim();
308
- return override && override.length > 0 ? override : DEFAULT_PINNED_MONITOR_BIN;
306
+ return override && override.length > 0 ? override : undefined;
309
307
  }
310
308
 
311
- export function pinnedMonitorRoot(): string {
309
+ export function pinnedMonitorRoot(): string | undefined {
312
310
  const override = process.env.GRANTTAP_PINNED_MONITOR_ROOT?.trim();
313
- return override && override.length > 0 ? override : DEFAULT_PINNED_MONITOR_ROOT;
311
+ return override && override.length > 0 ? override : undefined;
314
312
  }
315
313
 
316
314
  function xml(value: string): string {
@@ -326,15 +324,12 @@ export function isCursorHelperNode(nodePath: string): boolean {
326
324
  return nodePath.includes("Cursor.app") || nodePath.includes("/helpers/node");
327
325
  }
328
326
 
329
- /** True when plist already runs the nodvox monitor without Cursor/mcp clobber. */
327
+ /** True when a plist uses the caller's explicit development monitor pin. */
330
328
  export function isNodvoxPinnedPlist(contents: string): boolean {
331
329
  const pin = pinnedMonitorBin();
332
- const hasNodvoxBin =
333
- contents.includes(pin) ||
334
- contents.includes("/dev/nodvox/bin/granttap.mjs") ||
335
- contents.includes("/nodvox/bin/granttap.mjs");
330
+ if (!pin) return false;
336
331
  return (
337
- hasNodvoxBin &&
332
+ contents.includes(pin) &&
338
333
  !contents.includes("granttap-mcp.mjs") &&
339
334
  !contents.includes("Cursor.app") &&
340
335
  !contents.includes("/helpers/node")
@@ -364,8 +359,8 @@ export function resolveMonitorNodeBin(): string | null {
364
359
  * Keep task/session sync alive without a terminal or a newly opened MCP chat.
365
360
  *
366
361
  * Hard rules:
367
- * - Never overwrite a healthy nodvox-pinned LaunchAgent with granttap-mcp / Cursor helpers.
368
- * - When `/Users/serhiirihgt/dev/nodvox/bin/granttap.mjs` exists, pin to it (not this package's bin).
362
+ * - Never overwrite a healthy explicitly pinned LaunchAgent with package / Cursor helpers.
363
+ * - Development pins are opt-in through `GRANTTAP_PINNED_MONITOR_BIN`.
369
364
  * - Never put Cursor.app helpers/node in ProgramArguments.
370
365
  */
371
366
  export function installMonitorHelper(): InstallResult {
@@ -383,11 +378,12 @@ export function installMonitorHelper(): InstallResult {
383
378
  }
384
379
  }
385
380
 
386
- const pinBin = pinnedMonitorBin();
387
- const usePin = existsSync(pinBin);
388
- const executable = usePin ? pinBin : join(repoRoot, "bin", "granttap-mcp.mjs");
389
- const workingDirectory = usePin
390
- ? pinnedMonitorRoot()
381
+ const explicitPin = pinnedMonitorBin();
382
+ const pinBin = explicitPin && existsSync(explicitPin) ? explicitPin : undefined;
383
+ const usePin = pinBin != null;
384
+ const executable = pinBin ?? join(repoRoot, "bin", "granttap-mcp.mjs");
385
+ const workingDirectory = pinBin
386
+ ? (pinnedMonitorRoot() ?? dirname(pinBin))
391
387
  : (process.env.GRANTTAP_MONITOR_CWD ?? process.cwd());
392
388
  const nodeBin = resolveMonitorNodeBin();
393
389
  if (!nodeBin) {
@@ -473,6 +469,8 @@ export function installMonitorHelper(): InstallResult {
473
469
  return { status: already ? "already" : "installed", detail };
474
470
  }
475
471
 
472
+ const sandboxed = refusesLiveLaunchd(path);
473
+ if (sandboxed) return { status: "manual", detail: sandboxed };
476
474
  const uid = process.getuid?.();
477
475
  if (uid == null) return { status: "manual", detail: `${path}: could not determine user id` };
478
476
  const domain = `gui/${uid}`;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A LaunchAgent is user-global: `launchctl bootstrap` replaces whatever job
3
+ * already holds that label. A sandboxed run — a test, a probe, anything with a
4
+ * temporary `HOME` or config directory — therefore evicts the real GrantTap
5
+ * helper and installs a job whose plist, node modules, and log file vanish with
6
+ * the temporary directory. launchd then keeps restarting a program it can no
7
+ * longer load, which reads to the user as GrantTap crashing in a loop.
8
+ *
9
+ * A real installation always writes into the user's own Library. A plist under
10
+ * the operating system's temporary directory is a sandbox by definition, so it
11
+ * never reaches the live domain.
12
+ */
13
+ import { realpathSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { resolve, sep } from "node:path";
16
+
17
+ const TEMPORARY_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
18
+
19
+ function canonical(path: string): string {
20
+ try {
21
+ return realpathSync(path);
22
+ } catch {
23
+ return resolve(path);
24
+ }
25
+ }
26
+
27
+ export function insideTemporaryDirectory(path: string): boolean {
28
+ if (process.env.GRANTTAP_TEST_FAKE_LAUNCHCTL === "1") return false;
29
+ const target = canonical(path);
30
+ const roots = new Set<string>();
31
+ for (const root of [tmpdir(), ...TEMPORARY_ROOTS]) {
32
+ roots.add(resolve(root));
33
+ roots.add(canonical(root));
34
+ }
35
+ return [...roots].some((root) => target === root || target.startsWith(root + sep));
36
+ }
37
+
38
+ export const SANDBOXED_LAUNCH_AGENT_DETAIL =
39
+ "refusing to load a LaunchAgent from a temporary directory; "
40
+ + "a sandboxed run must not replace the installed GrantTap helper";
41
+
42
+ /**
43
+ * Whether this plist may be handed to `launchctl` in the live user domain.
44
+ *
45
+ * `GRANTTAP_TEST_FAKE_LAUNCHCTL=1` states that the `launchctl` on PATH is a
46
+ * stub, so the whole install path can be exercised without a live domain to
47
+ * damage. It is an explicit, greppable opt-in; nothing in the shipped product
48
+ * sets it.
49
+ */
50
+ export function refusesLiveLaunchd(path: string): string | null {
51
+ if (process.env.GRANTTAP_TEST_FAKE_LAUNCHCTL === "1") return null;
52
+ return insideTemporaryDirectory(path) ? `${path}: ${SANDBOXED_LAUNCH_AGENT_DETAIL}` : null;
53
+ }
@@ -9,12 +9,13 @@ identity without replacing provider-native session discovery. `store.ts`
9
9
  deduplicates replayed events and expires stale claims. `capsule.ts` reads only
10
10
  explicit git and task facts; transcripts and hidden reasoning are never copied.
11
11
  `worktree.ts` creates a separate target branch/worktree before Claude Code or
12
- Codex is started. The provider abstraction already admits Cursor and Grok Build
13
- without changing the core contract, while their unsupported phase-one start
14
- paths fail closed.
12
+ Codex is started. Cursor has trusted caller attribution but no phase-one remote
13
+ start. Grok Build remains observable and Experimental; because it has no
14
+ trusted caller hook, agent-authored scoped Mesh events are not offered for it.
15
+ Unsupported paths fail closed.
15
16
 
16
17
  `call-scope.ts` and `capability.ts` own caller identity. The MCP server cannot
17
- see which agent called it, so each provider hook records a single-use
18
+ see which agent called it, so supported provider hooks record a single-use
18
19
  attribution for the exact call it observed inside its own session, and the
19
20
  runtime derives the publishing execution from that instead of from anything the
20
21
  model supplied. `capability.ts` mints the opaque per-execution token that
@@ -28,6 +29,20 @@ CLI-only.
28
29
  committed facts, so uncommitted work blocks the handoff instead of silently
29
30
  staying behind, and the destination refuses just as explicitly when it lacks the
30
31
  named commit or when the capsule's resource claims overlap another execution.
32
+ "Uncommitted" is read from `git status` including untracked files, because an
33
+ untracked file is work no commit would carry. A probe that cannot answer
34
+ publishes `unknown`, and only an explicit `clean` releases the handoff.
35
+
36
+ `convergence.ts` and `task-state.ts` keep one Task identity while snapshots and
37
+ events arrive late, twice, and from several computers. Every writer raises the
38
+ Task `revision`, a merge keeps the higher one, and a tie resolves the same way
39
+ on every device. Ownership only moves forward: to an unowned Task, back to the
40
+ session that already owns it, or through a receipt from the current owner —
41
+ and a computer that already watched a session hand the Task on refuses that
42
+ session's later receipt. Completed and failed Tasks absorb events instead of
43
+ reopening, an execution closed by a receipt stays closed even while its native
44
+ session keeps reporting itself, and the local catalog may refresh a Task's
45
+ description but never take it back from the agent that owns it now.
31
46
 
32
47
  Every event is schema-bounded and sent under an independent task key; snapshots
33
48
  use an independent project key. Resource claims are advisory, expire without a
@@ -96,7 +96,7 @@ export function recordAttributedCall(input: {
96
96
  }): void {
97
97
  const tool = grantTapToolName(input.toolName);
98
98
  const sessionId = String(input.sessionId ?? "").trim();
99
- if (tool !== "notify" || !sessionId || sessionId.length > 256) return;
99
+ if (!tool || tool === "connect" || !sessionId || sessionId.length > 256) return;
100
100
  const now = input.now ?? Date.now();
101
101
  try {
102
102
  const call = AttributedCall.parse({
@@ -6,6 +6,7 @@ import {
6
6
  type SessionInfo,
7
7
  type TaskCapsule as CapsuleValue,
8
8
  } from "../../../../packages/protocol/schema";
9
+ import { workingTreeState } from "./catalog";
9
10
  import type { MeshStore } from "./store";
10
11
 
11
12
  function git(cwd: string, args: string[]): string | undefined {
@@ -72,6 +73,7 @@ export function buildTaskCapsule(
72
73
  branch: session.branch ?? git(cwd, ["branch", "--show-current"]),
73
74
  latestCommit: head,
74
75
  dirtyDiffHash: dirtyDiffHash(cwd),
76
+ workingTree: workingTreeState(cwd),
75
77
  filesChanged: changedFiles(cwd),
76
78
  dependencies: snapshot.dependencies
77
79
  .filter((item) => item.taskId === request.taskId).map((item) => item.dependsOnTaskId).slice(0, 32),
@@ -7,7 +7,12 @@ import type {
7
7
  SessionInfo,
8
8
  TaskState,
9
9
  } from "../../../../packages/protocol/schema";
10
- import { canonicalRepositoryIdentity, projectIdentity, taskIdentity } from "./identity";
10
+ import {
11
+ canonicalRepositoryIdentity,
12
+ projectIdentity,
13
+ sanitizedRepositoryRemote,
14
+ taskIdentity,
15
+ } from "./identity";
11
16
  import type { MeshStore } from "./store";
12
17
 
13
18
  export type RepositoryFacts = {
@@ -30,30 +35,44 @@ function git(cwd: string, args: string[]): string | undefined {
30
35
  }
31
36
 
32
37
  /**
33
- * Whether this checkout has changes that a commit-based handoff would leave
34
- * behind. `undefined` means the probe could not answer, which callers must not
35
- * read as "clean".
38
+ * Whether this checkout has anything a commit-based handoff would leave behind.
39
+ *
40
+ * `git status` rather than `git diff HEAD`, because an untracked file is work
41
+ * the Task Capsule cannot carry just as surely as a modified tracked one.
42
+ * `undefined` means the probe could not answer, which callers must not read as
43
+ * "clean".
36
44
  */
37
45
  export function hasUncommittedWork(cwd: string): boolean | undefined {
38
46
  try {
39
- execFileSync("git", ["-C", cwd, "diff", "--quiet", "HEAD"], {
40
- stdio: "ignore", timeout: 2_000,
41
- });
42
- return false;
43
- } catch (error) {
44
- return (error as { status?: number }).status === 1 ? true : undefined;
47
+ const status = execFileSync(
48
+ "git",
49
+ ["-C", cwd, "status", "--porcelain=v1", "--untracked-files=normal", "-z"],
50
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
51
+ maxBuffer: 4 * 1_024 * 1_024 },
52
+ );
53
+ return status.trim().length > 0;
54
+ } catch {
55
+ return undefined;
45
56
  }
46
57
  }
47
58
 
59
+ /** The same reading in the explicit form a Task Capsule publishes. */
60
+ export function workingTreeState(cwd: string): "clean" | "dirty" | "unknown" {
61
+ const uncommitted = hasUncommittedWork(cwd);
62
+ if (uncommitted === undefined) return "unknown";
63
+ return uncommitted ? "dirty" : "clean";
64
+ }
65
+
48
66
  export function inspectRepository(cwd: string): RepositoryFacts {
49
67
  const cached = repositoryCache.get(cwd);
50
68
  if (cached) return cached;
51
69
  const root = git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd;
52
- const baseRemote = git(root, ["remote", "get-url", "origin"]);
70
+ const rawRemote = git(root, ["remote", "get-url", "origin"]);
71
+ const baseRemote = rawRemote ? sanitizedRepositoryRemote(rawRemote) : undefined;
53
72
  const facts = {
54
73
  root,
55
74
  baseRemote,
56
- canonicalRepositoryId: canonicalRepositoryIdentity(baseRemote, root),
75
+ canonicalRepositoryId: canonicalRepositoryIdentity(rawRemote, root),
57
76
  worktree: git(root, ["rev-parse", "--show-toplevel"]),
58
77
  };
59
78
  repositoryCache.set(cwd, facts);
@@ -89,7 +108,9 @@ export function linkSessionsToProjects(
89
108
  name: basename(repository.root) || "Project",
90
109
  repositoryRoot: repository.root,
91
110
  canonicalRepositoryId: repository.canonicalRepositoryId,
92
- baseRemote: repository.baseRemote,
111
+ baseRemote: repository.baseRemote
112
+ ? sanitizedRepositoryRemote(repository.baseRemote)
113
+ : undefined,
93
114
  createdAt: session.startedAt,
94
115
  };
95
116
  store.upsertProject(project);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Ordered convergence for Task state.
3
+ *
4
+ * Mesh state travels through delayed relays, background wakes, and reconnects,
5
+ * so the same Task arrives out of order, more than once, and from several
6
+ * computers. Blind last-writer-wins made a stale snapshot able to restore a
7
+ * previous owner or turn finished work back into working. Every writer raises
8
+ * `revision`, and these rules decide which version survives — identically on
9
+ * every computer, so the Task converges instead of oscillating.
10
+ */
11
+ import type {
12
+ ExecutionSessionLink as ExecutionValue,
13
+ MeshTask as TaskValue,
14
+ } from "../../../../packages/protocol/schema";
15
+
16
+ /** States a Task only leaves through an explicit newer decision. */
17
+ const TERMINAL_STATES = new Set(["completed", "failed"]);
18
+
19
+ export function isTerminalTaskState(state: string): boolean {
20
+ return TERMINAL_STATES.has(state);
21
+ }
22
+
23
+ export function taskRevision(task: TaskValue): number {
24
+ return task.revision ?? 0;
25
+ }
26
+
27
+ export function nextTaskRevision(task: TaskValue): number {
28
+ return Math.min(taskRevision(task) + 1, Number.MAX_SAFE_INTEGER);
29
+ }
30
+
31
+ /** A total order, so both sides of a merge choose the same winner. */
32
+ export function preferTask(current: TaskValue, incoming: TaskValue): TaskValue {
33
+ if (taskRevision(current) !== taskRevision(incoming)) {
34
+ return taskRevision(incoming) > taskRevision(current) ? incoming : current;
35
+ }
36
+ if (current.updatedAt !== incoming.updatedAt) {
37
+ return incoming.updatedAt > current.updatedAt ? incoming : current;
38
+ }
39
+ if (isTerminalTaskState(current.state) !== isTerminalTaskState(incoming.state)) {
40
+ return isTerminalTaskState(incoming.state) ? incoming : current;
41
+ }
42
+ return taskOrderKey(incoming) > taskOrderKey(current) ? incoming : current;
43
+ }
44
+
45
+ function taskOrderKey(task: TaskValue): string {
46
+ return `${task.state}\0${task.ownerSessionId ?? ""}\0${task.title}\0${task.goal}`;
47
+ }
48
+
49
+ /** An execution that has ended stays ended; otherwise the newer reading wins. */
50
+ export function preferExecution(
51
+ current: ExecutionValue,
52
+ incoming: ExecutionValue,
53
+ ): ExecutionValue {
54
+ if ((current.endedAt != null) !== (incoming.endedAt != null)) {
55
+ const ended = current.endedAt != null ? current : incoming;
56
+ const live = current.endedAt != null ? incoming : current;
57
+ return { ...live, endedAt: ended.endedAt };
58
+ }
59
+ const currentAt = current.updatedAt ?? current.startedAt;
60
+ const incomingAt = incoming.updatedAt ?? incoming.startedAt;
61
+ if (currentAt !== incomingAt) return incomingAt > currentAt ? incoming : current;
62
+ return JSON.stringify(incoming) > JSON.stringify(current) ? incoming : current;
63
+ }
64
+
65
+ /**
66
+ * Whether `sessionId` may become the owner of `task`.
67
+ *
68
+ * Ownership moves forward only: to an unowned Task, back to the session that
69
+ * already owns it, or away from an owner whose execution this computer can see
70
+ * has ended. An owner this computer cannot see is treated as still working,
71
+ * because guessing would be exactly the split brain this prevents.
72
+ */
73
+ export function mayOwnTask(
74
+ task: TaskValue,
75
+ sessionId: string,
76
+ executions: readonly ExecutionValue[],
77
+ ): boolean {
78
+ if (task.ownerSessionId == null || task.ownerSessionId === sessionId) return true;
79
+ const owner = executions.find((item) =>
80
+ item.taskId === task.taskId && item.sessionId === task.ownerSessionId);
81
+ return owner?.endedAt != null;
82
+ }
@@ -5,23 +5,29 @@ function digest(prefix: string, value: string): string {
5
5
  return `${prefix}-${createHash("sha256").update(value).digest("base64url").slice(0, 24)}`;
6
6
  }
7
7
 
8
- function normalizedRemote(remote: string): string | undefined {
8
+ /** A public repository hint: host/path only, never credentials or URL query data. */
9
+ export function sanitizedRepositoryRemote(remote: string): string | undefined {
9
10
  const trimmed = remote.trim().replace(/\/+$/, "");
10
11
  if (!trimmed) return undefined;
11
12
  const scp = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
12
13
  if (scp && !trimmed.includes("://")) {
13
- return `${scp[1]}/${scp[2]}`.replace(/\.git$/i, "").toLowerCase();
14
+ return `${scp[1]}/${scp[2]?.split(/[?#]/, 1)[0]}`
15
+ .replace(/\.git$/i, "").toLowerCase();
14
16
  }
15
17
  try {
16
18
  const parsed = new URL(trimmed);
19
+ if (!parsed.hostname) return undefined;
17
20
  return `${parsed.hostname}${parsed.pathname}`.replace(/\.git$/i, "").toLowerCase();
18
21
  } catch {
19
- return trimmed.replace(/\.git$/i, "").toLowerCase();
22
+ if (/^[a-z0-9.-]+\/[a-z0-9._/-]+(?:\.git)?$/i.test(trimmed)) {
23
+ return trimmed.replace(/\.git$/i, "").toLowerCase();
24
+ }
25
+ return undefined;
20
26
  }
21
27
  }
22
28
 
23
29
  export function canonicalRepositoryIdentity(remote: string | undefined, root: string): string {
24
- const normalized = remote ? normalizedRemote(remote) : undefined;
30
+ const normalized = remote ? sanitizedRepositoryRemote(remote) : undefined;
25
31
  if (normalized) return normalized;
26
32
  try {
27
33
  return `local:${realpathSync(root)}`;