drafted 1.19.21 → 1.19.23
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/mcp/repo-address.mjs +215 -0
- package/mcp/server.mjs +107 -6
- package/package.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Drafted address does this working directory name?
|
|
3
|
+
*
|
|
4
|
+
* The MCP runs as a stdio process spawned in the user's repo. Until now it had
|
|
5
|
+
* no idea whether that repo was linked to a Drafted folder, so an agent working
|
|
6
|
+
* in a linked checkout would write to whatever org the session happened to
|
|
7
|
+
* inherit. The comment above `workingOrgId()` in server.mjs records where that
|
|
8
|
+
* ends: "trusting it is what wrote a Beoflow-bound agent's wiki page into
|
|
9
|
+
* Personal."
|
|
10
|
+
*
|
|
11
|
+
* Two rules shape this module:
|
|
12
|
+
*
|
|
13
|
+
* 1. It answers a PATH (/o/<org>/<folder>), never an orgId to stash. A stored
|
|
14
|
+
* org becomes an inherited default, which is the bug we are fixing, not a
|
|
15
|
+
* fix for it.
|
|
16
|
+
* 2. It does NOT normalize git URLs. Remotes go to the server raw and
|
|
17
|
+
* parseGitHubUrl (server/lib/repo-ingest.mjs) stays the single normalizer.
|
|
18
|
+
* A second normalizer that drifts from the first would reintroduce the
|
|
19
|
+
* split-master problem inside the fix itself.
|
|
20
|
+
*
|
|
21
|
+
* Side-effect free and network-free: probe() shells out to git, resolve() is
|
|
22
|
+
* pure. That split is what makes this testable without a repo or a server.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/** Run a git command in `cwd`, or null if git fails for any reason. Never
|
|
30
|
+
* throws: not-a-repo, no git binary, and a detached HEAD are all ordinary. */
|
|
31
|
+
function git(args, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
const out = execFileSync('git', args, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
timeout: 2000,
|
|
38
|
+
});
|
|
39
|
+
return out.trim() || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the git identity of a working directory.
|
|
47
|
+
*
|
|
48
|
+
* `--show-toplevel` is what makes a monorepo subdirectory and a worktree behave:
|
|
49
|
+
* any cwd inside the tree normalizes to the repo root, and a worktree reports
|
|
50
|
+
* its own root while sharing the remote.
|
|
51
|
+
*
|
|
52
|
+
* Every remote is returned, origin first. A fork whose `origin` Drafted has
|
|
53
|
+
* never seen may still have an `upstream` that is linked, and resolving that is
|
|
54
|
+
* strictly better than reporting nothing.
|
|
55
|
+
*/
|
|
56
|
+
export function probeRepo(cwd = process.cwd()) {
|
|
57
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd);
|
|
58
|
+
if (!root) return null;
|
|
59
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
60
|
+
const remotes = parseRemotes(git(['remote', '-v'], cwd));
|
|
61
|
+
return {
|
|
62
|
+
root,
|
|
63
|
+
// A detached HEAD reports the literal "HEAD"; that is not a branch name and
|
|
64
|
+
// must not be matched against repos.branch.
|
|
65
|
+
branch: branch && branch !== 'HEAD' ? branch : null,
|
|
66
|
+
remotes,
|
|
67
|
+
declared: readDeclaration(root),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `git remote -v` → [{ name, url }], deduped, origin first then upstream. */
|
|
72
|
+
export function parseRemotes(text) {
|
|
73
|
+
const seen = new Map();
|
|
74
|
+
for (const line of String(text || '').split('\n')) {
|
|
75
|
+
const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
|
76
|
+
if (m && !seen.has(m[1])) seen.set(m[1], m[2]);
|
|
77
|
+
}
|
|
78
|
+
const rank = (n) => (n === 'origin' ? 0 : n === 'upstream' ? 1 : 2);
|
|
79
|
+
return [...seen.entries()]
|
|
80
|
+
.map(([name, url]) => ({ name, url }))
|
|
81
|
+
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The optional committed declaration, `<repo root>/.drafted/config.json`.
|
|
86
|
+
*
|
|
87
|
+
* This file SELECTS among candidates the server returned; it never grants
|
|
88
|
+
* access to anything. That distinction is the whole reason it is safe to
|
|
89
|
+
* commit: "which org is this repo?" is a per-USER question (membership differs
|
|
90
|
+
* per collaborator) and a committed file is a per-REPO answer. As a filter it
|
|
91
|
+
* is correct; as an authority it would be a way for a forked repo to point your
|
|
92
|
+
* session at someone else's org.
|
|
93
|
+
*
|
|
94
|
+
* Malformed or missing is not an error — it is the normal case.
|
|
95
|
+
*/
|
|
96
|
+
export function readDeclaration(root) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(join(root, '.drafted', 'config.json'), 'utf8');
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
101
|
+
const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
102
|
+
const declared = { org: pick(parsed.org), folder: pick(parsed.folder), gitUrl: pick(parsed.gitUrl) };
|
|
103
|
+
return declared.org || declared.folder ? declared : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Turn the server's candidate list into one address, or an honest non-answer.
|
|
111
|
+
*
|
|
112
|
+
* States, and why each exists:
|
|
113
|
+
* none — not a git repo. Behaves exactly as today.
|
|
114
|
+
* unlinked — a remote exists and matched nothing. An EXPLICIT negative: a
|
|
115
|
+
* bare "not linked" is indistinguishable from "I failed to parse
|
|
116
|
+
* your remote", and that ambiguity is this feature's most likely
|
|
117
|
+
* silent failure, so the probed URLs are always echoed.
|
|
118
|
+
* linked — exactly one candidate, or the declaration picked one.
|
|
119
|
+
* ambiguous — several member orgs link this repo. Adopt NOTHING. This is the
|
|
120
|
+
* case where a wrong write is both most plausible and least
|
|
121
|
+
* detectable, so it is never guessed.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAddress(probe, matches = []) {
|
|
124
|
+
if (!probe) return { state: 'none' };
|
|
125
|
+
const base = {
|
|
126
|
+
root: probe.root,
|
|
127
|
+
branch: probe.branch,
|
|
128
|
+
remote: probe.remotes[0]?.url ?? null,
|
|
129
|
+
};
|
|
130
|
+
if (!matches.length) {
|
|
131
|
+
return { ...base, state: 'unlinked', probed: probe.remotes.map((r) => r.url) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const picked = matches.length === 1 ? matches[0] : selectByDeclaration(probe.declared, matches);
|
|
135
|
+
if (!picked) {
|
|
136
|
+
return { ...base, state: 'ambiguous', candidates: matches.map((m) => m.path) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The branch is reported, never enforced: reading a folder whose index tracks
|
|
140
|
+
// another branch is legitimate, it just has to be visible.
|
|
141
|
+
const onTrackedBranch = !probe.branch || picked.branch === probe.branch;
|
|
142
|
+
return {
|
|
143
|
+
...base,
|
|
144
|
+
state: 'linked',
|
|
145
|
+
path: picked.path,
|
|
146
|
+
org: picked.orgName,
|
|
147
|
+
orgId: picked.orgId,
|
|
148
|
+
orgSlug: picked.orgSlug,
|
|
149
|
+
folder: picked.folderName,
|
|
150
|
+
trackedBranch: picked.branch,
|
|
151
|
+
onTrackedBranch,
|
|
152
|
+
viaDeclaration: matches.length > 1,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Match a declaration against candidates by org name/slug and folder name.
|
|
157
|
+
* Returns null unless it selects exactly one — an ambiguous declaration is no
|
|
158
|
+
* better than no declaration, and must not break the tie by accident. */
|
|
159
|
+
function selectByDeclaration(declared, matches) {
|
|
160
|
+
if (!declared) return null;
|
|
161
|
+
const eq = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
|
|
162
|
+
const hits = matches.filter(
|
|
163
|
+
(m) =>
|
|
164
|
+
(!declared.org || eq(declared.org, m.orgName) || eq(declared.org, m.orgSlug)) &&
|
|
165
|
+
(!declared.folder || eq(declared.folder, m.folderName))
|
|
166
|
+
);
|
|
167
|
+
return hits.length === 1 ? hits[0] : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The prose an agent reads. Written to TEACH, like the 409 repo_owned gate:
|
|
171
|
+
* it names the address to use and the fact that other orgs stay reachable. */
|
|
172
|
+
export function addressNote(address, workingOrgId) {
|
|
173
|
+
if (!address || address.state === 'none') return null;
|
|
174
|
+
if (address.state === 'unknown') {
|
|
175
|
+
// Say WHICH question went unanswered. "Not linked" and "could not ask" look
|
|
176
|
+
// identical to an agent, and only one of them means it is safe to proceed.
|
|
177
|
+
return `This is a git checkout (${address.remote || 'no remote'}) but Drafted could not be asked whether it is linked (${address.reason}). Nothing is defaulted — address writes explicitly, and re-check with whoami once signed in.`;
|
|
178
|
+
}
|
|
179
|
+
if (address.state === 'unlinked') {
|
|
180
|
+
return `This checkout (${address.probed.join(', ') || 'no remote'}) is not linked to any Drafted folder you can see. Nothing is defaulted — address writes explicitly.`;
|
|
181
|
+
}
|
|
182
|
+
if (address.state === 'ambiguous') {
|
|
183
|
+
return `This checkout is linked in more than one org you belong to (${address.candidates.join(', ')}). Nothing is defaulted — pass org= or an absolute /o/<org>/... path.`;
|
|
184
|
+
}
|
|
185
|
+
const parts = [`Your working directory is the repo linked to ${address.path}.`];
|
|
186
|
+
if (workingOrgId && workingOrgId !== address.orgId) {
|
|
187
|
+
parts.push(`This session's working org is a DIFFERENT org — address work under ${address.path}/... unless the user asked for another org.`);
|
|
188
|
+
}
|
|
189
|
+
if (!address.onTrackedBranch) {
|
|
190
|
+
parts.push(`Note: that folder tracks branch ${address.trackedBranch} and you are on ${address.branch}, so its index describes another branch.`);
|
|
191
|
+
}
|
|
192
|
+
parts.push(`That folder's wiki and skills are git-owned: writes return 409 repo_owned and belong in a commit. Other orgs stay reachable with org= or /o/<org>/... .`);
|
|
193
|
+
return parts.join(' ');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Self-check: parsing and resolution, no git and no network.
|
|
197
|
+
export function demo() {
|
|
198
|
+
const ok = (c, m) => { if (!c) throw new Error(m); };
|
|
199
|
+
const remotes = parseRemotes('upstream\tgit@github.com:a/b.git (fetch)\nupstream\tx (push)\norigin\thttps://github.com/c/d (fetch)');
|
|
200
|
+
ok(remotes[0].name === 'origin', 'origin ranks first');
|
|
201
|
+
ok(remotes.length === 2, 'deduped to two remotes');
|
|
202
|
+
|
|
203
|
+
ok(resolveAddress(null).state === 'none', 'no repo -> none');
|
|
204
|
+
const probe = { root: '/r', branch: 'main', remotes: [{ name: 'origin', url: 'u' }], declared: null };
|
|
205
|
+
ok(resolveAddress(probe, []).state === 'unlinked', 'no matches -> unlinked');
|
|
206
|
+
|
|
207
|
+
const one = { path: '/o/x/F', orgName: 'X', orgSlug: 'x', orgId: 'o1', folderName: 'F', branch: 'main' };
|
|
208
|
+
ok(resolveAddress(probe, [one]).state === 'linked', 'single match -> linked');
|
|
209
|
+
|
|
210
|
+
const two = { ...one, path: '/o/y/G', orgName: 'Y', orgSlug: 'y', orgId: 'o2', folderName: 'G' };
|
|
211
|
+
ok(resolveAddress(probe, [one, two]).state === 'ambiguous', 'two matches -> ambiguous');
|
|
212
|
+
ok(resolveAddress({ ...probe, declared: { org: 'Y' } }, [one, two]).path === '/o/y/G', 'declaration breaks the tie');
|
|
213
|
+
ok(resolveAddress({ ...probe, declared: { org: 'Z' } }, [one, two]).state === 'ambiguous', 'declaration naming a non-candidate grants nothing');
|
|
214
|
+
console.log('repo-address self-check OK');
|
|
215
|
+
}
|
package/mcp/server.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/ex
|
|
|
24
24
|
import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
|
|
25
25
|
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
26
26
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
27
|
+
import { probeRepo, resolveAddress, addressNote } from './repo-address.mjs';
|
|
27
28
|
|
|
28
29
|
// Frame actions that mutate content — gated by G1 (wiki search before editing).
|
|
29
30
|
// Read-style actions (read, search, versions, get_*/read_*) are exempt.
|
|
@@ -398,7 +399,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
398
399
|
auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
|
|
399
400
|
|
|
400
401
|
// Identity — read-only introspection of THIS agent's session
|
|
401
|
-
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
402
|
+
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. PLUS `repo` — whether your WORKING DIRECTORY is a git checkout linked to a Drafted folder, and if so its address (`/o/<org>/<folder>`): when `repo.state` is "linked", address work under `repo.path` unless the user asked otherwise, and read `repo.note`. `workingOrg` reports the org that actually ADDRESSES your requests and where it came from (`switch`/`project`/`repo`/`none`) — this is NOT always the same as `orgId`, which is the server session\'s org. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
402
403
|
|
|
403
404
|
// Session naming — the name-before-work gate: every agent session must set a short
|
|
404
405
|
// name describing the work before any other tool call succeeds.
|
|
@@ -417,7 +418,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
417
418
|
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and write a producible into the project. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled). Requires the agent allowlist.' },
|
|
418
419
|
trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately, not retrievable later), list, update (enable/disable, edit template, daily limit, executor), rotate (new token), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
|
|
419
420
|
fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search, link, unlink, links.' },
|
|
420
|
-
repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode),
|
|
421
|
+
repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), rescan (re-fetch the tracked branch), entries (search the index). Linking and unlinking are NOT here — a human does them in the Drafted UI. Content stays in git; Drafted keeps a read-only index.' },
|
|
421
422
|
};
|
|
422
423
|
|
|
423
424
|
function isMutatingToolCall(name, args = {}) {
|
|
@@ -434,6 +435,36 @@ function isMutatingToolCall(name, args = {}) {
|
|
|
434
435
|
}
|
|
435
436
|
}
|
|
436
437
|
|
|
438
|
+
// Refuse to guess the destination, ONCE, when a write would land outside the
|
|
439
|
+
// org this checkout is linked to.
|
|
440
|
+
//
|
|
441
|
+
// Shaped exactly like the name-before-work gate: it does not block, it teaches
|
|
442
|
+
// and then gets out of the way. Repeating the same call confirms the intent and
|
|
443
|
+
// the gate never fires again this process. Report-only was not enough — the
|
|
444
|
+
// failure being fixed IS an agent not consulting available context, so adding
|
|
445
|
+
// more context it may not read reproduces the bug with better documentation.
|
|
446
|
+
//
|
|
447
|
+
// Only fires when the agent named NO org: an explicit `org=` or an absolute
|
|
448
|
+
// /o/<org>/... path is a deliberate address and is never second-guessed.
|
|
449
|
+
let crossOrgWarned = false;
|
|
450
|
+
async function getCrossOrgWriteWarning(name, args = {}) {
|
|
451
|
+
if (crossOrgWarned || mcpMode() !== 'stdio') return null;
|
|
452
|
+
if (!isMutatingToolCall(name, args)) return null;
|
|
453
|
+
if (args?.org || String(args?.path || '').startsWith('/o/')) return null;
|
|
454
|
+
|
|
455
|
+
const address = await getRepoAddress();
|
|
456
|
+
if (address.state !== 'linked') return null;
|
|
457
|
+
const current = workingOrgId();
|
|
458
|
+
if (!current || current === address.orgId) return null;
|
|
459
|
+
|
|
460
|
+
crossOrgWarned = true;
|
|
461
|
+
return [
|
|
462
|
+
`Refusing to guess the destination — this write names no org.`,
|
|
463
|
+
`Your working directory is the repo linked to ${address.path} (org "${address.org}"), but this session addresses a different org (source: ${workingOrgSource()}).`,
|
|
464
|
+
`Re-issue with the full path — e.g. ${name}(path="${address.path}/wiki/<page>") — or repeat this exact call to confirm the current org is what you meant.`,
|
|
465
|
+
].join(' ');
|
|
466
|
+
}
|
|
467
|
+
|
|
437
468
|
async function getRequiredMcpUpdateError(name, args = {}) {
|
|
438
469
|
if (mcpMode() !== 'stdio') return null;
|
|
439
470
|
if (!isMutatingToolCall(name, args)) return null;
|
|
@@ -514,6 +545,8 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
514
545
|
try {
|
|
515
546
|
const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
|
|
516
547
|
if (requiredUpdateError) return err(new Error(requiredUpdateError));
|
|
548
|
+
const crossOrgWarning = await getCrossOrgWriteWarning(name, args?.[0] || {});
|
|
549
|
+
if (crossOrgWarning) return err(new Error(crossOrgWarning));
|
|
517
550
|
// Reverse the remote JSON-string encoding (see REMOTE_JSON_STRING_PARAMS)
|
|
518
551
|
// so handlers receive the same object/array shapes they get on stdio.
|
|
519
552
|
if (isRemote && args?.[0] && typeof args[0] === 'object') {
|
|
@@ -1072,7 +1105,29 @@ async function serverFetch(url, opts = {}) {
|
|
|
1072
1105
|
// landed, so the two can never disagree.
|
|
1073
1106
|
function workingOrgId() {
|
|
1074
1107
|
const session = getSessionState();
|
|
1075
|
-
return session.boundOrgId
|
|
1108
|
+
return session.boundOrgId
|
|
1109
|
+
|| session.activeProjectMeta?.orgId
|
|
1110
|
+
|| getState().projectMeta?.orgId
|
|
1111
|
+
// Lowest rung, and only when the linked repo is UNAMBIGUOUS. It can only
|
|
1112
|
+
// ever replace null — every explicit act above outranks it, so detection
|
|
1113
|
+
// can never trap a session or retarget deliberate work. Null today falls
|
|
1114
|
+
// through to the server session's inherited org, which is the least
|
|
1115
|
+
// justified value in the chain: the user chose this checkout and linked
|
|
1116
|
+
// this repo; nobody chose the inherited org.
|
|
1117
|
+
|| session.repoOrgId
|
|
1118
|
+
|| null;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Source of the value workingOrgId() returned, for echoing in whoami. Without
|
|
1122
|
+
* this, whoami reports the SERVER SESSION's org while writes are addressed by
|
|
1123
|
+
* workingOrgId() — two different values, which is how whoami can confidently
|
|
1124
|
+
* name an org a write will not go to. */
|
|
1125
|
+
function workingOrgSource() {
|
|
1126
|
+
const session = getSessionState();
|
|
1127
|
+
if (session.boundOrgId) return 'switch';
|
|
1128
|
+
if (session.activeProjectMeta?.orgId || getState().projectMeta?.orgId) return 'project';
|
|
1129
|
+
if (session.repoOrgId) return 'repo';
|
|
1130
|
+
return 'none';
|
|
1076
1131
|
}
|
|
1077
1132
|
|
|
1078
1133
|
// One `ls` level of folders, from the org's folder list. Defined inside the
|
|
@@ -1127,7 +1182,10 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
|
|
|
1127
1182
|
}
|
|
1128
1183
|
const boundOrg = workingOrgId();
|
|
1129
1184
|
const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
|
|
1130
|
-
|
|
1185
|
+
// /api/repos/resolve is exempt: it asks which org a WORKING DIRECTORY belongs
|
|
1186
|
+
// to, so scoping it by the session's current org would answer a different
|
|
1187
|
+
// question — and the session's org is exactly what it exists to correct.
|
|
1188
|
+
if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/') && !path.startsWith('/api/repos/resolve')) {
|
|
1131
1189
|
headers['X-Drafted-Org'] = boundOrg;
|
|
1132
1190
|
}
|
|
1133
1191
|
const opts = { method, headers };
|
|
@@ -2338,17 +2396,56 @@ async function getGoogleDriveAvailability() {
|
|
|
2338
2396
|
}
|
|
2339
2397
|
}
|
|
2340
2398
|
|
|
2399
|
+
// ── Repo address: which Drafted folder is this working directory? ──
|
|
2400
|
+
//
|
|
2401
|
+
// The MCP is spawned in the user's repo but never knew whether that repo was
|
|
2402
|
+
// linked, so an agent in a linked checkout wrote to whatever org the session
|
|
2403
|
+
// inherited — see the comment above workingOrgId(). This resolves the cwd to a
|
|
2404
|
+
// PATH (/o/<org>/<folder>), which is an address the agent can use, rather than
|
|
2405
|
+
// an orgId to stash, which would just be a better-sourced inherited default.
|
|
2406
|
+
//
|
|
2407
|
+
// Cached for the process: the stdio MCP has exactly one cwd, fixed at spawn.
|
|
2408
|
+
let repoAddressCache;
|
|
2409
|
+
async function getRepoAddress() {
|
|
2410
|
+
if (repoAddressCache !== undefined) return repoAddressCache;
|
|
2411
|
+
const probe = probeRepo();
|
|
2412
|
+
// `none` means EXACTLY one thing: this is not a git checkout. It must never
|
|
2413
|
+
// be the catch-all, or "I could not reach the server" is reported as "not a
|
|
2414
|
+
// repo" — a confident wrong negative, which is worse than no detection.
|
|
2415
|
+
if (!probe) return (repoAddressCache = { state: 'none' });
|
|
2416
|
+
try {
|
|
2417
|
+
const params = new URLSearchParams();
|
|
2418
|
+
for (const r of probe.remotes) params.append('gitUrl', r.url);
|
|
2419
|
+
if (probe.branch) params.set('branch', probe.branch);
|
|
2420
|
+
// Cross-org by design, so it must NOT carry this session's org header —
|
|
2421
|
+
// that is the very context we are trying to correct.
|
|
2422
|
+
const out = await api('GET', `/api/repos/resolve?${params}`);
|
|
2423
|
+
return (repoAddressCache = resolveAddress(probe, out?.matches || []));
|
|
2424
|
+
} catch (e) {
|
|
2425
|
+
// Detection never breaks a session — but it does not lie about why either,
|
|
2426
|
+
// and it is NOT cached: an unauthenticated boot must re-ask once signed in.
|
|
2427
|
+
return { state: 'unknown', root: probe.root, branch: probe.branch,
|
|
2428
|
+
remote: probe.remotes[0]?.url ?? null, reason: e?.message || 'lookup failed' };
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2341
2432
|
// Identity + server health: report THIS agent session's own surface identity, server
|
|
2342
2433
|
// reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
|
|
2343
2434
|
// cached (5min), so repeat `whoami` calls are free; the server-side update gate still
|
|
2344
2435
|
// blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
|
|
2345
|
-
tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
|
|
2436
|
+
tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. PLUS `repo` — whether your WORKING DIRECTORY is a git checkout linked to a Drafted folder, and if so its address (`/o/<org>/<folder>`): when `repo.state` is "linked", address work under `repo.path` unless the user asked otherwise, and read `repo.note`. `workingOrg` reports the org that actually ADDRESSES your requests and where it came from (`switch`/`project`/`repo`/`none`) — this is NOT always the same as `orgId`, which is the server session\'s org. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
|
|
2346
2437
|
try {
|
|
2347
2438
|
// Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
|
|
2348
2439
|
// fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
|
|
2349
2440
|
await ensureSession();
|
|
2350
2441
|
const block = await sessionSurfaceBlock();
|
|
2351
|
-
const [mcpUpdate, googleDrive] = await Promise.all([
|
|
2442
|
+
const [mcpUpdate, googleDrive, repoAddress] = await Promise.all([
|
|
2443
|
+
getCachedMcpUpdateMetadata(), getGoogleDriveAvailability(), getRepoAddress(),
|
|
2444
|
+
]);
|
|
2445
|
+
// Adopt an unambiguous linked repo as the lowest-precedence org. Done here
|
|
2446
|
+
// rather than at connect because whoami is the bootstrap call every agent
|
|
2447
|
+
// makes, and adopting can only ever fill a null (see workingOrgId).
|
|
2448
|
+
if (repoAddress.state === 'linked') getSessionState().repoOrgId = repoAddress.orgId;
|
|
2352
2449
|
// Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
|
|
2353
2450
|
// enough; without an explicit instruction agents rarely say which session they are, so users
|
|
2354
2451
|
// can't match them to their tab on the Drafted surface.
|
|
@@ -2377,6 +2474,10 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
|
|
|
2377
2474
|
editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
|
|
2378
2475
|
agentLabel: getAgentLabel(),
|
|
2379
2476
|
googleDrive,
|
|
2477
|
+
repo: { ...repoAddress, note: addressNote(repoAddress, workingOrgId()) },
|
|
2478
|
+
// Echo the org that ADDRESSES requests, not the one the server session
|
|
2479
|
+
// happens to hold — `block.orgId` is the latter, and the two can differ.
|
|
2480
|
+
workingOrg: { orgId: workingOrgId(), source: workingOrgSource() },
|
|
2380
2481
|
...block,
|
|
2381
2482
|
...(instruction ? { instruction } : {}),
|
|
2382
2483
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.23",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|