@sigrun/sigrun 0.6.0 → 0.7.0
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/CHANGELOG.md +102 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/packages.js +7 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +13 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
package/dist/curation/targets.js
CHANGED
|
@@ -13,7 +13,16 @@
|
|
|
13
13
|
* extension's unlisted direct write tools (a "remember this" path the
|
|
14
14
|
* extension owns itself) are never intercepted, never gated, never
|
|
15
15
|
* undone by the curator.
|
|
16
|
+
*
|
|
17
|
+
* `SKILL_MANAGE_READ_ACTIONS` (exported for the A8 wrap and the tool
|
|
18
|
+
* itself — ONE literal, the A7 dedupe): the `skill_manage` actions that
|
|
19
|
+
* are safe anywhere (`list`/`view` pass through outside a curator run).
|
|
16
20
|
*/
|
|
21
|
+
/** `skill_manage`'s read actions — safe anywhere, never intercepted. */
|
|
22
|
+
export const SKILL_MANAGE_READ_ACTIONS = new Set([
|
|
23
|
+
"list",
|
|
24
|
+
"view",
|
|
25
|
+
]);
|
|
17
26
|
/**
|
|
18
27
|
* The registered tool names that are curation tools by core ownership.
|
|
19
28
|
* Exported for the A8 wrap feed's seed (the feed starts with the core
|
|
@@ -63,6 +72,7 @@ export class CurationRegistry {
|
|
|
63
72
|
export function buildCurationRegistry(bindings) {
|
|
64
73
|
const targets = [];
|
|
65
74
|
const seen = new Set();
|
|
75
|
+
const toolOwner = new Map();
|
|
66
76
|
for (const binding of bindings) {
|
|
67
77
|
for (const target of binding.targets) {
|
|
68
78
|
const id = `${binding.extension}:${target.name}`;
|
|
@@ -70,6 +80,17 @@ export function buildCurationRegistry(bindings) {
|
|
|
70
80
|
throw new Error(`duplicate curation target id "${id}" — the loader already rejected this; a second arrival is a boot bug`);
|
|
71
81
|
}
|
|
72
82
|
seen.add(id);
|
|
83
|
+
// A tool names ONE target (A8): two targets listing the same
|
|
84
|
+
// tool make `byTool` first-wins arbitrary and an undoReview
|
|
85
|
+
// silently un-actionable for the shadowed target. Fail loud
|
|
86
|
+
// at boot, the same discipline as the duplicate-id check.
|
|
87
|
+
for (const tool of target.tools) {
|
|
88
|
+
const owner = toolOwner.get(tool);
|
|
89
|
+
if (owner !== undefined) {
|
|
90
|
+
throw new Error(`curation tool "${tool}" is listed by both ${owner} and ${id} — a tool names one curation target`);
|
|
91
|
+
}
|
|
92
|
+
toolOwner.set(tool, id);
|
|
93
|
+
}
|
|
73
94
|
targets.push({ ...target, id, extension: binding.extension });
|
|
74
95
|
}
|
|
75
96
|
}
|
package/dist/curation/wrap.js
CHANGED
|
@@ -41,7 +41,7 @@ import { copyJson } from "@earendil-works/chord";
|
|
|
41
41
|
import { defineDoc, LiveDoc } from "@earendil-works/pi-durable";
|
|
42
42
|
import { authorOfSubmission } from "./attribution.js";
|
|
43
43
|
import { curationRun } from "./run-doc.js";
|
|
44
|
-
import { CORE_CURATION_TOOLS } from "./targets.js";
|
|
44
|
+
import { CORE_CURATION_TOOLS, SKILL_MANAGE_READ_ACTIONS } from "./targets.js";
|
|
45
45
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
46
46
|
/** The answer an owner-authored call receives (contract text). */
|
|
47
47
|
export const HINT_NOTED_TEXT = "Noted. The curator will review this after your reply.";
|
|
@@ -63,11 +63,6 @@ export const HintsDoc = defineDoc({
|
|
|
63
63
|
fork: "current",
|
|
64
64
|
initial: () => ({ hints: [] }),
|
|
65
65
|
});
|
|
66
|
-
/** `skill_manage`'s read actions — safe anywhere, never intercepted. */
|
|
67
|
-
const SKILL_MANAGE_READ_ACTIONS = new Set([
|
|
68
|
-
"list",
|
|
69
|
-
"view",
|
|
70
|
-
]);
|
|
71
66
|
/**
|
|
72
67
|
* Whether one call passes through to the original outside a curator
|
|
73
68
|
* run: only `skill_manage`'s read actions. Every other curation-tool
|
package/dist/extension.d.ts
CHANGED
|
@@ -330,12 +330,14 @@ export type CurationSkillChange = {
|
|
|
330
330
|
};
|
|
331
331
|
/**
|
|
332
332
|
* One change-feed entry (structural mirror of the store's `FeedEntry`).
|
|
333
|
-
*
|
|
333
|
+
* `id`, `before` and `after` are commit SHAs in the user's curation
|
|
334
|
+
* history repo (tabs+skills Task 9); `actor` widens to include
|
|
335
|
+
* `"outside"` for catch-up commits the store itself did not author.
|
|
334
336
|
*/
|
|
335
337
|
export type CurationFeedEntry = {
|
|
336
338
|
readonly id: string;
|
|
337
339
|
readonly at: string;
|
|
338
|
-
readonly actor: "curator" | "agent" | "user";
|
|
340
|
+
readonly actor: "curator" | "agent" | "user" | "outside";
|
|
339
341
|
readonly action: string;
|
|
340
342
|
readonly skill: string;
|
|
341
343
|
readonly reason?: string;
|
|
@@ -655,7 +657,14 @@ interface NotificationEventBase {
|
|
|
655
657
|
readonly user: string | undefined;
|
|
656
658
|
readonly title: string;
|
|
657
659
|
readonly body: string;
|
|
658
|
-
/**
|
|
660
|
+
/**
|
|
661
|
+
* A deep link into the webui, only ever set when `server.publicUrl` is
|
|
662
|
+
* configured: `${server.publicUrl}/#/c/<conversationId>` for
|
|
663
|
+
* conversation-carrying events, or the
|
|
664
|
+
* `${server.publicUrl}/#/ext/<slug>/<view>` link for an extension
|
|
665
|
+
* notice whose target is a declared view (with `?params=` when the
|
|
666
|
+
* target carries params). Absent otherwise.
|
|
667
|
+
*/
|
|
659
668
|
readonly url?: string;
|
|
660
669
|
}
|
|
661
670
|
/** One notification core delivers to every registered notifier (best-effort). */
|
package/dist/harness/boot.js
CHANGED
|
@@ -71,6 +71,7 @@ import { packagesDirOf, selfPackageRoot } from "./packages.js";
|
|
|
71
71
|
import { resolveHelper } from "../sandbox/helper.js";
|
|
72
72
|
import { launchInJail } from "../sandbox/launch.js";
|
|
73
73
|
import { buildUserPolicy, defaultModuleTrees } from "../sandbox/policy.js";
|
|
74
|
+
import { validateGrantPath } from "../sandbox/grants.js";
|
|
74
75
|
import { lookupOsAccount } from "../sandbox/os-account.js";
|
|
75
76
|
import { agentEnv } from "./agent-env.js";
|
|
76
77
|
import { UserServices } from "./user-services.js";
|
|
@@ -81,10 +82,13 @@ import { createAdmissionGuard } from "./identity/users.js";
|
|
|
81
82
|
import { sweepAccountLinks } from "./identity/link-sweep.js";
|
|
82
83
|
import { effectiveOwner as resolveEffectiveOwner } from "./identity/primary-admin.js";
|
|
83
84
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
84
|
-
import { learnedSkillsDir } from "../curation/paths.js";
|
|
85
|
+
import { learnedSkillsDir, userRepoSpec } from "../curation/paths.js";
|
|
85
86
|
import { SkillStore } from "../curation/store.js";
|
|
86
87
|
import { OverlayStore } from "../curation/overlays.js";
|
|
87
88
|
import { UsageCounters } from "../curation/usage.js";
|
|
89
|
+
import { GitHistory } from "../history/git.js";
|
|
90
|
+
import { workspaceHistoryFor, workspaceRepoSpec, } from "../history/workspace-history.js";
|
|
91
|
+
import { migrateUserCuration } from "../curation/migrate.js";
|
|
88
92
|
import { buildCurationRegistry } from "../curation/targets.js";
|
|
89
93
|
import { createCurationHost } from "../curation/host.js";
|
|
90
94
|
import { createCurationToolFeed, wrapExtensionCurationTools, } from "../curation/wrap.js";
|
|
@@ -286,6 +290,35 @@ export async function openHarness(config, options = {}) {
|
|
|
286
290
|
coreRoot,
|
|
287
291
|
userAreas: (user) => join(roots.dataDir, "users", user),
|
|
288
292
|
};
|
|
293
|
+
// Curation seal revalidation (tabs/skills Task 8): stored workspace or
|
|
294
|
+
// grant paths that fail TODAY's validator (e.g. a pre-seal record whose
|
|
295
|
+
// workspace is the bare user area, which contains curation/) are
|
|
296
|
+
// refused per use by buildUserPolicy, silently stripping the agent's
|
|
297
|
+
// tools. Boot names every such record loudly with its repair (Task 14
|
|
298
|
+
// documents PATCH /v1/users/<u> {"workspace": "<userArea>/workspace"}).
|
|
299
|
+
for (const record of identity.users.list()) {
|
|
300
|
+
if (record.workspace !== undefined) {
|
|
301
|
+
const result = validateGrantPath(record.workspace, {
|
|
302
|
+
managed: managedPaths,
|
|
303
|
+
user: record.user,
|
|
304
|
+
});
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
console.warn(`identity: user "${record.user}" workspace ${record.workspace}: ${result.reason} (agent tools unavailable until repaired; re-point to ${managedPaths.userAreas(record.user)}/workspace)`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const grant of record.grants ?? []) {
|
|
310
|
+
const result = validateGrantPath(grant.path, {
|
|
311
|
+
managed: managedPaths,
|
|
312
|
+
user: record.user,
|
|
313
|
+
...(grant.inheritedFrom === undefined
|
|
314
|
+
? {}
|
|
315
|
+
: { inheritedFrom: grant.inheritedFrom }),
|
|
316
|
+
});
|
|
317
|
+
if (!result.ok) {
|
|
318
|
+
console.warn(`identity: user "${record.user}" grant ${grant.path}: ${result.reason} (grant refused until repaired)`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
289
322
|
const userServices = new UserServices({
|
|
290
323
|
launch: (policy, command, args, serviceEnv) => {
|
|
291
324
|
if (helper === undefined)
|
|
@@ -410,6 +443,13 @@ export async function openHarness(config, options = {}) {
|
|
|
410
443
|
const skillStores = new Map();
|
|
411
444
|
const overlayStores = new Map();
|
|
412
445
|
const usageCounters = new Map();
|
|
446
|
+
const curationHistories = new Map();
|
|
447
|
+
// Per-user migration promise (review I3): store/overlay mutations
|
|
448
|
+
// await it via their `ready` seam, so a write during a pending
|
|
449
|
+
// migration lands as its OWN commit after the import — never
|
|
450
|
+
// swallowed by the migration's discard.
|
|
451
|
+
const migrations = new Map();
|
|
452
|
+
const gitHomeDir = join(roots.stateDir, "git-home");
|
|
413
453
|
const curationOf = (user) => {
|
|
414
454
|
if (user === undefined)
|
|
415
455
|
return undefined;
|
|
@@ -423,6 +463,34 @@ export async function openHarness(config, options = {}) {
|
|
|
423
463
|
return undefined;
|
|
424
464
|
}
|
|
425
465
|
const userArea = managedPaths.userAreas(user);
|
|
466
|
+
let history = curationHistories.get(user);
|
|
467
|
+
if (history === undefined) {
|
|
468
|
+
history = new GitHistory(userRepoSpec(userArea), {
|
|
469
|
+
gitHome: gitHomeDir,
|
|
470
|
+
now: () => clockNow(),
|
|
471
|
+
log: (line) => console.warn(`curation history (${user}): ${line}`),
|
|
472
|
+
});
|
|
473
|
+
curationHistories.set(user, history);
|
|
474
|
+
}
|
|
475
|
+
// Migration (tabs+skills Task 9): the boot sweep below covers the
|
|
476
|
+
// directory users boot saw; a user created AFTER boot migrates on
|
|
477
|
+
// this first construction. Never-in-a-render: curationOf is the
|
|
478
|
+
// store factory, not a render path.
|
|
479
|
+
if (!migrations.has(user)) {
|
|
480
|
+
// Once per process: the settled promise STAYS in the map. The
|
|
481
|
+
// "nothing" branch writes the marker itself (review C1), so the
|
|
482
|
+
// on-disk state guards restarts; the in-process map guards
|
|
483
|
+
// within one boot. Only a FAILURE re-arms (the next curationOf
|
|
484
|
+
// retries).
|
|
485
|
+
const migration = migrateUserCuration(userArea, history).then(() => undefined, (error) => {
|
|
486
|
+
console.warn(`curation migration (${user}) failed: ${String(error)}`);
|
|
487
|
+
migrations.delete(user);
|
|
488
|
+
});
|
|
489
|
+
migrations.set(user, migration);
|
|
490
|
+
}
|
|
491
|
+
const ready = async () => {
|
|
492
|
+
await migrations.get(user);
|
|
493
|
+
};
|
|
426
494
|
let store = skillStores.get(user);
|
|
427
495
|
if (store === undefined) {
|
|
428
496
|
store = new SkillStore({
|
|
@@ -441,6 +509,8 @@ export async function openHarness(config, options = {}) {
|
|
|
441
509
|
return undefined;
|
|
442
510
|
},
|
|
443
511
|
userExists: () => identity.users.get(user) !== undefined,
|
|
512
|
+
history,
|
|
513
|
+
ready,
|
|
444
514
|
});
|
|
445
515
|
skillStores.set(user, store);
|
|
446
516
|
}
|
|
@@ -450,7 +520,8 @@ export async function openHarness(config, options = {}) {
|
|
|
450
520
|
userArea,
|
|
451
521
|
user,
|
|
452
522
|
userExists: () => identity.users.get(user) !== undefined,
|
|
453
|
-
|
|
523
|
+
history,
|
|
524
|
+
ready,
|
|
454
525
|
});
|
|
455
526
|
overlayStores.set(user, overlays);
|
|
456
527
|
}
|
|
@@ -468,8 +539,55 @@ export async function openHarness(config, options = {}) {
|
|
|
468
539
|
});
|
|
469
540
|
usageCounters.set(user, usage);
|
|
470
541
|
}
|
|
471
|
-
return { store, overlays, usage };
|
|
542
|
+
return { store, overlays, usage, history };
|
|
543
|
+
};
|
|
544
|
+
// Workspace skills history (tabs+skills Task 10): the SECOND repo per
|
|
545
|
+
// user — `<workspace>/skills` under git, caught up on settle. Cached
|
|
546
|
+
// per (user, workspace path): a changed path is a new instance on the
|
|
547
|
+
// same gitDir, so one log follows the user across workspace moves.
|
|
548
|
+
const workspaceHistories = new Map();
|
|
549
|
+
const workspaceHistoryOf = (user) => {
|
|
550
|
+
const workspace = workspaceService?.workspaceRoot(user);
|
|
551
|
+
return workspaceHistoryFor(user, workspace, {
|
|
552
|
+
cache: workspaceHistories,
|
|
553
|
+
create: (resolved) => {
|
|
554
|
+
const history = new GitHistory(workspaceRepoSpec(managedPaths.userAreas(user), resolved), {
|
|
555
|
+
gitHome: gitHomeDir,
|
|
556
|
+
now: () => clockNow(),
|
|
557
|
+
log: (line) => console.warn(`workspace history (${user}): ${line}`),
|
|
558
|
+
});
|
|
559
|
+
// Task 12 race fix: settle the repo AT CONSTRUCTION, not on
|
|
560
|
+
// first use. Two concurrent first-uses (the view's catch-up +
|
|
561
|
+
// a write) each run prepare()'s init before either's commit:
|
|
562
|
+
// the later init lands between the earlier init and ITS
|
|
563
|
+
// commit — status reads an empty index, and the write lands
|
|
564
|
+
// with no commit. An eager empty catchUp (no-op on an
|
|
565
|
+
// existing repo, the import commit on a fresh one) makes
|
|
566
|
+
// every later use see a settled repo. Awaited BEFORE the
|
|
567
|
+
// instance returns so no first use can interleave with the
|
|
568
|
+
// init. Fail-soft like everything here: errors warn, never
|
|
569
|
+
// throw.
|
|
570
|
+
void (async () => {
|
|
571
|
+
try {
|
|
572
|
+
// settle(), never a catch-up: an init that COMMITTED
|
|
573
|
+
// would absorb a racing first edit under the init
|
|
574
|
+
// summary (the 2026-11-03 core/skills failure).
|
|
575
|
+
await history.settle();
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
console.warn(`workspace history (${user}) init failed: ${String(error)}`);
|
|
579
|
+
}
|
|
580
|
+
})();
|
|
581
|
+
return history;
|
|
582
|
+
},
|
|
583
|
+
});
|
|
472
584
|
};
|
|
585
|
+
// Curation history migration (tabs+skills Task 9): every directory
|
|
586
|
+
// user boot sees imports once. Users created after boot migrate on
|
|
587
|
+
// their first curationOf (the migratedUsers seam above).
|
|
588
|
+
for (const record of identity.users.list()) {
|
|
589
|
+
curationOf(record.user);
|
|
590
|
+
}
|
|
473
591
|
const curationSkillsOptions = {
|
|
474
592
|
...skillsOptions,
|
|
475
593
|
skillStore: (user) => curationOf(user)?.store,
|
|
@@ -665,6 +783,15 @@ export async function openHarness(config, options = {}) {
|
|
|
665
783
|
},
|
|
666
784
|
});
|
|
667
785
|
perUserSlugs.push(...loadedPerUserSlugs);
|
|
786
|
+
// Fail-fast vs wait (polish A1, spec §5.2): the caller declared a
|
|
787
|
+
// server WILL bind the host bridge — mark every loaded seam host so
|
|
788
|
+
// pre-bind calls keep the bounded wait instead of rejecting at once.
|
|
789
|
+
// `createServer` repeats this on entry (idempotent) for rigs that
|
|
790
|
+
// never passed the option.
|
|
791
|
+
if (options.expectHostBridge === true) {
|
|
792
|
+
for (const host of seamHosts.values())
|
|
793
|
+
host.expectBridge();
|
|
794
|
+
}
|
|
668
795
|
// The curation-target registry (curator P7): the loader's validated,
|
|
669
796
|
// slug-attributed bindings folded into the ONE registry — the A8
|
|
670
797
|
// wrapping feed (isCurationTool) and the A9 host.curation surface
|
|
@@ -935,6 +1062,7 @@ export async function openHarness(config, options = {}) {
|
|
|
935
1062
|
pool: sandboxPool,
|
|
936
1063
|
directory: identity.users,
|
|
937
1064
|
managed: managedPaths,
|
|
1065
|
+
defaultWorkspacePath: config.workspace.path,
|
|
938
1066
|
ownerOf: async (conversationId, read, ctx) => (await read.snapshot(SessionMetaDoc, conversationId, ctx))?.owner,
|
|
939
1067
|
});
|
|
940
1068
|
const workspacePrefs = defaultWorkspacePrefs(config.workspace.path);
|
|
@@ -1261,15 +1389,17 @@ export async function openHarness(config, options = {}) {
|
|
|
1261
1389
|
const [newest] = await listRevisions(storage, jobId, BACKGROUND_CONTEXT);
|
|
1262
1390
|
if (newest === undefined)
|
|
1263
1391
|
return;
|
|
1392
|
+
// Resolve once: the primary admin can move between the two reads
|
|
1393
|
+
// (a live directory), so a single snapshot keeps the row
|
|
1394
|
+
// self-consistent.
|
|
1395
|
+
const effectiveOwner = jobEffectiveOwner(newest.owner);
|
|
1264
1396
|
events.publish({
|
|
1265
1397
|
type: "job.changed",
|
|
1266
1398
|
job: {
|
|
1267
1399
|
id: jobId,
|
|
1268
1400
|
name: newest.spec.name,
|
|
1269
1401
|
source: newest.source,
|
|
1270
|
-
...(
|
|
1271
|
-
? {}
|
|
1272
|
-
: { owner: jobEffectiveOwner(newest.owner) }),
|
|
1402
|
+
...(effectiveOwner === undefined ? {} : { owner: effectiveOwner }),
|
|
1273
1403
|
...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
|
|
1274
1404
|
schedule: newest.spec.schedule,
|
|
1275
1405
|
action: newest.spec.action,
|
|
@@ -1389,6 +1519,10 @@ export async function openHarness(config, options = {}) {
|
|
|
1389
1519
|
for (const host of seamHosts.values())
|
|
1390
1520
|
host.bindBridge(bridge);
|
|
1391
1521
|
},
|
|
1522
|
+
expectHostBridge: () => {
|
|
1523
|
+
for (const host of seamHosts.values())
|
|
1524
|
+
host.expectBridge();
|
|
1525
|
+
},
|
|
1392
1526
|
extensionRoutes: routeBindings,
|
|
1393
1527
|
extensionCommands: commandBindings,
|
|
1394
1528
|
extensionSurfaces: surfaceBindings,
|
|
@@ -1399,6 +1533,9 @@ export async function openHarness(config, options = {}) {
|
|
|
1399
1533
|
skillRoots: extensionSkillRoots,
|
|
1400
1534
|
curation,
|
|
1401
1535
|
curationHost,
|
|
1536
|
+
curationOf,
|
|
1537
|
+
workspaceRootOf: (owner) => workspaceService?.workspaceRoot(owner),
|
|
1538
|
+
history: { workspace: workspaceHistoryOf },
|
|
1402
1539
|
commandExpansion,
|
|
1403
1540
|
eventBus,
|
|
1404
1541
|
events,
|
|
@@ -66,6 +66,7 @@ import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions
|
|
|
66
66
|
import { AmbientDoc } from "./ambient.js";
|
|
67
67
|
import { toDurableContext } from "./context.js";
|
|
68
68
|
import { beginUserDeletion } from "./stores.js";
|
|
69
|
+
import { HostBridgeUnavailableError } from "./seam-host.js";
|
|
69
70
|
/** Project a SessionMeta doc value into the tracked basis. */
|
|
70
71
|
function metaBasisOf(value) {
|
|
71
72
|
const basis = {
|
|
@@ -121,6 +122,13 @@ export class EventBus {
|
|
|
121
122
|
}
|
|
122
123
|
#queueMax;
|
|
123
124
|
#hookDeadlineMs;
|
|
125
|
+
/**
|
|
126
|
+
* The `${label}:${member}` pairs whose HostBridgeUnavailableError
|
|
127
|
+
* already logged (polish A1, spec §5.2): a serverless process fails
|
|
128
|
+
* fast on EVERY bridged handler call, so only the first per pair
|
|
129
|
+
* warns — repeats are swallowed (still resolving the handler slot).
|
|
130
|
+
*/
|
|
131
|
+
#unavailableLogged = new Set();
|
|
124
132
|
#idleThresholdMs;
|
|
125
133
|
#queue = [];
|
|
126
134
|
#settleHandlers = [];
|
|
@@ -1390,6 +1398,17 @@ export class EventBus {
|
|
|
1390
1398
|
// A handler may throw synchronously; that is the same failure as a
|
|
1391
1399
|
// rejected promise and must not escape into the drain loop.
|
|
1392
1400
|
const failed = (error) => {
|
|
1401
|
+
if (error instanceof HostBridgeUnavailableError) {
|
|
1402
|
+
const key = `${label}:${error.member}`;
|
|
1403
|
+
if (this.#unavailableLogged.has(key)) {
|
|
1404
|
+
finish();
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
this.#unavailableLogged.add(key);
|
|
1408
|
+
console.warn("event-bus: %s handler failed: %s (further repeats for this member suppressed)", label, error.message);
|
|
1409
|
+
finish();
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1393
1412
|
console.error("event-bus: %s handler failed:", label, error);
|
|
1394
1413
|
finish();
|
|
1395
1414
|
};
|
package/dist/harness/loader.js
CHANGED
|
@@ -1536,6 +1536,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1536
1536
|
// target, so a duplicate quarantines the later declarer instead of
|
|
1537
1537
|
// shadowing (never last-wins).
|
|
1538
1538
|
const curationIds = new Set();
|
|
1539
|
+
/** The qualified target id owning each claimed curation tool (A8's one-target-per-tool rule). */
|
|
1540
|
+
const curationToolOwners = new Map();
|
|
1539
1541
|
// Channel id → slug (gateway C1): the same claimed-only-by-LOADED pattern
|
|
1540
1542
|
// as `slugs`. Chat bindings are keyed by channel id, so a shadowing
|
|
1541
1543
|
// adapter would inherit another adapter's chats — the collision
|
|
@@ -1770,6 +1772,10 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1770
1772
|
// (the same name twice in one result) hits the check on
|
|
1771
1773
|
// its SECOND arrival, quarantining before any install.
|
|
1772
1774
|
const claimed = new Set();
|
|
1775
|
+
// This result's tool claims (the A8 one-target rule's
|
|
1776
|
+
// intra-list half — merged into the cross-extension map
|
|
1777
|
+
// only when the whole result survives).
|
|
1778
|
+
const claimedTools = new Map();
|
|
1773
1779
|
for (const target of resolved.curationTargets) {
|
|
1774
1780
|
const id = `${slug}:${target.name}`;
|
|
1775
1781
|
if (curationIds.has(id) || claimed.has(id)) {
|
|
@@ -1780,6 +1786,19 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1780
1786
|
if (unregistered !== undefined) {
|
|
1781
1787
|
throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
|
|
1782
1788
|
}
|
|
1789
|
+
// A tool names ONE target (A8): two targets — across any
|
|
1790
|
+
// extensions — listing the same tool leave `byTool`
|
|
1791
|
+
// first-wins arbitrary and the shadowed target's undos
|
|
1792
|
+
// un-actionable. Quarantine the later declarer. Intra-list
|
|
1793
|
+
// claims are LOCAL (`claimedTools`) until the whole result
|
|
1794
|
+
// validates — a quarantined extension never leaks a claim.
|
|
1795
|
+
for (const tool of target.tools) {
|
|
1796
|
+
const owner = curationToolOwners.get(tool) ?? claimedTools.get(tool);
|
|
1797
|
+
if (owner !== undefined) {
|
|
1798
|
+
throw new Error(`curation tool "${tool}" is listed by both ${owner} and ${id} — a tool names one curation target`);
|
|
1799
|
+
}
|
|
1800
|
+
claimedTools.set(tool, id);
|
|
1801
|
+
}
|
|
1783
1802
|
}
|
|
1784
1803
|
}
|
|
1785
1804
|
if (resolved.gateway !== undefined) {
|
|
@@ -1883,6 +1902,9 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1883
1902
|
});
|
|
1884
1903
|
for (const target of resolved.curationTargets) {
|
|
1885
1904
|
curationIds.add(`${slug}:${target.name}`);
|
|
1905
|
+
for (const tool of target.tools) {
|
|
1906
|
+
curationToolOwners.set(tool, `${slug}:${target.name}`);
|
|
1907
|
+
}
|
|
1886
1908
|
}
|
|
1887
1909
|
}
|
|
1888
1910
|
if (resolved.jobs !== undefined) {
|
package/dist/harness/packages.js
CHANGED
|
@@ -85,6 +85,13 @@ export const HOST_RUNTIME_MODULES = [
|
|
|
85
85
|
"@earendil-works/pi-durable",
|
|
86
86
|
"@earendil-works/chord",
|
|
87
87
|
"typebox",
|
|
88
|
+
// The shared workspace is host-shipped too (wire + access contracts
|
|
89
|
+
// every extension family type against; @sigrun/recall is the first
|
|
90
|
+
// packages-dir extension importing it at RUNTIME — value imports like
|
|
91
|
+
// accessOf, not just types). Linked like the Earendil host modules so
|
|
92
|
+
// a packages-dir extension's bare `@sigrun/shared/*` import resolves
|
|
93
|
+
// against the copy the host runs (one shared, never a second copy).
|
|
94
|
+
"@sigrun/shared",
|
|
88
95
|
SELF_MODULE_NAME,
|
|
89
96
|
];
|
|
90
97
|
/**
|
|
@@ -29,6 +29,24 @@ export class SeamError extends Error {
|
|
|
29
29
|
this.name = "SeamError";
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A bridge-backed `host.*` member was called in a process that will never
|
|
34
|
+
* bind the host bridge (no `createServer`, no `expectHostBridge: true`).
|
|
35
|
+
* Fails fast instead of waiting out the bind deadline: the member and the
|
|
36
|
+
* calling extension are named so the embedder can either start a server
|
|
37
|
+
* or stop calling bridged members.
|
|
38
|
+
*/
|
|
39
|
+
export class HostBridgeUnavailableError extends Error {
|
|
40
|
+
member;
|
|
41
|
+
caller;
|
|
42
|
+
constructor(member, caller) {
|
|
43
|
+
super(`seam host: ${member} — no server will bind the host bridge (caller: ${caller}); ` +
|
|
44
|
+
`start createServer or pass expectHostBridge: true to wait for a bind`);
|
|
45
|
+
this.name = "HostBridgeUnavailableError";
|
|
46
|
+
this.member = member;
|
|
47
|
+
this.caller = caller;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
32
50
|
/**
|
|
33
51
|
* Build the seam host for one extension. `kinds` gates writes: a record
|
|
34
52
|
* whose `kind`/`key` the extension never declared is rejected — the
|
|
@@ -59,7 +77,7 @@ function syncStoreForwarder(pending, member, user) {
|
|
|
59
77
|
},
|
|
60
78
|
});
|
|
61
79
|
}
|
|
62
|
-
export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services) {
|
|
80
|
+
export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services, options) {
|
|
63
81
|
const interactionKinds = new Set(kinds.interaction ?? []);
|
|
64
82
|
const ambientKinds = new Set(kinds.ambient ?? []);
|
|
65
83
|
// The scoped stores view, keyed on the CURRENT identity — the same
|
|
@@ -101,31 +119,57 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
101
119
|
const bridgePromise = new Promise((resolve) => {
|
|
102
120
|
bridgeReleased = resolve;
|
|
103
121
|
});
|
|
122
|
+
// Fail-fast vs wait (polish A1, spec §5.2): `expectBridge()` declares a
|
|
123
|
+
// server WILL bind (fanned out from `OpenedHarness.expectHostBridge`,
|
|
124
|
+
// which `createServer` calls synchronously on entry). Until then a
|
|
125
|
+
// bridge-less process is treated as a serverless embed — calls reject
|
|
126
|
+
// at once instead of stalling the full hook deadline.
|
|
127
|
+
let bridgeBound = false;
|
|
128
|
+
let bridgeExpected = false;
|
|
104
129
|
// The pre-bind bound: a bridge call that waits PAST this long fails
|
|
105
130
|
// loud instead of hanging forever (the jobs-task fake-clock hang's
|
|
106
|
-
// lesson).
|
|
107
|
-
// at boot, long inside the bound
|
|
108
|
-
//
|
|
109
|
-
const bridgeDeadlineMs = 60_000;
|
|
131
|
+
// lesson). Only reachable when a server is expected — `createServer`
|
|
132
|
+
// binds the bridge at boot, long inside the bound. Injectable so a
|
|
133
|
+
// test can exercise the deadline without a 60-second wait.
|
|
134
|
+
const bridgeDeadlineMs = options?.bridgeDeadlineMs ?? 60_000;
|
|
110
135
|
const hostBridge = async (member) => {
|
|
111
136
|
await bind.harness();
|
|
112
|
-
|
|
113
|
-
bridgePromise
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
if (bridgeBound)
|
|
138
|
+
return await bridgePromise;
|
|
139
|
+
if (!bridgeExpected) {
|
|
140
|
+
throw new HostBridgeUnavailableError(member, identity.name);
|
|
141
|
+
}
|
|
142
|
+
// The deadline lane's timer is cleared the moment the bridge
|
|
143
|
+
// resolves — a successful bind never leaves a live 60-second
|
|
144
|
+
// rejection timer holding the process's event loop open.
|
|
145
|
+
let timer;
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([
|
|
148
|
+
bridgePromise,
|
|
149
|
+
new Promise((_, reject) => {
|
|
150
|
+
timer = setTimeout(() => {
|
|
151
|
+
reject(new Error(`seam host: ${member} — the bridge never bound after ${bridgeDeadlineMs}ms; is createServer running? (caller: ${identity.name})`));
|
|
152
|
+
}, bridgeDeadlineMs);
|
|
153
|
+
// The rejection timer never keeps the process alive.
|
|
154
|
+
if (typeof timer === "object" && "unref" in timer)
|
|
155
|
+
timer.unref();
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (timer !== undefined)
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
123
163
|
};
|
|
124
164
|
// The curation surface's settled bridge host (curator P6/A9): memoized
|
|
125
165
|
// HERE — in the host closure — so every `host.curation` getter call (a
|
|
126
166
|
// FRESH proxy per read) shares one settled reference. See the getter's
|
|
127
167
|
// own comment for the sync-member discipline this serves.
|
|
128
168
|
let curationHost;
|
|
169
|
+
// The surfaces fire-and-forget lane logs an unavailable bridge ONCE per
|
|
170
|
+
// member (polish A1): the same fail-fast error every call, so repeats
|
|
171
|
+
// are noise — other failures keep their per-call console.error.
|
|
172
|
+
let surfacesUnavailableLogged = false;
|
|
129
173
|
return {
|
|
130
174
|
get stores() {
|
|
131
175
|
return { ...stores, user: userStores };
|
|
@@ -519,12 +563,23 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
519
563
|
bridge.surfaces.changed(identity.name, id, key, update);
|
|
520
564
|
})
|
|
521
565
|
.catch((error) => {
|
|
566
|
+
if (error instanceof HostBridgeUnavailableError) {
|
|
567
|
+
if (surfacesUnavailableLogged)
|
|
568
|
+
return;
|
|
569
|
+
surfacesUnavailableLogged = true;
|
|
570
|
+
console.warn(`seam host: surfaces.changed (${identity.name}) — ${error.message} (further repeats suppressed)`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
522
573
|
console.error(`seam host: surfaces.changed (${identity.name}/${id}) failed:`, error);
|
|
523
574
|
});
|
|
524
575
|
},
|
|
525
576
|
},
|
|
526
577
|
bindBridge(bridge) {
|
|
578
|
+
bridgeBound = true;
|
|
527
579
|
bridgeReleased(bridge);
|
|
528
580
|
},
|
|
581
|
+
expectBridge() {
|
|
582
|
+
bridgeExpected = true;
|
|
583
|
+
},
|
|
529
584
|
};
|
|
530
585
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Resolve each conversation's ExecutionEnv: single-user, jailed, or fail-closed. */
|
|
2
2
|
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
4
|
-
import { buildUserPolicy } from "../sandbox/policy.js";
|
|
4
|
+
import { buildUserPolicy, validateUserPath } from "../sandbox/policy.js";
|
|
5
5
|
import { SandboxedExecutionEnv } from "../sandbox/sandboxed-env.js";
|
|
6
6
|
import { UnavailableExecutionEnv } from "../sandbox/unavailable-env.js";
|
|
7
7
|
import { selfPackageRoot } from "./packages.js";
|
|
@@ -38,6 +38,39 @@ export function createWorkspaceService(deps) {
|
|
|
38
38
|
}
|
|
39
39
|
return join(managed.userAreas(record.user), "jobs");
|
|
40
40
|
},
|
|
41
|
+
workspaceRoot(owner) {
|
|
42
|
+
if (deps === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
const { directory, managed, isolation } = deps;
|
|
45
|
+
const records = directory.list();
|
|
46
|
+
const record = selectRecord(records, owner);
|
|
47
|
+
const active = isolation.active();
|
|
48
|
+
let osAccount;
|
|
49
|
+
if (record?.osUser !== undefined) {
|
|
50
|
+
if (deps.osUsers?.available !== true)
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
osAccount = (deps.lookupAccount ?? lookupOsAccount)(record.osUser);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (osAccount === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
// Rules 1+2: without active isolation a workspace root exists
|
|
62
|
+
// only in the single-record posture — forConversation resolves
|
|
63
|
+
// the same default there (and is unavailable with more users).
|
|
64
|
+
if (osAccount === undefined && !active) {
|
|
65
|
+
return records.length <= 1 ? deps.defaultWorkspacePath : undefined;
|
|
66
|
+
}
|
|
67
|
+
if (record === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
const candidate = record.workspace ??
|
|
70
|
+
osAccount?.home ??
|
|
71
|
+
join(managed.userAreas(record.user), "workspace");
|
|
72
|
+
return validateUserPath(candidate, record, managed).ok ? candidate : undefined;
|
|
73
|
+
},
|
|
41
74
|
async forConversation(conversation, prefs, read, ctx) {
|
|
42
75
|
const workspace = prefs.defaultWorkspace;
|
|
43
76
|
if (workspace.type !== "local") {
|