@ctrl-spc/cs 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/companion.js +178 -18
- package/dist/daemon.js +103 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/mcp.js +59 -286
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +22 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +802 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -14,7 +14,12 @@ import { z } from 'zod';
|
|
|
14
14
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
15
15
|
import { agentPath } from './agents.js';
|
|
16
16
|
import { mcpToken, readMcpToken, readSession } from './config.js';
|
|
17
|
-
import {
|
|
17
|
+
import { readableWriteError, FIREWALL_WRITING_RULE } from './firewall.js';
|
|
18
|
+
/* The path rule itself, moved out so `workflows.ts` can ask the same question
|
|
19
|
+
before it builds a workflow. `refuseAbsolutePaths` below is still v2's own
|
|
20
|
+
wrapper: it is the one that answers in a `CallToolResult`. */
|
|
21
|
+
import { ABSOLUTE_PATH_RE, absolutePathToken } from './local-paths.js';
|
|
22
|
+
import { readPngScreenshot, screenshotArtifactId, screenshotRequestId, } from './screenshots.js';
|
|
18
23
|
/* 18c Slice 7 correction: the product captures the PNG itself, because a
|
|
19
24
|
spawned worker has no reachable way to run a capture command. The whole
|
|
20
25
|
argument is on `captureUrlScreenshot`. */
|
|
@@ -239,7 +244,7 @@ function validateGrounding(value) {
|
|
|
239
244
|
async function must(query) {
|
|
240
245
|
const { data, error } = await query;
|
|
241
246
|
if (error)
|
|
242
|
-
throw new Error(error.message);
|
|
247
|
+
throw new Error(readableWriteError(error.message));
|
|
243
248
|
return data;
|
|
244
249
|
}
|
|
245
250
|
/** The message of a thrown value, WITHOUT assuming it is an `Error`. `(err as
|
|
@@ -987,50 +992,6 @@ const SCREENSHOT_PLATFORM_LABEL = {
|
|
|
987
992
|
function attachScreenshotFailure(title, reason, created = 'artifact') {
|
|
988
993
|
return errorResult(`Couldn’t attach "${title}": ${reason}. No ${created} was created.`);
|
|
989
994
|
}
|
|
990
|
-
/** 18c Slice 7. The deterministic id for a REQUEST-anchored screenshot.
|
|
991
|
-
*
|
|
992
|
-
* IT IS THE SAME DERIVATION WITH A DIFFERENT ANCHOR AND A DIFFERENT DOMAIN
|
|
993
|
-
* TAG, and both halves of that matter. Same derivation, because the property it
|
|
994
|
-
* buys is the one the work-item path already needs: an interrupted call that
|
|
995
|
-
* uploaded the object but never wrote the row retries onto the SAME key and
|
|
996
|
-
* converges, instead of leaking one private object per attempt. Different
|
|
997
|
-
* domain tag (`:todo:`), because the two anchors must not be able to collide —
|
|
998
|
-
* a task id and a request id are both uuids, and without the tag a screenshot
|
|
999
|
-
* with the same title, platform, target and bytes could derive one id under two
|
|
1000
|
-
* anchors and have the second insert fail against the first's object. */
|
|
1001
|
-
export function screenshotRequestId(todoId, title, platform, target, bytes) {
|
|
1002
|
-
return screenshotDeterministicId('ctrl-spc:screenshot:todo:v1\0', todoId, title, platform, target, bytes);
|
|
1003
|
-
}
|
|
1004
|
-
export function screenshotArtifactId(taskId, title, platform, target, bytes) {
|
|
1005
|
-
return screenshotDeterministicId('ctrl-spc:screenshot:v1\0', taskId, title, platform, target, bytes);
|
|
1006
|
-
}
|
|
1007
|
-
/** The shared body of the two id derivations above. Extracted rather than
|
|
1008
|
-
* duplicated so the two anchors cannot drift into hashing different things:
|
|
1009
|
-
* the whole point of a content-addressed id is that the same picture yields the
|
|
1010
|
-
* same key, and two copies of this would eventually disagree about what "the
|
|
1011
|
-
* same picture" means. `domain` is what keeps them distinct. */
|
|
1012
|
-
function screenshotDeterministicId(domain, anchorId, title, platform, target, bytes) {
|
|
1013
|
-
const digest = createHash('sha256')
|
|
1014
|
-
.update(domain)
|
|
1015
|
-
.update(anchorId)
|
|
1016
|
-
.update('\0')
|
|
1017
|
-
.update(title)
|
|
1018
|
-
.update('\0')
|
|
1019
|
-
.update(platform)
|
|
1020
|
-
.update('\0')
|
|
1021
|
-
.update(target)
|
|
1022
|
-
.update('\0')
|
|
1023
|
-
.update(bytes)
|
|
1024
|
-
.digest()
|
|
1025
|
-
.subarray(0, 16);
|
|
1026
|
-
// RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
|
|
1027
|
-
// a retry after Storage succeeded but Postgres failed converge on the same
|
|
1028
|
-
// object instead of leaking one new object per retry.
|
|
1029
|
-
digest[6] = (digest[6] & 0x0f) | 0x50;
|
|
1030
|
-
digest[8] = (digest[8] & 0x3f) | 0x80;
|
|
1031
|
-
const hex = digest.toString('hex');
|
|
1032
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1033
|
-
}
|
|
1034
995
|
function isDuplicateStorageObject(error) {
|
|
1035
996
|
return /duplicate|already exists|resource exists/i.test(`${error.message} ${error.error ?? ''} ${error.name ?? ''}`);
|
|
1036
997
|
}
|
|
@@ -6071,19 +6032,6 @@ const MAX_PROPOSALS = 50;
|
|
|
6071
6032
|
* make a legal document un-acceptable — so the BATCH is what is bounded, which
|
|
6072
6033
|
* is the thing that actually protects the write. */
|
|
6073
6034
|
const MAX_PROPOSAL_BATCH_CHARS = 400_000;
|
|
6074
|
-
/** Absolute in any form a repo path can arrive in: POSIX (`/etc`), UNC and
|
|
6075
|
-
* root-relative Windows (`\\server\share`, `\Users\Lane\repo`), Windows
|
|
6076
|
-
* drive-letter (`C:\repo`, `C:/repo`) and drive-RELATIVE (`C:AGENTS.md`, which
|
|
6077
|
-
* resolves against that drive's current directory and still discloses a local
|
|
6078
|
-
* layout). One leading separator of EITHER kind, or any drive letter, is
|
|
6079
|
-
* enough — a single leading backslash is a Windows absolute path just as `/`
|
|
6080
|
-
* is a POSIX one.
|
|
6081
|
-
*
|
|
6082
|
-
* `cliv2_work_reservations_path_relative`
|
|
6083
|
-
* (20260722160000_cliv2_coordination.sql) is the same rule for the same
|
|
6084
|
-
* reason, and it has the narrower form with both of those holes. That is a
|
|
6085
|
-
* pre-existing bug on that table, not a licence to repeat it here. */
|
|
6086
|
-
const ABSOLUTE_PATH_RE = /^(?:[\\/]|[A-Za-z]:)/;
|
|
6087
6035
|
/** A `..` segment under either separator. A relative path that climbs out of
|
|
6088
6036
|
* the scanned repo names a file the scan had no business reading, and it is
|
|
6089
6037
|
* refused for the same privacy reason an absolute path is. */
|
|
@@ -7698,220 +7646,6 @@ const STEP_SOURCE_KINDS = ['workflow', 'plan'];
|
|
|
7698
7646
|
* sets the opening value; `update_step` writes every transition after it —
|
|
7699
7647
|
* including 'done', which is the only one `cliv2_steps_up_next` advances on. */
|
|
7700
7648
|
const STEP_STATUSES = ['pending', 'in_progress', 'blocked', 'interrupted', 'done'];
|
|
7701
|
-
/** Decode the HTML entities a browser would decode, so the guard scans what
|
|
7702
|
-
* the USER will eventually read rather than what the agent happened to type.
|
|
7703
|
-
* Without this, `/Users/lane/x` sails past every scan and then
|
|
7704
|
-
* `interactiveArtifactHtml()` — which parses with DOMParser — paints the
|
|
7705
|
-
* real absolute path into hosted browser JS. The guard and the renderer must
|
|
7706
|
-
* agree about what a string SAYS; entities are exactly where they diverge.
|
|
7707
|
-
* Numeric (decimal and hex) plus the small set of named entities that can
|
|
7708
|
-
* spell a path separator or a drive colon. */
|
|
7709
|
-
const NAMED_ENTITIES = {
|
|
7710
|
-
sol: '/',
|
|
7711
|
-
bsol: '\\',
|
|
7712
|
-
colon: ':',
|
|
7713
|
-
period: '.',
|
|
7714
|
-
lowbar: '_',
|
|
7715
|
-
quot: '"',
|
|
7716
|
-
apos: "'",
|
|
7717
|
-
amp: '&',
|
|
7718
|
-
lt: '<',
|
|
7719
|
-
gt: '>',
|
|
7720
|
-
};
|
|
7721
|
-
/* The trailing `;` is OPTIONAL for numeric entities, because browsers accept
|
|
7722
|
-
it that way: `/Users` renders as `/Users`. A guard that required the
|
|
7723
|
-
semicolon allowed exactly that string through while the renderer painted a
|
|
7724
|
-
real path — found by differentially testing this function against jsdom's
|
|
7725
|
-
DOMParser, which is the same parser interactiveArtifactHtml() uses. Named
|
|
7726
|
-
entities keep the required `;` (that is what browsers do outside a short
|
|
7727
|
-
legacy list, and dropping it would eat `&sole` in ordinary prose). */
|
|
7728
|
-
function decodeEntities(text) {
|
|
7729
|
-
return text.replace(/&(?:(#[Xx][0-9A-Fa-f]+|#\d+);?|([A-Za-z][A-Za-z0-9]*);)/g, (whole, numericBody, namedBody) => {
|
|
7730
|
-
const body = numericBody ?? namedBody ?? '';
|
|
7731
|
-
if (body.startsWith('#')) {
|
|
7732
|
-
const code = body[1] === 'x' || body[1] === 'X'
|
|
7733
|
-
? Number.parseInt(body.slice(2), 16)
|
|
7734
|
-
: Number.parseInt(body.slice(1), 10);
|
|
7735
|
-
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
|
7736
|
-
}
|
|
7737
|
-
// Named entities are case-SENSITIVE in HTML: `∷` is U+2237 (∷), not
|
|
7738
|
-
// a colon, so lower-casing here refused text the browser never renders as
|
|
7739
|
-
// a path. Exact match only.
|
|
7740
|
-
const named = NAMED_ENTITIES[body];
|
|
7741
|
-
return named ?? whole;
|
|
7742
|
-
});
|
|
7743
|
-
}
|
|
7744
|
-
/**
|
|
7745
|
-
* The first absolute local path inside `text`, or null.
|
|
7746
|
-
*
|
|
7747
|
-
* THE DESIGN, and why it is not the previous one. This helper used to split
|
|
7748
|
-
* the text into tokens on a separator list and test each token. Three review
|
|
7749
|
-
* rounds each answered a leak by adding characters to that list (`(,=;`, then
|
|
7750
|
-
* `<>"'`), and the third round proved the approach is wrong rather than
|
|
7751
|
-
* incomplete: a quote *inside* a path (`/'Users/lane/x`, `/Users/lane/Lane's
|
|
7752
|
-
* Docs/x`) SPLIT the path into fragments, none of which starts with a root
|
|
7753
|
-
* marker — so widening the separator list to catch markup simultaneously
|
|
7754
|
-
* opened a hole for paths containing that markup, and the refusal message for
|
|
7755
|
-
* a legitimate apostrophe path named a truncated path that did not exist.
|
|
7756
|
-
* Separators cannot both be inside and outside the thing being matched.
|
|
7757
|
-
*
|
|
7758
|
-
* So: do not tokenise. SCAN for the path shape itself, anchored at a root
|
|
7759
|
-
* marker, and let the match end where a character that cannot appear in a
|
|
7760
|
-
* path appears. A path is a match, not a token. The exclusions below are the
|
|
7761
|
-
* genuine false positives, tested against the same corpus as before:
|
|
7762
|
-
*
|
|
7763
|
-
* - a URL's authority (`https://host/path`) is not a local path — a match
|
|
7764
|
-
* immediately preceded by `//` of a non-`file:` scheme is skipped, while
|
|
7765
|
-
* `file:///Users/…` IS reported (its body is a real local path);
|
|
7766
|
-
* - a bare drive label `A:` with no path body is a list marker;
|
|
7767
|
-
* - a single-segment `/word` (`/dashboard`, `/api/`) is a URL path — local
|
|
7768
|
-
* paths in prose always carry a second separator.
|
|
7769
|
-
*
|
|
7770
|
-
* Entities are decoded first (see `decodeEntities`), because the renderer
|
|
7771
|
-
* decodes them too.
|
|
7772
|
-
*/
|
|
7773
|
-
/* The scan finds the path SHAPE anywhere in the text and decides what it is
|
|
7774
|
-
from WHAT MATCHED, not from what precedes it.
|
|
7775
|
-
*
|
|
7776
|
-
* A previous round anchored this with a lookbehind excluding "characters a
|
|
7777
|
-
* path cannot follow". That was the separator-list mistake one layer down: the
|
|
7778
|
-
* exclusion set is itself a character list, and every character in it became a
|
|
7779
|
-
* hiding place. `~/Users/lane/x`, `x./Users/lane/x` and a path embedded in a
|
|
7780
|
-
* URL all leaked, and `~/…` is an entirely ordinary thing for an agent to
|
|
7781
|
-
* write. A rule that says "not after these characters" can always be defeated
|
|
7782
|
-
* by writing one of them first.
|
|
7783
|
-
*
|
|
7784
|
-
* So there is no lookbehind. Instead the two genuine false positives are
|
|
7785
|
-
* excluded by structure, below:
|
|
7786
|
-
* - a repo-relative path (`web/src/App.tsx`) never starts at a root marker,
|
|
7787
|
-
* so requiring the match to BEGIN with `/`, `\\` or `C:` already excludes
|
|
7788
|
-
* it — `/src/App.tsx` inside it is only reachable mid-token, which the
|
|
7789
|
-
* single-segment and second-separator rules then handle;
|
|
7790
|
-
* - a URL's authority is recognised by its own scheme, checked explicitly.
|
|
7791
|
-
*
|
|
7792
|
-
* `'` and `"` are NOT body terminators — a quote inside a path is exactly the
|
|
7793
|
-
* case that made the separator-splitting design leak. */
|
|
7794
|
-
const ABSOLUTE_PATH_SCAN = /(?:[A-Za-z]:[\\/'"]|\\\\|\/)[^\s<>`(){}\[\],;|&*?\n\r\t]*/g;
|
|
7795
|
-
/* The POSIX roots that actually hold a user's files. A leading `/` alone does
|
|
7796
|
-
NOT make a machine path — `/api/items`, `/img/logo.png` and `/dashboard` are
|
|
7797
|
-
site paths, and an agent's panel is full of them (href, src, srcset, CSS
|
|
7798
|
-
url()). Refusing those would make the house style unusable and agents would
|
|
7799
|
-
route around the guard, which is worse than a narrower rule. These roots are
|
|
7800
|
-
the ones whose contents are private to the machine. */
|
|
7801
|
-
const POSIX_MACHINE_ROOT = /^\/(?:Users|home|root|var|etc|opt|srv|private|tmp|mnt|media|Volumes|Applications|Library|System|usr\/local|dev)(?:\/|$)/i;
|
|
7802
|
-
/** A machine path buried inside a longer match — the segment of a remote URL
|
|
7803
|
-
* that spells one (`https://host/x/Users/lane/secret.txt`). The scan's body
|
|
7804
|
-
* is greedy, so such a path never gets a match of its own; it has to be dug
|
|
7805
|
-
* out of the match that swallowed it. Rendered in front of a viewer, it
|
|
7806
|
-
* discloses exactly what bare text would. */
|
|
7807
|
-
function buriedMachinePath(candidate) {
|
|
7808
|
-
// A Windows path can be buried too — `https://host/xC:\Users\Lane\x` — and
|
|
7809
|
-
// it does not begin at a `/`, so scanning only slash positions missed it.
|
|
7810
|
-
// Shape, not just a colon-slash: a drive letter, a separator, then at least
|
|
7811
|
-
// one real segment. `a:/b` in a URL query is not a Windows path; the `\` or
|
|
7812
|
-
// a `Users`-style segment is what makes it one. Requiring a BACKSLASH
|
|
7813
|
-
// separator keeps this narrow — a forward-slash drive path (`C:/x`) inside a
|
|
7814
|
-
// URL is indistinguishable from an ordinary URL fragment, and the POSIX pass
|
|
7815
|
-
// below already covers the roots that matter.
|
|
7816
|
-
const drive = /[A-Za-z]:\\[^\s<>"'`]+/.exec(candidate);
|
|
7817
|
-
if (drive)
|
|
7818
|
-
return candidate.slice(drive.index);
|
|
7819
|
-
for (let at = candidate.indexOf('/'); at !== -1; at = candidate.indexOf('/', at + 1)) {
|
|
7820
|
-
const tail = candidate.slice(at);
|
|
7821
|
-
if (POSIX_MACHINE_ROOT.test(tail))
|
|
7822
|
-
return tail;
|
|
7823
|
-
}
|
|
7824
|
-
return null;
|
|
7825
|
-
}
|
|
7826
|
-
function absolutePathToken(text) {
|
|
7827
|
-
const decoded = decodeEntities(text);
|
|
7828
|
-
// `file:///Users/lane/x` is a local path wearing a URL. Handle it up front
|
|
7829
|
-
// and by NAME, rather than leaving the generic scan to reason about where a
|
|
7830
|
-
// scheme ends — that reasoning is what produced two of this function's bugs.
|
|
7831
|
-
const fileUrl = /\bfile:\/\/(\/\S*)/i.exec(decoded);
|
|
7832
|
-
if (fileUrl && POSIX_MACHINE_ROOT.test(fileUrl[1]))
|
|
7833
|
-
return fileUrl[1];
|
|
7834
|
-
ABSOLUTE_PATH_SCAN.lastIndex = 0;
|
|
7835
|
-
let match;
|
|
7836
|
-
while ((match = ABSOLUTE_PATH_SCAN.exec(decoded)) !== null) {
|
|
7837
|
-
// Trailing wrapping punctuation belongs to the prose, not the path:
|
|
7838
|
-
// `("/Users/lane/x.md")` and `see /Users/lane/x.md.` both end early.
|
|
7839
|
-
const candidate = match[0].replace(/["')\].,:;]+$/, '');
|
|
7840
|
-
if (!candidate || !ABSOLUTE_PATH_RE.test(candidate))
|
|
7841
|
-
continue;
|
|
7842
|
-
const before = decoded.slice(0, match.index);
|
|
7843
|
-
// A URL: skip its authority and its ordinary path — `https://host/a/b`
|
|
7844
|
-
// matches at `//host/a/b` and again at `/b`, and neither is a local path.
|
|
7845
|
-
// The scheme identifies it, so look for the scheme rather than testing the
|
|
7846
|
-
// character immediately before. (`file:` never reaches here; it is handled
|
|
7847
|
-
// by name above.)
|
|
7848
|
-
//
|
|
7849
|
-
// EXCEPT when the URL's path is itself rooted at a machine root. A remote
|
|
7850
|
-
// URL spelling `…/x/Users/lane/private/secret.txt` puts that path in front
|
|
7851
|
-
// of a viewer just as plainly as bare text does, and the contract is about
|
|
7852
|
-
// the string reaching hosted browser JS, not about how it got there. A
|
|
7853
|
-
// false alarm costs the agent one retry; a leak is permanent, so this errs
|
|
7854
|
-
// toward refusing.
|
|
7855
|
-
if (/([A-Za-z][A-Za-z0-9+.-]*):\/\/\S*$/.test(before) && !POSIX_MACHINE_ROOT.test(candidate)) {
|
|
7856
|
-
const buried = buriedMachinePath(candidate);
|
|
7857
|
-
if (buried)
|
|
7858
|
-
return buried;
|
|
7859
|
-
continue;
|
|
7860
|
-
}
|
|
7861
|
-
// `https://host/x` also matches AT the scheme's own `s://…`, which reads
|
|
7862
|
-
// as a one-letter drive. A real drive letter is followed by a separator
|
|
7863
|
-
// and then a path segment — never by `//`. Before discarding it, check
|
|
7864
|
-
// whether a machine root is buried INSIDE: the scan's body is greedy, so
|
|
7865
|
-
// `s://example.com/x/Users/lane/x` is one match and the `/Users/…` inside
|
|
7866
|
-
// it never gets a match of its own. A remote URL spelling a machine path
|
|
7867
|
-
// discloses it to a viewer just as plainly as bare text does.
|
|
7868
|
-
if (/^[A-Za-z]:\/\//.test(candidate)) {
|
|
7869
|
-
const buried = buriedMachinePath(candidate);
|
|
7870
|
-
if (buried)
|
|
7871
|
-
return buried;
|
|
7872
|
-
continue;
|
|
7873
|
-
}
|
|
7874
|
-
// A CONTINUATION of a relative path: `web/src/App.tsx` matches at
|
|
7875
|
-
// `/src/App.tsx`, which is not a local path — the token it belongs to
|
|
7876
|
-
// began with a bare word. Walk back to the start of the whole token and
|
|
7877
|
-
// ask what IT begins with. This is deliberately not "is the preceding
|
|
7878
|
-
// character in a set": `~/Users/lane/x` and `x./Users/lane/x` both have a
|
|
7879
|
-
// path-ish character before the slash, and both DO carry a real absolute
|
|
7880
|
-
// path, so a character test leaks them. Reading the token's own first
|
|
7881
|
-
// character answers correctly in every one of those cases.
|
|
7882
|
-
// The token runs back to whitespace OR a markup boundary (`<>"'=`) — a
|
|
7883
|
-
// quote or a tag bracket ends the token even though it is not whitespace,
|
|
7884
|
-
// which is what lets `class="mono">/Users/…` be seen as a path rather than
|
|
7885
|
-
// as the tail of the token `class`.
|
|
7886
|
-
// Only a token that is itself a plausible RELATIVE PATH suppresses the
|
|
7887
|
-
// match — `web/src` in `web/src/App.tsx`. A bare word (`x.`), a number
|
|
7888
|
-
// (`1`), or anything not shaped like a path segment does not, because
|
|
7889
|
-
// `x./Users/lane/x` and `1/Users/lane/x` still carry a real absolute path.
|
|
7890
|
-
const tokenStart = /([^\s<>"'=]+)$/.exec(before)?.[1] ?? '';
|
|
7891
|
-
if (/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]+)*\/?$/.test(tokenStart)
|
|
7892
|
-
&& tokenStart.includes('/')
|
|
7893
|
-
&& !POSIX_MACHINE_ROOT.test(candidate))
|
|
7894
|
-
continue;
|
|
7895
|
-
if (/^[A-Za-z]:$/.test(candidate))
|
|
7896
|
-
continue;
|
|
7897
|
-
// A POSIX candidate is only a MACHINE path if it starts at a real root.
|
|
7898
|
-
// `/img/x.png`, `/api/items`, `/dashboard` and `2026/07/31` are site paths
|
|
7899
|
-
// and dates — they are not local paths, they are the ordinary content of
|
|
7900
|
-
// an agent's panel (CSS urls, hrefs, srcset), and refusing them makes the
|
|
7901
|
-
// house style unusable. The roots below are the ones that actually carry a
|
|
7902
|
-
// user's files; a path under any of them is refused, everything else is
|
|
7903
|
-
// left alone. Windows (`C:\…`) and UNC (`\\host\share`) are unambiguous by
|
|
7904
|
-
// construction and are not filtered here.
|
|
7905
|
-
// Strip a quote sitting immediately after the root before classifying:
|
|
7906
|
-
// `/'Users/lane/x` is the separator-design leak, and the root test must
|
|
7907
|
-
// see `/Users/…` to recognise it.
|
|
7908
|
-
const rooted = candidate.replace(/^([\\/]+)["']/, '$1');
|
|
7909
|
-
if (rooted.startsWith('/') && !POSIX_MACHINE_ROOT.test(rooted))
|
|
7910
|
-
continue;
|
|
7911
|
-
return candidate;
|
|
7912
|
-
}
|
|
7913
|
-
return null;
|
|
7914
|
-
}
|
|
7915
7649
|
/** Refuse the WHOLE call when any field carries an absolute local path —
|
|
7916
7650
|
* naming the field and the offending token, so the agent can fix exactly
|
|
7917
7651
|
* that. Refusal happens before any write; nothing is rewritten. */
|
|
@@ -10015,7 +9749,9 @@ const SERVER_INSTRUCTIONS = `You are connected to CTRL+SPC, where this user's wo
|
|
|
10015
9749
|
|
|
10016
9750
|
3. KEEP THE TERMINAL TERSE. The user is watching CTRL+SPC, not this conversation. Do not narrate, do not paste long output, do not hold the discussion here. Say in one or two lines what you did and point them at CTRL+SPC to see it. Ask questions through ask_question so the answer is recorded, not through terminal prose that vanishes.
|
|
10017
9751
|
|
|
10018
|
-
The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives
|
|
9752
|
+
The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.
|
|
9753
|
+
|
|
9754
|
+
Everything you write into CTRL+SPC is saved the same way, whichever tool you use. ${FIREWALL_WRITING_RULE}`;
|
|
10019
9755
|
export function buildToolsServer(client, userId, machineId, connectionId,
|
|
10020
9756
|
/** 18c SLICE 1 — the request (`cliv2_loose_todos.id`) this connection is
|
|
10021
9757
|
* working, from the connection's own URL rather than a tool argument the
|
|
@@ -10079,7 +9815,8 @@ runTodoIdSource = null) {
|
|
|
10079
9815
|
+ 'client (get_client_context lists them under `projects` — one project resolves the choice itself, several means '
|
|
10080
9816
|
+ "you choose and say why). Ground the idea in the client's own words in a CITED ARTIFACT on it "
|
|
10081
9817
|
+ '(create_artifact) — the description is the human\'s plain-text surface and carries no citation lines. '
|
|
10082
|
-
+ CITATION_LINE_TEACHING
|
|
9818
|
+
+ CITATION_LINE_TEACHING + ' '
|
|
9819
|
+
+ FIREWALL_WRITING_RULE,
|
|
10083
9820
|
inputSchema: {
|
|
10084
9821
|
project_id: z.string(),
|
|
10085
9822
|
title: z
|
|
@@ -10133,7 +9870,8 @@ runTodoIdSource = null) {
|
|
|
10133
9870
|
'`grounding` naming exactly what you read. The manifest is stored with the artifact and its ' +
|
|
10134
9871
|
'absence is visible to every reader: a user_story written WITHOUT reading available client ' +
|
|
10135
9872
|
'context must say so in its opening line. ' +
|
|
10136
|
-
CITATION_LINE_TEACHING
|
|
9873
|
+
CITATION_LINE_TEACHING + ' ' +
|
|
9874
|
+
FIREWALL_WRITING_RULE,
|
|
10137
9875
|
inputSchema: {
|
|
10138
9876
|
task_id: z.string(),
|
|
10139
9877
|
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
|
|
@@ -10226,7 +9964,8 @@ runTodoIdSource = null) {
|
|
|
10226
9964
|
+ 'That is not a default you can be talked out of: if the user asks you to rewrite a description that has content, say you cannot and offer the artifact, rather than offering to do it anyway. '
|
|
10227
9965
|
+ 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
|
|
10228
9966
|
'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
|
|
10229
|
-
'call get_task again to re-read and retry. The change appears in the web board.'
|
|
9967
|
+
'call get_task again to re-read and retry. The change appears in the web board. ' +
|
|
9968
|
+
FIREWALL_WRITING_RULE,
|
|
10230
9969
|
inputSchema: {
|
|
10231
9970
|
id: z.string().describe('Task id'),
|
|
10232
9971
|
expected_revision: z
|
|
@@ -10248,7 +9987,8 @@ runTodoIdSource = null) {
|
|
|
10248
9987
|
server.registerTool('update_artifact', {
|
|
10249
9988
|
description: 'Edit an artifact you can access — its title, content, type, or format. Pass expected_revision from the ' +
|
|
10250
9989
|
'most recent get_task (optimistic concurrency); on a conflict, call get_task again and retry. ' +
|
|
10251
|
-
'The change appears in the web UI.'
|
|
9990
|
+
'The change appears in the web UI. ' +
|
|
9991
|
+
FIREWALL_WRITING_RULE,
|
|
10252
9992
|
inputSchema: {
|
|
10253
9993
|
id: z.string(),
|
|
10254
9994
|
expected_revision: z
|
|
@@ -10320,7 +10060,8 @@ runTodoIdSource = null) {
|
|
|
10320
10060
|
return setTaskRoleSlugsHandler(client, args);
|
|
10321
10061
|
});
|
|
10322
10062
|
server.registerTool('add_comment', {
|
|
10323
|
-
description: 'Leave a note on a task. The comment appears in the web UI, authored by you.'
|
|
10063
|
+
description: 'Leave a note on a task. The comment appears in the web UI, authored by you. ' +
|
|
10064
|
+
FIREWALL_WRITING_RULE,
|
|
10324
10065
|
inputSchema: {
|
|
10325
10066
|
task_id: z.string().describe('Task id'),
|
|
10326
10067
|
body: z.string().min(1),
|
|
@@ -10340,7 +10081,8 @@ runTodoIdSource = null) {
|
|
|
10340
10081
|
'READ BEFORE YOU WRITE: list what exists (list_tasks) before adding structure, and cut items ' +
|
|
10341
10082
|
'along real seams — separately buildable, separately testable — not one item per noun in the ' +
|
|
10342
10083
|
'ask. Add NO duration estimates to names or descriptions unless the user asked: agents ' +
|
|
10343
|
-
'overestimate toward human timelines, and sequencing, not duration, is the value.'
|
|
10084
|
+
'overestimate toward human timelines, and sequencing, not duration, is the value. ' +
|
|
10085
|
+
FIREWALL_WRITING_RULE,
|
|
10344
10086
|
inputSchema: {
|
|
10345
10087
|
project_id: z.string().describe('Project id the task belongs to'),
|
|
10346
10088
|
name: z.string().min(1),
|
|
@@ -11112,7 +10854,8 @@ runTodoIdSource = null) {
|
|
|
11112
10854
|
'present_wireframes ' +
|
|
11113
10855
|
'again for iterations of the same design. Requires an open session (begin_work). If diagrams are created ' +
|
|
11114
10856
|
'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
|
|
11115
|
-
'— follow them instead of calling this tool again.'
|
|
10857
|
+
'— follow them instead of calling this tool again. ' +
|
|
10858
|
+
FIREWALL_WRITING_RULE,
|
|
11116
10859
|
inputSchema: {
|
|
11117
10860
|
title: z.string().min(1).describe('Short title for the presentation (shown on the work item)'),
|
|
11118
10861
|
kind: z.enum(['ui', 'architecture']).describe("'ui' for wireframes, 'architecture' for code/flow diagrams"),
|
|
@@ -11162,7 +10905,8 @@ runTodoIdSource = null) {
|
|
|
11162
10905
|
'present_mocks ' +
|
|
11163
10906
|
'again for iterations of the same design. Requires an open session (begin_work). If mocks are created ' +
|
|
11164
10907
|
'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
|
|
11165
|
-
'— follow them instead of calling this tool again.'
|
|
10908
|
+
'— follow them instead of calling this tool again. ' +
|
|
10909
|
+
FIREWALL_WRITING_RULE,
|
|
11166
10910
|
// This schema DECLARES every field the handler reads and CONSTRAINS none
|
|
11167
10911
|
// of what the handler validates — the two halves of the Phase 1 defect.
|
|
11168
10912
|
// DECLARE, because the SDK parses args with z.object(inputSchema) and
|
|
@@ -11247,7 +10991,8 @@ runTodoIdSource = null) {
|
|
|
11247
10991
|
});
|
|
11248
10992
|
server.registerTool('record_context_exploration', {
|
|
11249
10993
|
description: "Record what you explored for this work item as its context document (the canonical 'Work item context' " +
|
|
11250
|
-
'for the item). Repeated calls update the same document. Appears in the web UI.'
|
|
10994
|
+
'for the item). Repeated calls update the same document. Appears in the web UI. ' +
|
|
10995
|
+
FIREWALL_WRITING_RULE,
|
|
11251
10996
|
inputSchema: {
|
|
11252
10997
|
content: z.string().min(1),
|
|
11253
10998
|
},
|
|
@@ -11476,7 +11221,8 @@ runTodoIdSource = null) {
|
|
|
11476
11221
|
'writes proposals the user accepts or rejects in the web app, under Project settings → Project ' +
|
|
11477
11222
|
'context. Say so when you report back. Re-running replaces this codebase\'s pending proposals; send ' +
|
|
11478
11223
|
'an empty `proposals` array to report that the repo holds no standing context, which clears them. ' +
|
|
11479
|
-
"Uses the open session's task when `task_id` is omitted."
|
|
11224
|
+
"Uses the open session's task when `task_id` is omitted. " +
|
|
11225
|
+
FIREWALL_WRITING_RULE,
|
|
11480
11226
|
inputSchema: {
|
|
11481
11227
|
task_id: z
|
|
11482
11228
|
.string()
|
|
@@ -12317,8 +12063,26 @@ function sessionForAttribution(connectionId) {
|
|
|
12317
12063
|
* token the presence loop rebuilt after a wedge/refresh flows into the session
|
|
12318
12064
|
* heartbeat too (FIX 2 — the CLI-v1 stale-token root cause). Only takes effect
|
|
12319
12065
|
* while the tools server is running; the per-connection tool handlers keep their
|
|
12320
|
-
* own captured client, which is acceptable
|
|
12321
|
-
* stay healthy for the presence chip.
|
|
12066
|
+
* own captured client, which is acceptable here, because the session heartbeat
|
|
12067
|
+
* is what must stay healthy for the presence chip.
|
|
12068
|
+
*
|
|
12069
|
+
* THAT REASONING IS LOCAL TO THIS FILE AND DOES NOT CARRY ACROSS. A `panel3`
|
|
12070
|
+
* tool handler serves project writes rather than a heartbeat, so a captured
|
|
12071
|
+
* client there is a refused write against real work, not a chip that goes grey.
|
|
12072
|
+
* `panel3/tools.ts` takes its client by value and fans it out to every handler
|
|
12073
|
+
* for that connection's life, and the poll-storm fix deliberately stopped at
|
|
12074
|
+
* that boundary. The gap is named, with what closing it would cost, in the
|
|
12075
|
+
* out-of-scope list of
|
|
12076
|
+
* `.bugs/20260831-supabase-health/plan-03-dead-session-poll-loop.md`.
|
|
12077
|
+
*
|
|
12078
|
+
* ═══ THE `handle` GUARD IS WHY presence.ts CALLS THIS TWICE. ═══ Refusing
|
|
12079
|
+
* while the server is down is right, since there is nothing to serve and no
|
|
12080
|
+
* reason to hold a client. It also means the first `heartbeat`'s own
|
|
12081
|
+
* `setToolsClient` is a no-op, because that heartbeat runs BEFORE
|
|
12082
|
+
* `startToolsServer` sets `handle`. `startPresence` therefore calls this again
|
|
12083
|
+
* the moment the server is up, with the client that is live by then rather
|
|
12084
|
+
* than the one it started the server with. Without that second call a rebuild
|
|
12085
|
+
* during startup would leave `toolsClient` on the original client forever. */
|
|
12322
12086
|
export function setToolsClient(client) {
|
|
12323
12087
|
if (handle)
|
|
12324
12088
|
toolsClient = client;
|
|
@@ -12727,6 +12491,15 @@ export async function heartbeatOpenSessions() {
|
|
|
12727
12491
|
* healed, while a per-statement refusal is recorded by either. That is the
|
|
12728
12492
|
* whole reason this lives here rather than in the handler.
|
|
12729
12493
|
*
|
|
12494
|
+
* ═══ AND IT REALLY IS THE REBUILT ONE, WHICH IT WAS NOT UNTIL THE ORDERING WAS
|
|
12495
|
+
* FIXED. ═══ `startToolsServer` sets `toolsClient = deps.client`, and
|
|
12496
|
+
* `startPresence` starts the server AFTER its first heartbeat, so a rebuild in
|
|
12497
|
+
* that first heartbeat used to be lost twice over: `setToolsClient` refused it
|
|
12498
|
+
* (no `handle` yet) and the later `startToolsServer` then overwrote the field
|
|
12499
|
+
* with the pre-rebuild client anyway. `startPresence` now calls
|
|
12500
|
+
* `setToolsClient` once more the moment the server is up, so the sentence above
|
|
12501
|
+
* is true from the first tick rather than only after a second rebuild.
|
|
12502
|
+
*
|
|
12730
12503
|
* BEST-EFFORT AND NEVER THROWS, following `writeWorkerStep` and the session
|
|
12731
12504
|
* helpers above. This is bookkeeping about a failure; it must not turn one
|
|
12732
12505
|
* refused line into a failed tool call, because the tool's own error is the
|
package/dist/panel3/client.js
CHANGED
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
import { createClient } from '@supabase/supabase-js';
|
|
119
119
|
import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
|
|
120
120
|
import { readSession } from '../config.js';
|
|
121
|
+
import { readableWriteError } from '../firewall.js';
|
|
121
122
|
/**
|
|
122
123
|
* How a v3 client holds its session, for the two lifetimes v3 has.
|
|
123
124
|
*
|
|
@@ -202,7 +203,7 @@ export async function signedInClient(living = false) {
|
|
|
202
203
|
export async function returned(query, verb, subject) {
|
|
203
204
|
const { data, error } = await query;
|
|
204
205
|
if (error)
|
|
205
|
-
throw new Error(`could not ${verb} ${subject}: ${error.message}`);
|
|
206
|
+
throw new Error(`could not ${verb} ${subject}: ${readableWriteError(error.message)}`);
|
|
206
207
|
if (!data) {
|
|
207
208
|
throw new Error(`could not ${verb} ${subject}: neither data nor an error came back`);
|
|
208
209
|
}
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
* how to write it travels with `write_report`'s own description, where ux.md
|
|
131
131
|
* says the how belongs.
|
|
132
132
|
*/
|
|
133
|
+
import { FIREWALL_WRITING_RULE } from '../firewall.js';
|
|
133
134
|
/**
|
|
134
135
|
* ═══ WHAT EVERY LEVEL IS TOLD ABOUT THE THING IT SENDS BACK. ═══
|
|
135
136
|
*
|
|
@@ -429,9 +430,12 @@ const projectCodebases = (codebases) => {
|
|
|
429
430
|
* — which matters because the daemon writes it to `panel3_runs.brief`.
|
|
430
431
|
*
|
|
431
432
|
* ═══ `cardTitle` IS A NAME, NOT THE QUESTION, AND ITS LABEL SAYS SO. ═══ `say`
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
433
|
+
* writes it as the truncated first line of the FIRST message, and the launcher
|
|
434
|
+
* may replace it once, with `dispatch`'s `work_name`, so that it names the work
|
|
435
|
+
* rather than that sentence. Either way it names the CONVERSATION and not what
|
|
436
|
+
* is currently being asked: it is written before the first answer and nothing
|
|
437
|
+
* touches it after. Labelled as what is being asked, a second turn would tell
|
|
438
|
+
* the agent the current question is the opening line and then hand it the real
|
|
435
439
|
* ones underneath — so the label names the conversation, which is all the title
|
|
436
440
|
* has ever been.
|
|
437
441
|
*
|
|
@@ -494,6 +498,18 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
|
|
|
494
498
|
'',
|
|
495
499
|
'WHAT THIS CONVERSATION IS CALLED',
|
|
496
500
|
cardTitle,
|
|
501
|
+
/* ═══ THE ONE MOMENT ANYTHING CAN NAME THE WORK. ═══ Until the owner is
|
|
502
|
+
sent, this is the truncated first line of the person's first message, and
|
|
503
|
+
sending the owner cuts the branch for the work from it and stamps that
|
|
504
|
+
branch for good. The launcher is the only thing that runs in between, and
|
|
505
|
+
it has the messages in front of it, so it is the only thing that can say
|
|
506
|
+
what the work is before both are fixed. */
|
|
507
|
+
'This is the first line of what they typed, so it names their sentence rather than the work. If',
|
|
508
|
+
'the person pointed this conversation at a work item, that item is already its name and there is',
|
|
509
|
+
'nothing to do. Otherwise pass `work_name` to `dispatch`: a few words, six at most, naming the',
|
|
510
|
+
'work itself, such as "Fix the sign-out checklist bug". The conversation is called that from',
|
|
511
|
+
'then on, and the branch the work goes on is cut from it, both as you send the owner. This is',
|
|
512
|
+
'the only moment either can be named.',
|
|
497
513
|
'',
|
|
498
514
|
'WHAT HAS ALREADY BEEN MADE HERE',
|
|
499
515
|
'Everything this conversation has produced so far, whatever it was that made it, with the id to',
|
|
@@ -727,6 +743,9 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
|
|
|
727
743
|
'not what you were halfway through. Notes written at the end of the work are notes that were',
|
|
728
744
|
'never written.',
|
|
729
745
|
'',
|
|
746
|
+
'ANYTHING YOU SAVE GOES THROUGH A FIREWALL',
|
|
747
|
+
FIREWALL_WRITING_RULE,
|
|
748
|
+
'',
|
|
730
749
|
...WHILE_YOU_ARE_WORKING,
|
|
731
750
|
'',
|
|
732
751
|
...(level === 2
|