@awebai/oats 0.30.0 → 0.30.2
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/bin/oats.mjs +1 -1
- package/docs/capabilities.md +3 -3
- package/docs/design/2026-09-23-workspace-module-contracts.md +2 -1
- package/docs/desktop-cli-api.md +23 -11
- package/docs/first-team.md +1 -1
- package/docs/implementation.md +2 -1
- package/docs/integrations.md +1 -1
- package/docs/knowledge-capability-authoring.md +1 -1
- package/docs/knowledge.md +4 -4
- package/docs/official-catalog.md +4 -4
- package/docs/packages.md +13 -13
- package/docs/plans/0.30-close-out.md +24 -2
- package/docs/release-lane.md +7 -2
- package/docs/release-notes/v0.30.1.md +123 -0
- package/docs/release-notes/v0.30.2.md +85 -0
- package/docs/souls-and-instances.md +6 -5
- package/docs/workspaces.md +7 -2
- package/lib/core.mjs +42 -28
- package/lib/instance-inspect.mjs +1 -1
- package/lib/instance-resolution.mjs +15 -8
- package/lib/materialize.mjs +33 -19
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +1 -1
- package/package-catalog.json +3 -3
- package/package.json +1 -3
- package/skills/oats-getting-started/SKILL.md +2 -2
- package/capabilities/oats-authoring/LICENSE +0 -21
- package/capabilities/oats-authoring/oats-package.json +0 -11
- package/capabilities/oats-authoring/oats.json +0 -12
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +0 -84
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +0 -109
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +0 -116
- package/capabilities/oats-aweb/bin/oats-aweb-binding.mjs +0 -11
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +0 -1672
- package/capabilities/oats-aweb/injects/aweb.md +0 -47
- package/capabilities/oats-aweb/lib/binding-wire.mjs +0 -365
- package/capabilities/oats-aweb/lib/captured-execution.mjs +0 -91
- package/capabilities/oats-aweb/lib/captured-native.mjs +0 -91
- package/capabilities/oats-aweb/lib/grant-custody.mjs +0 -38
- package/capabilities/oats-aweb/lib/invocation-shape.mjs +0 -135
- package/capabilities/oats-aweb/lib/portable-binding.mjs +0 -146
- package/capabilities/oats-aweb/lib/session-readiness.mjs +0 -56
- package/capabilities/oats-aweb/lib/wake-receive.mjs +0 -56
- package/capabilities/oats-aweb/oats.json +0 -201
- package/capabilities/oats-aweb/skills/LICENSE +0 -21
- package/capabilities/oats-aweb/skills/VENDORED.md +0 -31
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +0 -201
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +0 -161
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +0 -61
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +0 -116
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +0 -74
- package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +0 -286
- package/capabilities/oats-code-review/injects/reviewer.md +0 -26
- package/capabilities/oats-code-review/oats.json +0 -16
- package/capabilities/oats-code-review/skills/adversarial-review/SKILL.md +0 -66
- package/capabilities/oats-code-review/skills/review-dev-docs/SKILL.md +0 -30
- package/capabilities/oats-code-review/skills/security-review/SKILL.md +0 -56
- package/capabilities/oats-code-review/skills/simplification-review/SKILL.md +0 -34
- package/capabilities/oats-developer/injects/developer.md +0 -38
- package/capabilities/oats-developer/oats.json +0 -17
- package/capabilities/oats-developer/skills/execution-strategy/SKILL.md +0 -43
- package/capabilities/oats-developer/skills/maintain-dev-docs/SKILL.md +0 -47
- package/capabilities/oats-developer/skills/run-the-review-loop/SKILL.md +0 -65
- package/capabilities/oats-developer/skills/understand-the-spec/SKILL.md +0 -37
- package/capabilities/oats-developer/skills/worktrees/SKILL.md +0 -36
- package/capabilities/oats-engineering-expert/injects/expert.md +0 -37
- package/capabilities/oats-engineering-expert/oats.json +0 -17
- package/capabilities/oats-engineering-expert/skills/coordinate-developers/SKILL.md +0 -37
- package/capabilities/oats-engineering-expert/skills/coordinate-experts/SKILL.md +0 -52
- package/capabilities/oats-engineering-expert/skills/land-your-prs/SKILL.md +0 -50
- package/capabilities/oats-engineering-expert/skills/plan-and-spec/SKILL.md +0 -53
- package/capabilities/oats-engineering-expert/skills/verify-developer-work/SKILL.md +0 -49
- package/capabilities/oats-jira/bin/oats-jira.mjs +0 -40
- package/capabilities/oats-jira/injects/jira.md +0 -10
- package/capabilities/oats-jira/oats.json +0 -22
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +0 -179
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +0 -34
- package/capabilities/oats-linear/bin/oats-linear.mjs +0 -344
- package/capabilities/oats-linear/injects/linear.md +0 -8
- package/capabilities/oats-linear/oats.json +0 -24
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +0 -223
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +0 -14
- package/capabilities/oats-okf/bin/oats-okf.mjs +0 -213
- package/capabilities/oats-okf/injects/okf.md +0 -42
- package/capabilities/oats-okf/lib/binding-wire.mjs +0 -380
- package/capabilities/oats-okf/lib/captured-worker.mjs +0 -109
- package/capabilities/oats-okf/lib/config.mjs +0 -124
- package/capabilities/oats-okf/lib/consult.mjs +0 -518
- package/capabilities/oats-okf/lib/harvest-status.mjs +0 -88
- package/capabilities/oats-okf/lib/harvest-switch.mjs +0 -94
- package/capabilities/oats-okf/lib/inspection.mjs +0 -138
- package/capabilities/oats-okf/lib/invocation-context.mjs +0 -111
- package/capabilities/oats-okf/lib/invocation-shape.mjs +0 -135
- package/capabilities/oats-okf/lib/io.mjs +0 -118
- package/capabilities/oats-okf/lib/migration.mjs +0 -137
- package/capabilities/oats-okf/lib/okf-validate.mjs +0 -123
- package/capabilities/oats-okf/lib/portable-binding.mjs +0 -199
- package/capabilities/oats-okf/lib/source-contract.mjs +0 -46
- package/capabilities/oats-okf/lib/sources.mjs +0 -438
- package/capabilities/oats-okf/lib/stores.mjs +0 -473
- package/capabilities/oats-okf/lib/worker.mjs +0 -486
- package/capabilities/oats-okf/oats.json +0 -151
- package/capabilities/oats-okf/schemas/okf-base.schema.json +0 -46
- package/capabilities/oats-okf/schemas/okf-bindings.schema.json +0 -112
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +0 -87
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +0 -113
- package/capabilities/oats-okf/schemas/okf-soul.schema.json +0 -37
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +0 -144
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +0 -86
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +0 -104
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +0 -140
- package/capabilities/oats-okf-harvest/injects/harvester.md +0 -12
- package/capabilities/oats-okf-harvest/oats.json +0 -26
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +0 -168
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +0 -192
- package/capabilities/oats-okf-harvest/skills/okf-authoring/SKILL.md +0 -151
- package/capabilities/oats-okf-harvest/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +0 -170
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +0 -12
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +0 -50
- package/capabilities/oats-okf-maintenance/oats.json +0 -21
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +0 -159
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +0 -192
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +0 -151
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +0 -134
- package/capabilities/oats-workspace-experts/injects/oats-experts.md +0 -26
- package/capabilities/oats-workspace-experts/oats.json +0 -9
|
@@ -1,1672 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* oats-aweb — OATS messaging-provider hooks for aweb.
|
|
4
|
-
*
|
|
5
|
-
* Invoked by the OATS kernel at instance lifecycle events (hook contract):
|
|
6
|
-
* oats-aweb spawn mint a team-scoped aweb identity for the instance
|
|
7
|
-
* oats-aweb retire gracefully self-delete it (BEFORE the home dir is removed)
|
|
8
|
-
* oats-aweb roster list the aweb team's members — the cross-machine directory
|
|
9
|
-
* of live instances (alias = instance name) and humans
|
|
10
|
-
* oats-aweb setup guided onboarding: check the aw CLI, initialize the
|
|
11
|
-
* messaging root with aw init / aw team join, verify team
|
|
12
|
-
*
|
|
13
|
-
* Env contract (set by the kernel):
|
|
14
|
-
* OATS_EVENT spawn|retire
|
|
15
|
-
* OATS_INSTANCE instance name (used as the aweb alias)
|
|
16
|
-
* OATS_HOME instance home dir (cwd is also set to it)
|
|
17
|
-
* OATS_CONTEXT resolution context dir (the soul's repo / agents root parent)
|
|
18
|
-
* OATS_WORKSPACE the agents root's parent — the team boundary
|
|
19
|
-
* OATS_SETTINGS JSON of the provider's `settings:` block
|
|
20
|
-
* OATS_DEFAULT_TEAM/OATS_DEFAULT_TEAM_ID/OATS_DEFAULT_TEAM_FROM default team facts
|
|
21
|
-
* OATS_TEAMS JSON rows {label, team, default, from} for mapped eligible teams
|
|
22
|
-
* OATS_META JSON persisted from this hook's previous spawn output (retire only)
|
|
23
|
-
*
|
|
24
|
-
* Output (spawn, stdout JSON):
|
|
25
|
-
* { "meta": {...persisted to instance.json + OATS_META at retire},
|
|
26
|
-
* "brief": "one-line TASK.md briefing line", "warning": "non-fatal problem" }
|
|
27
|
-
*
|
|
28
|
-
* EXIT CODE IS THE CONTRACT for the spawn hook. This capability declares
|
|
29
|
-
* `spawn` as required (oats.json), so a nonzero exit fails the spawn and rolls it
|
|
30
|
-
* back. Messaging is the whole point of the capability: an instance whose
|
|
31
|
-
* identity was never minted believes it can be woken by mail and cannot, which
|
|
32
|
-
* is worse than not starting. So identity failures exit nonzero, while genuinely
|
|
33
|
-
* advisory problems (the Claude channel plugin, a team-name mismatch) stay
|
|
34
|
-
* warnings on exit 0.
|
|
35
|
-
*
|
|
36
|
-
* On a fatal path, ALWAYS emit any metadata gathered so far before exiting: the
|
|
37
|
-
* kernel feeds it to the retire hook to compensate partial state, and an
|
|
38
|
-
* identity joined moments before the failure must still be deletable.
|
|
39
|
-
*/
|
|
40
|
-
import { execFileSync } from "node:child_process";
|
|
41
|
-
import { appendFileSync, chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
42
|
-
import { hostname } from "node:os";
|
|
43
|
-
import { join, dirname, resolve, delimiter, isAbsolute, relative } from "node:path";
|
|
44
|
-
import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
|
|
45
|
-
import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
|
|
46
|
-
import { runCapturedNative } from "../lib/captured-native.mjs";
|
|
47
|
-
import { AW_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
|
|
48
|
-
import { custodyPreflight } from "../lib/grant-custody.mjs";
|
|
49
|
-
import { runtimeDeliveryFor, wakeRegistration } from "../lib/wake-receive.mjs";
|
|
50
|
-
|
|
51
|
-
/** Run a command as ARGV — never a shell string. Team ids, aliases, instance
|
|
52
|
-
* names and invite tokens all flow through here; quoting them correctly is a
|
|
53
|
-
* property of one helper staying correct forever, while argv removes the class.
|
|
54
|
-
* This hook is a REQUIRED spawn hook, so it gates every spawn, which is reason
|
|
55
|
-
* enough not to rely on quoting. */
|
|
56
|
-
const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [], input } = {}) => {
|
|
57
|
-
try {
|
|
58
|
-
// An inherited AWEB_IDENTITY_HOME (every aweb instance session carries its
|
|
59
|
-
// own) is never this hook's identity: aw would act as the CALLER — refusing
|
|
60
|
-
// cwd-rooted commands such as team invite/list, or deleting the caller's
|
|
61
|
-
// workspace when a lead retires a worker. Only an explicit env sets one.
|
|
62
|
-
const childEnv = { ...process.env, ...(extraEnv || {}) };
|
|
63
|
-
if (!extraEnv || !Object.hasOwn(extraEnv, "AWEB_IDENTITY_HOME")) delete childEnv.AWEB_IDENTITY_HOME;
|
|
64
|
-
for (const name of unsetEnv) delete childEnv[name];
|
|
65
|
-
return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], ...(input === undefined ? {} : { input }), timeout, env: childEnv }).trim();
|
|
66
|
-
} catch (e) {
|
|
67
|
-
// execFileSync puts the WHOLE ARGV in e.message ("Command failed: aw team
|
|
68
|
-
// join <token> …"). This hook's failures are reported by the kernel and land
|
|
69
|
-
// in CLI/Desktop logs, so a failed join disclosed a still-valid team invite
|
|
70
|
-
// token to anyone reading them (reviewer-aggregate2). Rebuild the error from
|
|
71
|
-
// the command name and the status — never the argv.
|
|
72
|
-
//
|
|
73
|
-
// secretSafe drops the child's OUTPUT as well. Scrubbing known strings is not
|
|
74
|
-
// enough for a command that MINTS a secret: `aw team invite` can print a
|
|
75
|
-
// freshly-created token to stderr while failing, and at that point the caller
|
|
76
|
-
// has no value to scrub because the token is exactly what it never received
|
|
77
|
-
// (reviewer-1a6e82e). For those commands, status plus fixed context is all
|
|
78
|
-
// the diagnosis anyone gets.
|
|
79
|
-
const scrub = (t) => secrets.filter(Boolean).reduce((acc, sec) => acc.split(sec).join("<redacted>"), String(t ?? ""));
|
|
80
|
-
const where = [argv[0], argv[1], argv[2]].filter((a) => a && !secrets.includes(a) && !a.startsWith("-")).join(" ");
|
|
81
|
-
const why = secretSafe ? "" : (scrub(e.stderr).trim() || (e.status === undefined ? String(e.code || "failed") : ""));
|
|
82
|
-
const err = new Error(`${where} failed${e.status === undefined ? "" : ` (exit ${e.status})`}${why ? `: ${why}` : ""}${secretSafe ? " (output withheld: this command handles credentials)" : ""}`);
|
|
83
|
-
err.status = e.status;
|
|
84
|
-
err.stdout = scrub(e.stdout).trim();
|
|
85
|
-
err.stderr = scrub(e.stderr).trim();
|
|
86
|
-
// A classification, never the text: the caller may name a KNOWN failure
|
|
87
|
-
// class (an alias that still holds a certificate) without any output of a
|
|
88
|
-
// credential-handling command reaching a log.
|
|
89
|
-
err.aliasConflict = /already|exists|conflict|422|active certificate/i.test(String(e.stderr ?? "") + String(e.stdout ?? ""));
|
|
90
|
-
throw err;
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
/** JSON.parse whose failure never quotes the input. Node includes an excerpt of
|
|
94
|
-
* the malformed text in a SyntaxError, which for these commands IS the credential
|
|
95
|
-
* (reviewer-1a6e82e). */
|
|
96
|
-
const parseSecretJson = (text, what) => {
|
|
97
|
-
try { return JSON.parse(text); }
|
|
98
|
-
catch { throw new Error(`${what} returned output that is not valid JSON (withheld: this command handles credentials)`); }
|
|
99
|
-
};
|
|
100
|
-
/** Is a command on PATH? Resolved in-process rather than by running
|
|
101
|
-
* `command -v`, which is a SHELL BUILTIN — spawning it as a program depends on
|
|
102
|
-
* a /usr/bin/command binary that many systems do not ship, and its absence
|
|
103
|
-
* would read as "aw is missing" on every such host. */
|
|
104
|
-
/** The installed aw version from `aw version`, or undefined when it cannot be read. */
|
|
105
|
-
function awVersionTriple() {
|
|
106
|
-
try { return /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return undefined; }
|
|
107
|
-
}
|
|
108
|
-
function semverAtLeast(version, floor) {
|
|
109
|
-
const a = version.split(".").map(Number), b = floor.split(".").map(Number);
|
|
110
|
-
for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0); }
|
|
111
|
-
return true;
|
|
112
|
-
}
|
|
113
|
-
function awVersionLabel() {
|
|
114
|
-
const v = awVersionTriple();
|
|
115
|
-
return v ? v.slice(1, 4).join(".") : undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function onPath(cmd) {
|
|
119
|
-
for (const dir of String(process.env.PATH || "").split(delimiter)) {
|
|
120
|
-
if (!dir) continue;
|
|
121
|
-
try { const st = statSync(join(dir, cmd)); if (st.isFile() && (st.mode & 0o111)) return true; } catch { /* keep looking */ }
|
|
122
|
-
}
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
// Kernel home operations (`oats operation run messaging:teams|join|leave`, run
|
|
126
|
-
// with OATS_OPERATION and --json) read stdout as EXACTLY ONE JSON-v1 envelope
|
|
127
|
-
// whose `ok` agrees with the exit status (oats bin/oats.mjs finishOperation):
|
|
128
|
-
// {schemaVersion:1, ok:true, result} with exit 0, or {schemaVersion:1, ok:false,
|
|
129
|
-
// error:{code, message}} with a nonzero exit. Under OATS_OPERATION every other
|
|
130
|
-
// stdout write goes to stderr and every way out answers one envelope. Without
|
|
131
|
-
// it (`oats aweb teams --json` from a shell) the bare document is unchanged.
|
|
132
|
-
const OPERATION_COMMANDS = ["teams", "join", "leave"];
|
|
133
|
-
const operation = process.env.OATS_OPERATION && OPERATION_COMMANDS.includes(process.env.OATS_EVENT || process.argv[2]) ? process.env.OATS_OPERATION : undefined;
|
|
134
|
-
let operationAnswered = false;
|
|
135
|
-
let lastStderr = "";
|
|
136
|
-
const operationEnvelopeFailure = (code, message, details) => ({ schemaVersion: 1, ok: false, error: { code: code || "E_OPERATION_FAILED", message: String(message || "failed").slice(0, 1000), ...(details ? { details } : {}) } });
|
|
137
|
-
function answerOperation(envelope, exitCode) {
|
|
138
|
-
operationAnswered = true;
|
|
139
|
-
writeSync(1, JSON.stringify(envelope) + "\n");
|
|
140
|
-
process.exit(exitCode);
|
|
141
|
-
}
|
|
142
|
-
const operationOk = (result) => answerOperation({ schemaVersion: 1, ok: true, result }, 0);
|
|
143
|
-
const operationFail = (code, message, details) => answerOperation(operationEnvelopeFailure(code, message, details), 1);
|
|
144
|
-
if (operation) {
|
|
145
|
-
const toStderr = process.stderr.write.bind(process.stderr);
|
|
146
|
-
process.stderr.write = (chunk, ...rest) => { if (String(chunk).trim()) lastStderr = String(chunk).trim(); return toStderr(chunk, ...rest); };
|
|
147
|
-
process.stdout.write = (chunk, ...rest) => process.stderr.write(chunk, ...rest);
|
|
148
|
-
// Any exit that did not answer (a refusal printed to stderr, an uncaught
|
|
149
|
-
// error) still answers one failure envelope, and never exits 0.
|
|
150
|
-
process.on("exit", (code) => {
|
|
151
|
-
if (operationAnswered) return;
|
|
152
|
-
operationAnswered = true;
|
|
153
|
-
writeSync(1, JSON.stringify(operationEnvelopeFailure("E_OPERATION_FAILED", lastStderr || `oats-aweb ${operation} exited ${code} without an answer`)) + "\n");
|
|
154
|
-
if (!code) process.exitCode = 1;
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
const out = (o, code = 0) => {
|
|
158
|
-
if (operation) operationFail("E_OPERATION_FAILED", String(o?.warning || o?.problems?.[0]?.message || "failed").replace(/^oats-aweb: /, ""));
|
|
159
|
-
process.stdout.write(JSON.stringify(o) + "\n");
|
|
160
|
-
process.exit(code);
|
|
161
|
-
};
|
|
162
|
-
const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
|
|
163
|
-
/** Fatal for a REQUIRED spawn hook: emit metadata for compensation, then exit
|
|
164
|
-
* nonzero so the kernel rolls the spawn back. `meta` carries whatever external
|
|
165
|
-
* state already exists (e.g. a joined identity) so retire can undo it. */
|
|
166
|
-
const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(m).slice(0, 300)}` }, 1);
|
|
167
|
-
const parseAwJson = (text, what) => {
|
|
168
|
-
const trimmed = String(text ?? "").trim();
|
|
169
|
-
if (!trimmed) throw new Error(`${what} returned no JSON result`);
|
|
170
|
-
try { return JSON.parse(trimmed); } catch { /* may have progress before JSON */ }
|
|
171
|
-
const lines = trimmed.split(/\r?\n/);
|
|
172
|
-
for (let i = 0; i < lines.length; i++) {
|
|
173
|
-
if (!lines[i].trimStart().startsWith("{")) continue;
|
|
174
|
-
try { return JSON.parse(lines.slice(i).join("\n")); } catch { /* keep looking */ }
|
|
175
|
-
}
|
|
176
|
-
throw new Error(`${what} returned no JSON result`);
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
// Any selected snapshot enters the captured consumer BEFORE legacy settings,
|
|
180
|
-
// root discovery or identity handling. Invalid-present never falls back.
|
|
181
|
-
try {
|
|
182
|
-
const loaded = loadCapturedAwebExecution();
|
|
183
|
-
if (loaded.kind === "captured") {
|
|
184
|
-
const event = process.argv[2] || process.env.OATS_EVENT;
|
|
185
|
-
if ((process.env.OATS_EVENT && process.env.OATS_EVENT !== event) || process.argv.slice(3).some(arg => arg !== "--json")) throw new Error("captured entrypoint arguments differ from the selected action");
|
|
186
|
-
const manifest = JSON.parse(readFileSync(new URL("../oats.json", import.meta.url), "utf8"));
|
|
187
|
-
const selected = requireCapturedAwebAction(loaded, event, manifest);
|
|
188
|
-
const settings = parseBindingJson(Buffer.from(process.env.OATS_SETTINGS || "{}"));
|
|
189
|
-
if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => !["delivery", "root", "roots"].includes(k))) throw new Error("captured settings support delivery/root readiness only; no identity copying or ambient fallback");
|
|
190
|
-
const checked = assessCapturedSessionReadiness({ binding: selected.binding, invocation: selected.context, settings }, {
|
|
191
|
-
query(args, options) { selected.assertCurrent(); const result = querySelectedKernel(args, options); selected.assertCurrent(); return result; },
|
|
192
|
-
});
|
|
193
|
-
if (checked.status !== "ready") out({ ...checked, warning: `oats-aweb: ${checked.problems[0].message}` }, 1);
|
|
194
|
-
const result = runCapturedNative({ selected, event, settings, run });
|
|
195
|
-
out(result.output, result.exitCode);
|
|
196
|
-
}
|
|
197
|
-
} catch (error) {
|
|
198
|
-
out({ status: "needs-configuration", problems: [{ code: "invalid-binding", message: "invalid or changed captured aweb execution input" }], warning: "oats-aweb: invalid or changed captured aweb execution input; no legacy fallback was used" }, 1);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const event = process.env.OATS_EVENT || process.argv[2];
|
|
202
|
-
const instance = process.env.OATS_INSTANCE;
|
|
203
|
-
let home = process.env.OATS_HOME || process.cwd();
|
|
204
|
-
const AWEB_ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
205
|
-
const AWEB_ALIAS_RULE = "invalid alias: aweb aliases must be 1-64 characters, start with a letter or digit, and then contain only letters, digits, '-' or '_'";
|
|
206
|
-
const ALIAS_REUSE_REMEDY = "spawn with a different --name (kernels 0.26.0+) or a different --purpose";
|
|
207
|
-
const CLASSIC_REFUSAL = "oats.aweb 1.14 needs OATS 0.26.0 or newer (workspace model); on an older kernel pin oats.aweb v1.13.x";
|
|
208
|
-
const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_DEFAULT_TEAM);
|
|
209
|
-
const isClassicEnvironment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
|
|
210
|
-
// Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
|
|
211
|
-
// delivery: "channel" (default) keeps the native channel packages waking the
|
|
212
|
-
// instance; "session" hands delivery to the host wake broker (aweb-abil):
|
|
213
|
-
// AWEB_DELIVERY=session goes into the launch environment, the Claude channel
|
|
214
|
-
// flag is omitted, and nothing wakes the instance until the broker exists.
|
|
215
|
-
let settings = {};
|
|
216
|
-
try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); } catch { settings = {}; }
|
|
217
|
-
const TEAM_SETTING_MESSAGE = "teams are not a setting since oats.aweb 1.17 / OATS 0.30: use oats teams / oats soul teams";
|
|
218
|
-
const hasStaleTeamSetting = settings && typeof settings === "object" && !Array.isArray(settings) && Object.hasOwn(settings, "team");
|
|
219
|
-
if (hasStaleTeamSetting && ["spawn", "launch", "setup", "teams", "join", "leave", "roster"].includes(event)) fatal(TEAM_SETTING_MESSAGE);
|
|
220
|
-
const deliveryMode = (() => {
|
|
221
|
-
const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
|
|
222
|
-
return v === "session" ? "session" : "channel";
|
|
223
|
-
})();
|
|
224
|
-
const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
|
|
225
|
-
const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
|
|
226
|
-
if (isClassicEnvironment() && ["spawn", "setup"].includes(event)) {
|
|
227
|
-
if (event === "setup") { console.error(CLASSIC_REFUSAL); process.exit(1); }
|
|
228
|
-
fatal(CLASSIC_REFUSAL);
|
|
229
|
-
}
|
|
230
|
-
if (!["local", "global"].includes(identityMode) && ["spawn"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
|
|
231
|
-
if (event === "spawn" && identityMode === "local" && !identitySettings.source && (!instance || !AWEB_ALIAS_RE.test(instance))) fatal(`${AWEB_ALIAS_RULE}; OATS_INSTANCE is ${instance ? "not valid" : "missing"}, so no identity could be minted`);
|
|
232
|
-
const NO_TEAMS_MESSAGE = "no teams configured: run `oats aweb setup`";
|
|
233
|
-
const unmappedDefaultMessage = (label = defaultTeamLabel()) => `the default team ${label} has no provider id yet: its owner runs oats aweb setup, then commits the id, or choose another default with oats teams default`;
|
|
234
|
-
const defaultTeamLabel = () => typeof process.env.OATS_DEFAULT_TEAM === "string" && process.env.OATS_DEFAULT_TEAM.trim() ? process.env.OATS_DEFAULT_TEAM.trim() : undefined;
|
|
235
|
-
const defaultTeamId = () => typeof process.env.OATS_DEFAULT_TEAM_ID === "string" && process.env.OATS_DEFAULT_TEAM_ID.trim() ? process.env.OATS_DEFAULT_TEAM_ID.trim() : undefined;
|
|
236
|
-
const defaultTeamFromEnv = () => process.env.OATS_DEFAULT_TEAM_FROM === "soul" ? "soul" : "deployment";
|
|
237
|
-
const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
|
|
238
|
-
const teamConfigRemedy = () => defaultTeamLabel() && !defaultTeamId() ? unmappedDefaultMessage() : NO_TEAMS_MESSAGE;
|
|
239
|
-
function declaredRootCandidate(team = defaultTeamId()) {
|
|
240
|
-
const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
|
|
241
|
-
if (team && typeof roots[team] === "string" && roots[team].trim()) return { root: roots[team].trim(), key: `settings.oats.aweb.roots[${JSON.stringify(team)}]`, declared: true };
|
|
242
|
-
if (typeof settings.root === "string" && settings.root.trim()) return { root: settings.root.trim(), key: "settings.oats.aweb.root", declared: true };
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
function rootSettingCandidate(team = defaultTeamId()) {
|
|
246
|
-
const declared = declaredRootCandidate(team);
|
|
247
|
-
if (declared) return declared;
|
|
248
|
-
const fallback = process.env.OATS_WORKSPACE || process.cwd();
|
|
249
|
-
return fallback ? { root: fallback, key: "settings.oats.aweb.root", declared: false } : undefined;
|
|
250
|
-
}
|
|
251
|
-
function awebRootProblem(candidate) {
|
|
252
|
-
if (!candidate?.root) return `no messaging root at ${process.cwd()}: run oats aweb setup there or set settings.oats.aweb.root`;
|
|
253
|
-
if (!isAbsolute(candidate.root)) return `${candidate.key} must be an absolute directory whose .aw is the aweb minting root`;
|
|
254
|
-
return `no messaging root at ${resolve(candidate.root)}: run oats aweb setup there or set ${candidate.key}`;
|
|
255
|
-
}
|
|
256
|
-
function resolveAwebRoot() {
|
|
257
|
-
const declared = declaredRootCandidate();
|
|
258
|
-
if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
|
|
259
|
-
if (declared?.declared) return undefined;
|
|
260
|
-
const candidate = rootSettingCandidate();
|
|
261
|
-
if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
|
|
262
|
-
return undefined;
|
|
263
|
-
}
|
|
264
|
-
function awebRoot() { return resolveAwebRoot(); }
|
|
265
|
-
|
|
266
|
-
/** Team memberships from `aw team list --json`. The current CLI returns
|
|
267
|
-
* `memberships`; older output used `teams`. Spawn resolution and `oats aweb
|
|
268
|
-
* setup` MUST read this the same way — they drifted once, and because spawn
|
|
269
|
-
* treats "no matching membership" as fatal, the stale reader turned every
|
|
270
|
-
* name-only team config into a blocked spawn (reviewer-602627c). */
|
|
271
|
-
const teamMemberships = (listed) => listed?.memberships || listed?.teams || [];
|
|
272
|
-
const teamIdsOf = (listed) => teamMemberships(listed).map((m) => m.team_id || m.id || m);
|
|
273
|
-
function normalizeAwebTeamName(label) {
|
|
274
|
-
const normalized = String(label || "").toLowerCase().replace(/[_.]+/g, "-").replace(/^-+|-+$/g, "");
|
|
275
|
-
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(normalized) || normalized.length > 128) throw new Error(`team label ${JSON.stringify(label)} does not normalize to a valid aweb team name`);
|
|
276
|
-
return normalized;
|
|
277
|
-
}
|
|
278
|
-
const AWEB_TEAM_ID_MESSAGE = "aweb team ids must have shape <name>:<namespace> (name matches ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$; namespace is a hostname)";
|
|
279
|
-
function validHostname(value) { const s = String(value || ""); return s.length <= 253 && s.split(".").every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)); }
|
|
280
|
-
function validAwebTeamId(value) { const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,127}):([^:]+)$/.exec(String(value || "")); return !!m && validHostname(m[2]); }
|
|
281
|
-
function invalidAwebTeamId(env = process.env) {
|
|
282
|
-
const id = defaultTeamId();
|
|
283
|
-
if (id && !validAwebTeamId(id)) return id;
|
|
284
|
-
try { const rows = JSON.parse(env.OATS_TEAMS || "[]"); if (Array.isArray(rows)) for (const r of rows) if (r && typeof r.team === "string" && r.team.trim() && !validAwebTeamId(r.team.trim())) return r.team.trim(); } catch { /* malformed OATS_TEAMS is handled as no eligible teams */ }
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
const flagEq = (name, value) => `${name}=${String(value)}`;
|
|
288
|
-
function assertNotFlag(value, what) {
|
|
289
|
-
if (String(value || "").startsWith("-")) throw new Error(`${what} must not start with '-'`);
|
|
290
|
-
}
|
|
291
|
-
const yamlQuote = (value) => JSON.stringify(String(value));
|
|
292
|
-
function findOatsLocal(start = process.env.OATS_WORKSPACE || process.cwd()) {
|
|
293
|
-
let dir = resolve(start);
|
|
294
|
-
for (;;) {
|
|
295
|
-
const file = join(dir, "oats-local.yaml");
|
|
296
|
-
if (existsSync(file)) return file;
|
|
297
|
-
const parent = dirname(dir);
|
|
298
|
-
if (parent === dir) return join(resolve(start), "oats-local.yaml");
|
|
299
|
-
dir = parent;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
function blockEnd(lines, start, indent) {
|
|
303
|
-
let end = lines.length;
|
|
304
|
-
for (let i = start + 1; i < lines.length; i++) if (lines[i].trim() && !lines[i].startsWith(" ".repeat(indent + 1))) { end = i; break; }
|
|
305
|
-
return end;
|
|
306
|
-
}
|
|
307
|
-
function ensureYamlBlock(lines, parentStart, parentEnd, indent, header) {
|
|
308
|
-
const row = `${" ".repeat(indent)}${header}:`;
|
|
309
|
-
for (let i = parentStart + 1; i < parentEnd; i++) if (lines[i].trimEnd() === row) return i;
|
|
310
|
-
lines.splice(parentEnd, 0, row);
|
|
311
|
-
return parentEnd;
|
|
312
|
-
}
|
|
313
|
-
function atomicWrite(file, text) {
|
|
314
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
315
|
-
const tmp = join(dirname(file), `.${basenameForTemp(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
316
|
-
writeFileSync(tmp, text, { mode: 0o600 });
|
|
317
|
-
renameSync(tmp, file);
|
|
318
|
-
}
|
|
319
|
-
function basenameForTemp(file) { return file.split(/[\\/]/).pop() || "oats-local.yaml"; }
|
|
320
|
-
function unsupportedLocalYaml(file, detail, team, rootDir) {
|
|
321
|
-
throw new Error(`${file}: cannot safely update settings.oats.aweb.roots automatically (${detail}); add this line by hand under block-style settings.oats.aweb.roots: ${yamlQuote(team)}: ${yamlQuote(rootDir)}`);
|
|
322
|
-
}
|
|
323
|
-
function findBlockHeader(lines, start, end, indent, names) {
|
|
324
|
-
const pad = " ".repeat(indent);
|
|
325
|
-
for (let i = start; i < end; i++) {
|
|
326
|
-
const line = lines[i];
|
|
327
|
-
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
328
|
-
if (!line.startsWith(pad) || line.startsWith(pad + " ")) continue;
|
|
329
|
-
const trimmed = line.slice(indent).trimEnd();
|
|
330
|
-
for (const name of names) if (trimmed === `${name}:`) return i;
|
|
331
|
-
for (const name of names) if (trimmed.startsWith(`${name}:`)) return { unsupported: i, line };
|
|
332
|
-
}
|
|
333
|
-
return -1;
|
|
334
|
-
}
|
|
335
|
-
function assertAwebRootSettingRecordable(team, rootDir, { start = process.env.OATS_WORKSPACE || process.cwd() } = {}) {
|
|
336
|
-
const file = findOatsLocal(start);
|
|
337
|
-
if (!existsSync(file)) return;
|
|
338
|
-
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
|
339
|
-
let settings = findBlockHeader(lines, 0, lines.length, 0, ["settings"]);
|
|
340
|
-
if (typeof settings === "object") unsupportedLocalYaml(file, `line ${settings.unsupported + 1} is not a block-style settings: mapping`, team, rootDir);
|
|
341
|
-
if (settings < 0) return;
|
|
342
|
-
const settingsEnd = blockEnd(lines, settings, 0);
|
|
343
|
-
let aweb = findBlockHeader(lines, settings + 1, settingsEnd, 2, ["oats.aweb", '"oats.aweb"', "'oats.aweb'"]);
|
|
344
|
-
if (typeof aweb === "object") unsupportedLocalYaml(file, `line ${aweb.unsupported + 1} is not a block-style oats.aweb: mapping`, team, rootDir);
|
|
345
|
-
if (aweb < 0) return;
|
|
346
|
-
const awebEnd = blockEnd(lines, aweb, 2);
|
|
347
|
-
const roots = findBlockHeader(lines, aweb + 1, awebEnd, 4, ["roots"]);
|
|
348
|
-
if (typeof roots === "object") unsupportedLocalYaml(file, `line ${roots.unsupported + 1} is not a block-style roots: mapping`, team, rootDir);
|
|
349
|
-
}
|
|
350
|
-
function recordAwebRootSetting(team, rootDir, { start = process.env.OATS_WORKSPACE || process.cwd() } = {}) {
|
|
351
|
-
const file = findOatsLocal(start);
|
|
352
|
-
const existed = existsSync(file);
|
|
353
|
-
const lines = existed ? readFileSync(file, "utf8").split(/\r?\n/) : ["schemaVersion: 2", "workspace: local"];
|
|
354
|
-
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
355
|
-
let settings = findBlockHeader(lines, 0, lines.length, 0, ["settings"]);
|
|
356
|
-
if (typeof settings === "object") unsupportedLocalYaml(file, `line ${settings.unsupported + 1} is not a block-style settings: mapping`, team, rootDir);
|
|
357
|
-
if (settings < 0) { lines.push("settings:"); settings = lines.length - 1; }
|
|
358
|
-
let settingsEnd = blockEnd(lines, settings, 0);
|
|
359
|
-
let aweb = findBlockHeader(lines, settings + 1, settingsEnd, 2, ["oats.aweb", '"oats.aweb"', "'oats.aweb'"]);
|
|
360
|
-
if (typeof aweb === "object") unsupportedLocalYaml(file, `line ${aweb.unsupported + 1} is not a block-style oats.aweb: mapping`, team, rootDir);
|
|
361
|
-
if (aweb < 0) { aweb = ensureYamlBlock(lines, settings, settingsEnd, 2, "oats.aweb"); settingsEnd++; }
|
|
362
|
-
let awebEnd = blockEnd(lines, aweb, 2);
|
|
363
|
-
let roots = findBlockHeader(lines, aweb + 1, awebEnd, 4, ["roots"]);
|
|
364
|
-
if (typeof roots === "object") unsupportedLocalYaml(file, `line ${roots.unsupported + 1} is not a block-style roots: mapping`, team, rootDir);
|
|
365
|
-
if (roots < 0) { roots = ensureYamlBlock(lines, aweb, awebEnd, 4, "roots"); awebEnd++; }
|
|
366
|
-
const rootsEnd = blockEnd(lines, roots, 4);
|
|
367
|
-
const key = yamlQuote(team), value = yamlQuote(rootDir), row = ` ${key}: ${value}`;
|
|
368
|
-
const existing = lines.findIndex((l, i) => i > roots && i < rootsEnd && l.trimStart().startsWith(`${key}:`));
|
|
369
|
-
if (existing >= 0) lines[existing] = row; else lines.splice(rootsEnd, 0, row);
|
|
370
|
-
atomicWrite(file, `${lines.join("\n")}\n`);
|
|
371
|
-
}
|
|
372
|
-
function perTeamRoot(base, label) { return join(resolve(base), ".aweb-roots", normalizeAwebTeamName(label)); }
|
|
373
|
-
|
|
374
|
-
function awFloorProblem() {
|
|
375
|
-
if (!onPath("aw")) return `aw CLI not on PATH; install aw >= ${AW_MIN}`;
|
|
376
|
-
const version = awVersionLabel();
|
|
377
|
-
if (!version) return `aw version could not be read; install aw >= ${AW_MIN}`;
|
|
378
|
-
if (semverAtLeast(version, AW_MIN)) return undefined;
|
|
379
|
-
return `aw ${version} is older than required ${AW_MIN}; install aw >= ${AW_MIN}`;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const AW_INSTALL = "install the aw CLI first — see https://aweb.ai/docs (or `oats aweb setup` for guided onboarding)";
|
|
383
|
-
const isCommand = ["roster", "setup", "teams", "join", "leave"].includes(event);
|
|
384
|
-
const invalidConfiguredTeamId = invalidAwebTeamId();
|
|
385
|
-
if (invalidConfiguredTeamId && (isCommand || ["spawn", "launch"].includes(event))) {
|
|
386
|
-
if (isCommand) { console.error(`oats aweb ${event}: ${AWEB_TEAM_ID_MESSAGE}`); process.exit(1); }
|
|
387
|
-
fatal(AWEB_TEAM_ID_MESSAGE);
|
|
388
|
-
}
|
|
389
|
-
if (!onPath("aw")) {
|
|
390
|
-
if (isCommand) { console.error(`oats aweb ${event}: aw CLI not on PATH — ${AW_INSTALL}`); process.exit(1); }
|
|
391
|
-
if (event === "spawn") fatal(`aw CLI not on PATH, so no identity could be minted and this instance would have no messaging — ${AW_INSTALL}`);
|
|
392
|
-
warn(`aw CLI not on PATH — no identity minted; ${AW_INSTALL}`);
|
|
393
|
-
}
|
|
394
|
-
if (isCommand || event === "spawn") {
|
|
395
|
-
const floorProblem = awFloorProblem();
|
|
396
|
-
if (floorProblem) {
|
|
397
|
-
if (isCommand) { console.error(`oats aweb ${event}: ${floorProblem}`); process.exit(1); }
|
|
398
|
-
fatal(`${floorProblem}, so no identity could be minted and this instance would not meet the messaging contract`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ---------------------------------------------------------------------------
|
|
403
|
-
// Retained identity (explicit per-soul opt-in): a standing seat keeps its
|
|
404
|
-
// did:aw and address when re-seated as an OATS instance. Per aweb's contract
|
|
405
|
-
// (2026-09-05): copy exactly the identity-authority files from the source
|
|
406
|
-
// .aw into the home's .aw, reconnect the coordination binding with
|
|
407
|
-
// `aw workspace connect`, verify online, heartbeat and status show the new
|
|
408
|
-
// path, and hold a lock BESIDE the source so no second seat can take it.
|
|
409
|
-
// Never copy workspace.yaml, caches or locks; never delete the source; never
|
|
410
|
-
// team-join (that is the mint path, which would try to create the alias
|
|
411
|
-
// again). Retire releases the lock and leaves the identity alone.
|
|
412
|
-
const IDENTITY_AUTHORITY = ["signing.key", "identity.yaml", "teams.yaml", "team-certs", "encryption.yaml", "encryption-keys"];
|
|
413
|
-
// Session delivery registers the home with the host wake broker (aweb-abil:
|
|
414
|
-
// `aw wake register --home <abs> --identity-home <abs> --delivery session
|
|
415
|
-
// [--backend tmux|herdr]`, durable even when the daemon is down). An aw
|
|
416
|
-
// without `aw wake` cannot deliver in session mode: refuse, never silently
|
|
417
|
-
// turn a working channel into a poll-only instance.
|
|
418
|
-
// Throws, never exits: both callers run it inside a try whose catch performs
|
|
419
|
-
// the rollback (the seat path restores the binding; the mint path hands the
|
|
420
|
-
// minted identity to compensation).
|
|
421
|
-
function wakeRegister(instanceHome, identityHome) {
|
|
422
|
-
const backend = process.env.OATS_BACKEND;
|
|
423
|
-
try {
|
|
424
|
-
run(["aw", "wake", "register", "--home", instanceHome, "--identity-home", identityHome, "--delivery", "session", ...(backend ? ["--backend", backend] : [])], instanceHome, 60000);
|
|
425
|
-
} catch (e) {
|
|
426
|
-
throw new Error(`delivery: session needs an aw with the wake broker CLI (aw wake register), which this aw does not provide (${e.message || e}); install the aweb release that ships aw wake, or use delivery: channel`);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
function wakeDeregister(instanceHome) {
|
|
430
|
-
try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
|
|
431
|
-
}
|
|
432
|
-
const NORMAL_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send", "events.read", "coord.read", "coord.write", "presence.write", "contacts.read", "contacts.write"];
|
|
433
|
-
const REVIEWER_GRANT_SCOPES = ["mail.read", "chat.read", "events.read", "coord.read", "presence.write"];
|
|
434
|
-
const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
|
|
435
|
-
const grantProfile = () => identitySettings.profile === undefined || identitySettings.profile === null || identitySettings.profile === "" ? "normal" : String(identitySettings.profile);
|
|
436
|
-
function grantScopes() {
|
|
437
|
-
if (Array.isArray(identitySettings.scopes) && identitySettings.scopes.length) return identitySettings.scopes.map(String);
|
|
438
|
-
const profile = grantProfile();
|
|
439
|
-
if (profile === "normal") return [...NORMAL_GRANT_SCOPES];
|
|
440
|
-
if (profile === "reviewer") return [...REVIEWER_GRANT_SCOPES];
|
|
441
|
-
fatal(`identity.profile must be one of "normal" or "reviewer" (got ${JSON.stringify(identitySettings.profile)})`);
|
|
442
|
-
}
|
|
443
|
-
const grantE2eeRequired = () => identitySettings.e2ee !== false;
|
|
444
|
-
const grantRenewMode = () => identitySettings.renew === undefined || identitySettings.renew === null || identitySettings.renew === "" ? "off" : String(identitySettings.renew);
|
|
445
|
-
function resolveResidentCustody(name) {
|
|
446
|
-
if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
|
|
447
|
-
const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
|
|
448
|
-
const custody = residents[name];
|
|
449
|
-
if (typeof custody !== "string" || !isAbsolute(custody) || !existsSync(join(custody, ".aw", "identity.yaml"))) {
|
|
450
|
-
fatal(`identity.mode "global" resident ${JSON.stringify(name)} is not resolvable; set ${residentKeyHint(name)} to an absolute custody directory whose .aw/identity.yaml exists`);
|
|
451
|
-
}
|
|
452
|
-
return custody;
|
|
453
|
-
}
|
|
454
|
-
function grantShow(custody, grantId) {
|
|
455
|
-
const raw = run(["aw", "id", "grant", "show", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
456
|
-
return parseAwJson(raw, "aw id grant show");
|
|
457
|
-
}
|
|
458
|
-
const revokeMaybeApplied = (error) => /may have applied|context deadline exceeded|timed out|timeout/i.test(String(error?.message || error));
|
|
459
|
-
function revokeGrant(custody, grantId) {
|
|
460
|
-
try {
|
|
461
|
-
const raw = run(["aw", "id", "grant", "revoke", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
462
|
-
return parseAwJson(raw, "aw id grant revoke");
|
|
463
|
-
} catch (e) {
|
|
464
|
-
if (revokeMaybeApplied(e)) {
|
|
465
|
-
try {
|
|
466
|
-
const shown = grantShow(custody, grantId);
|
|
467
|
-
const status = String(shown.status || shown.grant?.status || "").toLowerCase();
|
|
468
|
-
if (status === "revoked" || shown.revoked === true) return { grant_id: grantId, status: "revoked", verifiedByShow: true };
|
|
469
|
-
} catch { /* fall through to original revoke error */ }
|
|
470
|
-
}
|
|
471
|
-
throw e;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
function grantYamlNestedScalar(text, sectionName, key) {
|
|
475
|
-
const lines = String(text ?? "").split(/\r?\n/);
|
|
476
|
-
let inSection = false, baseIndent = 0;
|
|
477
|
-
for (const line of lines) {
|
|
478
|
-
const section = new RegExp(`^(\\s*)${sectionName}:\\s*(?:#.*)?$`).exec(line);
|
|
479
|
-
if (section) { inSection = true; baseIndent = section[1].length; continue; }
|
|
480
|
-
if (inSection) {
|
|
481
|
-
const ind = /^(\s*)/.exec(line)?.[1].length || 0;
|
|
482
|
-
if (line.trim() && ind <= baseIndent) inSection = false;
|
|
483
|
-
const value = new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`).exec(line);
|
|
484
|
-
if (value) return value[1].trim();
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
return undefined;
|
|
488
|
-
}
|
|
489
|
-
function recoverGrantHome(grantHome) {
|
|
490
|
-
try {
|
|
491
|
-
const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
|
|
492
|
-
const scalar = (key) => {
|
|
493
|
-
const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
|
|
494
|
-
return m ? m[1].trim() : undefined;
|
|
495
|
-
};
|
|
496
|
-
return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at"), subjectAlias: grantYamlNestedScalar(text, "subject", "alias"), custodySocket: grantYamlCustodySocket(text) };
|
|
497
|
-
} catch { return {}; }
|
|
498
|
-
}
|
|
499
|
-
function readGrantYamlCustodySocket(grantHome) {
|
|
500
|
-
const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
|
|
501
|
-
return grantYamlCustodySocket(text);
|
|
502
|
-
}
|
|
503
|
-
function verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team }) {
|
|
504
|
-
const recorded = readGrantYamlCustodySocket(grantHome);
|
|
505
|
-
if (!recorded) throw new Error(`grant.yaml custody.socket_path is missing`);
|
|
506
|
-
if (recorded !== custodySocket) throw new Error(`grant.yaml custody.socket_path ${recorded} differs from preflight ${custodySocket}`);
|
|
507
|
-
const raw = run(["aw", "custody", "status", "--json"], grantHome, 20000, { env: { AWEB_IDENTITY_HOME: grantHome }, secretSafe: true });
|
|
508
|
-
const status = parseAwJson(raw, "aw custody status");
|
|
509
|
-
if (String(status.status || "") !== "running") throw new Error(`grant custody status is ${status.status || "unknown"}, not running`);
|
|
510
|
-
if (status.socket_path !== custodySocket) throw new Error(`grant custody status socket_path ${status.socket_path || "<missing>"} differs from preflight ${custodySocket}`);
|
|
511
|
-
if (status.resident?.alias !== alias) throw new Error(`grant custody resident alias ${status.resident?.alias || "<missing>"} differs from ${alias}`);
|
|
512
|
-
const teamRow = (Array.isArray(status.teams) ? status.teams : []).find((t) => t && (t.team_id || t.id) === team);
|
|
513
|
-
if (!teamRow || teamRow.ready !== true) throw new Error(`grant custody team ${team} is not ready`);
|
|
514
|
-
return status;
|
|
515
|
-
}
|
|
516
|
-
function preflightCustodySocket(preflight) {
|
|
517
|
-
return typeof preflight.status?.socket_path === "string" && preflight.status.socket_path.trim() ? preflight.status.socket_path.trim() : undefined;
|
|
518
|
-
}
|
|
519
|
-
function requirePreflightCustodySocket(preflight) {
|
|
520
|
-
const socket = preflightCustodySocket(preflight);
|
|
521
|
-
if (!socket) fatal("custody preflight reported no socket_path, so the grant cannot be attached to custody");
|
|
522
|
-
return socket;
|
|
523
|
-
}
|
|
524
|
-
function grantHomeDirs() {
|
|
525
|
-
try {
|
|
526
|
-
return readdirSync(home)
|
|
527
|
-
.filter((name) => name === ".aweb-identity" || /^\.aweb-identity-\d+$/.test(name))
|
|
528
|
-
.map((name) => join(home, name))
|
|
529
|
-
.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
|
|
530
|
-
} catch { return []; }
|
|
531
|
-
}
|
|
532
|
-
function removeGrantHomes() { for (const p of grantHomeDirs()) { try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } }
|
|
533
|
-
function newestGrantHome() {
|
|
534
|
-
const dirs = grantHomeDirs().map((p) => {
|
|
535
|
-
try { return { path: p, mtime: statSync(p).mtimeMs }; } catch { return { path: p, mtime: 0 }; }
|
|
536
|
-
}).sort((a, b) => b.mtime - a.mtime || b.path.localeCompare(a.path));
|
|
537
|
-
return dirs[0]?.path || join(home, ".aweb-identity");
|
|
538
|
-
}
|
|
539
|
-
const priorGrantHome = (meta = {}) => typeof meta.identity?.grant?.home === "string" && meta.identity.grant.home ? meta.identity.grant.home : newestGrantHome();
|
|
540
|
-
function retainedLaunchOutput(meta = {}, identityHome = priorGrantHome(meta)) {
|
|
541
|
-
const delivery = meta.delivery || deliveryMode;
|
|
542
|
-
const env = {};
|
|
543
|
-
if (delivery === "session") env.AWEB_DELIVERY = "session";
|
|
544
|
-
if (meta.identity?.mode === "global" && meta.identity?.grant?.id) env.AWEB_IDENTITY_HOME = identityHome;
|
|
545
|
-
const launch = (process.env.OATS_RUNTIME || "") === "claude" && delivery === "channel" ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" } : undefined;
|
|
546
|
-
return { ...(Object.keys(env).length ? { env } : {}), ...(launch ? { launch } : {}) };
|
|
547
|
-
}
|
|
548
|
-
function grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }) {
|
|
549
|
-
return ["aw", "id", "grant", "mint", flagEq("--team", team), flagEq("--scope", scopes.join(",")), flagEq("--ttl", ttl), flagEq("--label", `oats:${instance}`), flagEq("--out", grantHome), flagEq("--custody-socket", custodySocket), "--json"];
|
|
550
|
-
}
|
|
551
|
-
function validateMintedGrant(minted, grantHome) {
|
|
552
|
-
const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
|
|
553
|
-
const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
|
|
554
|
-
const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
|
|
555
|
-
const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
|
|
556
|
-
if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
|
|
557
|
-
if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
|
|
558
|
-
return { minted, grantId, expiresAt, mintedTeam, alias: typeof minted.alias === "string" && minted.alias ? minted.alias : undefined, address: typeof minted.address === "string" && minted.address ? minted.address : null };
|
|
559
|
-
}
|
|
560
|
-
function parseMintedGrant(raw, grantHome) { return validateMintedGrant(parseAwJson(raw, "aw id grant mint"), grantHome); }
|
|
561
|
-
function globalGrantRenew() {
|
|
562
|
-
const mode = grantRenewMode();
|
|
563
|
-
const oldMeta = JSON.parse(process.env.OATS_META || "{}");
|
|
564
|
-
if (mode === "off") out(retainedLaunchOutput(oldMeta));
|
|
565
|
-
if (mode !== "launch") fatal(`identity.renew must be "off" or "launch" (got ${JSON.stringify(identitySettings.renew)})`);
|
|
566
|
-
if (oldMeta.identity?.mode !== "global" || !oldMeta.identity?.grant?.id) out(retainedLaunchOutput(oldMeta));
|
|
567
|
-
const resident = String(identitySettings.resident || oldMeta.identity.resident || "");
|
|
568
|
-
const custody = resolveResidentCustody(resident);
|
|
569
|
-
const team = defaultTeamId() || oldMeta.identity.team;
|
|
570
|
-
if (!team) fatal(teamConfigRemedy());
|
|
571
|
-
const scopes = grantScopes();
|
|
572
|
-
const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
|
|
573
|
-
const oldHome = priorGrantHome(oldMeta);
|
|
574
|
-
let preflight;
|
|
575
|
-
try { preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), fatalOnError: false, runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal }); }
|
|
576
|
-
catch (e) { out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` }); }
|
|
577
|
-
const custodySocket = preflightCustodySocket(preflight);
|
|
578
|
-
if (!custodySocket) out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight reported no socket_path, so the grant cannot be attached to custody; keeping previous grant ${oldMeta.identity.grant.id}` });
|
|
579
|
-
let stamp = Math.floor(Date.now() / 1000);
|
|
580
|
-
let grantHome = join(home, `.aweb-identity-${stamp}`);
|
|
581
|
-
while (existsSync(grantHome)) grantHome = join(home, `.aweb-identity-${++stamp}`);
|
|
582
|
-
let parsed;
|
|
583
|
-
try {
|
|
584
|
-
parsed = parseMintedGrant(run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] }), grantHome);
|
|
585
|
-
} catch (e) {
|
|
586
|
-
try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
587
|
-
out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal mint failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
|
|
588
|
-
}
|
|
589
|
-
const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = parsed;
|
|
590
|
-
const recovered = recoverGrantHome(grantHome);
|
|
591
|
-
const alias = recovered.subjectAlias || mintedAlias || oldMeta.identity.alias || resident;
|
|
592
|
-
const newMeta = { ...oldMeta, delivery: oldMeta.delivery || deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address: address || oldMeta.identity.address || null, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
|
|
593
|
-
if (mintedTeam !== team) {
|
|
594
|
-
try { revokeGrant(custody, grantId); } catch { /* minted mismatch expires by TTL if revoke fails */ }
|
|
595
|
-
try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
596
|
-
out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal minted grant team ${mintedTeam} differs from ${team}; keeping previous grant ${oldMeta.identity.grant.id}` });
|
|
597
|
-
}
|
|
598
|
-
try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias: newMeta.identity.alias, team: mintedTeam }); }
|
|
599
|
-
catch (e) {
|
|
600
|
-
try { revokeGrant(custody, grantId); } catch { /* new grant expires by TTL if revoke fails */ }
|
|
601
|
-
try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
602
|
-
out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal grant ${grantId} custody attachment failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
|
|
603
|
-
}
|
|
604
|
-
let warning;
|
|
605
|
-
try { revokeGrant(custody, oldMeta.identity.grant.id); }
|
|
606
|
-
catch (e) { warning = `oats-aweb: previous grant ${oldMeta.identity.grant.id} was not revoked (${e.message || e}); new grant ${grantId} is kept and the previous grant still expires at ${oldMeta.identity.grant.expiresAt || "its TTL"}`; }
|
|
607
|
-
out({ meta: newMeta, ...retainedLaunchOutput(newMeta, grantHome), ...(warning ? { warning } : {}) });
|
|
608
|
-
}
|
|
609
|
-
function globalGrantSpawn() {
|
|
610
|
-
const resident = String(identitySettings.resident || "");
|
|
611
|
-
const custody = resolveResidentCustody(resident);
|
|
612
|
-
let team = defaultTeamId();
|
|
613
|
-
const teamWarnings = [];
|
|
614
|
-
if (!team) fatal(teamConfigRemedy());
|
|
615
|
-
const grantHome = join(home, ".aweb-identity");
|
|
616
|
-
if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
|
|
617
|
-
const scopes = grantScopes();
|
|
618
|
-
const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
|
|
619
|
-
const preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal });
|
|
620
|
-
const custodySocket = requirePreflightCustodySocket(preflight);
|
|
621
|
-
let meta;
|
|
622
|
-
const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
623
|
-
const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
|
|
624
|
-
try {
|
|
625
|
-
const raw = run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
626
|
-
let minted;
|
|
627
|
-
try { minted = parseMintedGrant(raw, grantHome).minted; }
|
|
628
|
-
catch (parseError) {
|
|
629
|
-
const recovered = recoverGrantHome(grantHome);
|
|
630
|
-
if (recovered.grantId) {
|
|
631
|
-
meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes, home: grantHome } }) };
|
|
632
|
-
try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
|
|
633
|
-
catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
|
|
634
|
-
}
|
|
635
|
-
throw parseError;
|
|
636
|
-
}
|
|
637
|
-
const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = validateMintedGrant(minted, grantHome);
|
|
638
|
-
const recovered = recoverGrantHome(grantHome);
|
|
639
|
-
const alias = recovered.subjectAlias || mintedAlias || resident;
|
|
640
|
-
meta = { delivery: deliveryMode, defaultTeam: { label: defaultTeamLabel(), team: mintedTeam, from: defaultTeamFromEnv() }, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
|
|
641
|
-
if (mintedTeam !== team) {
|
|
642
|
-
try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
|
|
643
|
-
catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
|
|
644
|
-
}
|
|
645
|
-
try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team: mintedTeam }); }
|
|
646
|
-
catch (e) {
|
|
647
|
-
try { revokeGrant(custody, grantId); failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; the grant was revoked and nothing was kept`); }
|
|
648
|
-
catch (revokeError) { failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
|
|
649
|
-
}
|
|
650
|
-
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
651
|
-
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
652
|
-
: undefined;
|
|
653
|
-
const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: grantHome };
|
|
654
|
-
if (deliveryMode === "session") {
|
|
655
|
-
try { wakeRegister(home, grantHome); }
|
|
656
|
-
catch (e) {
|
|
657
|
-
try { revokeGrant(custody, grantId); failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; grant revoked and grant home removed`); }
|
|
658
|
-
catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
const warnings = [...teamWarnings, ...preflight.warnings];
|
|
662
|
-
const e2eeBrief = preflight.warnings.length ? ` Warning: ${preflight.warnings.join(" ")}` : "";
|
|
663
|
-
const deliveryBrief = deliveryMode === "session"
|
|
664
|
-
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
665
|
-
: "";
|
|
666
|
-
out({
|
|
667
|
-
meta,
|
|
668
|
-
env,
|
|
669
|
-
brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run; your grant home is attached to the resident's custody service. If a message you sent shows unverified at the receiver, report it, do not retry.${e2eeBrief}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
|
|
670
|
-
...(launch ? { launch } : {}),
|
|
671
|
-
...(warnings.length ? { warning: warnings.join(" | ") } : {}),
|
|
672
|
-
});
|
|
673
|
-
} catch (e) {
|
|
674
|
-
if (meta?.identity?.grant?.id) { try { revokeGrant(custody, meta.identity.grant.id); } catch { /* retire compensation gets meta */ } cleanup(); }
|
|
675
|
-
fatal(`identity grant minting failed: ${e.message || e}`, meta);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
function globalGrantRetire(meta) {
|
|
679
|
-
if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
|
|
680
|
-
const id = meta.identity?.grant?.id;
|
|
681
|
-
if (!id) out({ meta: { retired: false, reason: "nothing-to-revoke" } });
|
|
682
|
-
const resident = meta.identity?.resident;
|
|
683
|
-
const custody = resolveResidentCustody(resident);
|
|
684
|
-
try {
|
|
685
|
-
revokeGrant(custody, id);
|
|
686
|
-
removeGrantHomes();
|
|
687
|
-
out({ meta: { retired: true, identityRevoked: true, grant: id } });
|
|
688
|
-
} catch (e) {
|
|
689
|
-
removeGrantHomes();
|
|
690
|
-
out({ meta: { retired: false, reason: "grant-revoke-failed", grant: id }, warning: `oats-aweb: grant ${id} was not revoked (${e.message || e}); it still expires at ${meta.identity?.grant?.expiresAt || "its TTL"}` }, 1);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
|
|
694
|
-
/** The alias a home's .aw/workspace.yaml records under memberships (indented),
|
|
695
|
-
* or undefined. Read only when the hook has no alias of its own. */
|
|
696
|
-
const workspaceAliasOf = (homeDir) => {
|
|
697
|
-
try { const m = readFileSync(join(homeDir, ".aw", "workspace.yaml"), "utf8").match(/^\s*alias:\s*["']?([a-z0-9][a-z0-9_-]{0,63})["']?\s*$/mi); return m ? m[1] : undefined; }
|
|
698
|
-
catch { return undefined; }
|
|
699
|
-
};
|
|
700
|
-
/** A join that the CLI reported as failed (or that this hook killed on
|
|
701
|
-
* timeout) may still have completed server-side: the home then holds a
|
|
702
|
-
* signing key, a team certificate and a workspace binding. */
|
|
703
|
-
const joinedLate = (homeDir) => existsSync(join(homeDir, ".aw", "signing.key")) && existsSync(join(homeDir, ".aw", "team-certs")) && !!workspaceAliasOf(homeDir);
|
|
704
|
-
const JOIN_TIMEOUT_MS = Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) > 0 ? Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) : 120000;
|
|
705
|
-
const yamlScalar = (text, key) => {
|
|
706
|
-
const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
|
|
707
|
-
return m ? m[1].trim() : undefined;
|
|
708
|
-
};
|
|
709
|
-
function activeTeamAt(root) {
|
|
710
|
-
try {
|
|
711
|
-
const text = readFileSync(join(resolve(root), ".aw", "teams.yaml"), "utf8");
|
|
712
|
-
return yamlScalar(text, "active_team") || yamlScalar(text, "active");
|
|
713
|
-
} catch { return undefined; }
|
|
714
|
-
}
|
|
715
|
-
function retainedSeatSpawn(source, takeOver) {
|
|
716
|
-
if (typeof source !== "string" || !source.startsWith("/")) fatal("identity.source must be the absolute path of the legacy .aw directory to retain");
|
|
717
|
-
if (!existsSync(join(source, "signing.key"))) fatal(`identity.source ${source} holds no signing.key, so there is no identity to retain`);
|
|
718
|
-
const lockPath = seatLockPath(source);
|
|
719
|
-
let takenOver;
|
|
720
|
-
if (existsSync(lockPath)) {
|
|
721
|
-
let held; try { held = JSON.parse(readFileSync(lockPath, "utf8")); } catch { held = {}; }
|
|
722
|
-
const holderHome = held.home;
|
|
723
|
-
// Held means the holder's home still exists: retire removes both the lock
|
|
724
|
-
// and the home, so a home that is there is a seat that was never retired.
|
|
725
|
-
// No process liveness is inferred (the spawner's pid says nothing about the
|
|
726
|
-
// runtime). The only escape is the explicit, warned take-over for a seat
|
|
727
|
-
// whose runtime is known to be dead.
|
|
728
|
-
if (holderHome && existsSync(holderHome)) {
|
|
729
|
-
if (takeOver !== true) fatal(`identity at ${source} is already held by ${holderHome} (${lockPath}); a seat is never taken from a holder whose home exists — retire that instance first, or set identity.takeOver: true only if you know its runtime is dead`);
|
|
730
|
-
takenOver = holderHome;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
const srcWorkspace = existsSync(join(source, "workspace.yaml")) ? readFileSync(join(source, "workspace.yaml"), "utf8") : "";
|
|
734
|
-
const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
|
|
735
|
-
if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
|
|
736
|
-
const role = yamlScalar(srcWorkspace, "role_name");
|
|
737
|
-
let team = defaultTeamId();
|
|
738
|
-
if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
|
|
739
|
-
if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (${teamConfigRemedy()}, or keep an active team in ${join(source, "teams.yaml")})`);
|
|
740
|
-
const dest = join(home, ".aw");
|
|
741
|
-
const legacyHome = dirname(source);
|
|
742
|
-
// The lock is taken FIRST: a concurrent second spawn must see it before any
|
|
743
|
-
// byte of the identity is copied.
|
|
744
|
-
// Exclusive creation (wx): two concurrent spawns cannot both pass the
|
|
745
|
-
// existence check and overwrite each other; the loser fails here having
|
|
746
|
-
// copied nothing. A take-over replaces the stale lock first, deliberately.
|
|
747
|
-
if (takenOver) { try { rmSync(lockPath, { force: true }); } catch { /* replaced below */ } }
|
|
748
|
-
try {
|
|
749
|
-
writeFileSync(lockPath, JSON.stringify({ home, instance, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
750
|
-
} catch (e) {
|
|
751
|
-
fatal(`identity at ${source} was taken by another spawn a moment ago (${lockPath} exists); nothing copied`);
|
|
752
|
-
}
|
|
753
|
-
let connected = false;
|
|
754
|
-
const rollback = () => {
|
|
755
|
-
// After `aw workspace connect` the server binding points at the new home;
|
|
756
|
-
// deleting the copy alone would leave the identity bound to nothing. Put
|
|
757
|
-
// the binding back where it was, from the legacy home, then remove the
|
|
758
|
-
// copy; if the restore fails, KEEP the copy so the seat stays recoverable.
|
|
759
|
-
let restored = !connected;
|
|
760
|
-
if (connected) {
|
|
761
|
-
try { run(["aw", "workspace", "connect", flagEq("--service", service), flagEq("--team", team), ...(role ? [flagEq("--role", role)] : [])], legacyHome, 60000); restored = true; }
|
|
762
|
-
catch { restored = false; }
|
|
763
|
-
}
|
|
764
|
-
if (restored) { try { rmSync(dest, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } }
|
|
765
|
-
return restored;
|
|
766
|
-
};
|
|
767
|
-
try {
|
|
768
|
-
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
769
|
-
chmodSync(dest, 0o700);
|
|
770
|
-
for (const name of IDENTITY_AUTHORITY) {
|
|
771
|
-
const from = join(source, name);
|
|
772
|
-
if (!existsSync(from)) continue; // encryption material may be absent on an identity that never had it
|
|
773
|
-
const to = join(dest, name);
|
|
774
|
-
if (statSync(from).isDirectory()) {
|
|
775
|
-
cpSync(from, to, { recursive: true }); chmodSync(to, 0o700);
|
|
776
|
-
for (const f of readdirSync(to)) { const p = join(to, f); if (statSync(p).isFile()) chmodSync(p, 0o600); } // private keys inside, whatever the source modes were
|
|
777
|
-
} else { copyFileSync(from, to); chmodSync(to, 0o600); }
|
|
778
|
-
}
|
|
779
|
-
for (const forbidden of ["workspace.yaml", "context", "interaction-log.jsonl", "channel-delivered-ids.json", "chat-delivered-ids.json"]) {
|
|
780
|
-
if (existsSync(join(dest, forbidden))) rmSync(join(dest, forbidden), { recursive: true, force: true });
|
|
781
|
-
}
|
|
782
|
-
run(["aw", "workspace", "connect", flagEq("--service", service), flagEq("--team", team), ...(role ? [flagEq("--role", role)] : [])], home, 60000);
|
|
783
|
-
connected = true;
|
|
784
|
-
run(["aw", "check", "--online"], home, 60000);
|
|
785
|
-
run(["aw", "heartbeat"], home, 60000);
|
|
786
|
-
const status = run(["aw", "workspace", "status", "--json"], home, 60000);
|
|
787
|
-
// Thrown, not fatal: the catch below rolls the copy and the lock back first.
|
|
788
|
-
// Verified by parsing: the workspace row's path must be this home. The
|
|
789
|
-
// hostname the row records is whatever the binding stored (on hosted
|
|
790
|
-
// teams it need not equal this OS hostname), so it is reported, not judged.
|
|
791
|
-
let st; try { st = JSON.parse(String(status)); } catch { throw new Error(`aw workspace status from ${home} answered no JSON, so the seat is not connected; nothing is briefed`); }
|
|
792
|
-
const ws = st.workspace && typeof st.workspace === "object" ? st.workspace : st;
|
|
793
|
-
const shownPath = String(ws.workspace_path || ws.path || "");
|
|
794
|
-
const same = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
795
|
-
if (!shownPath || !same(shownPath, home)) throw new Error(`aw workspace status from ${home} shows workspace_path ${JSON.stringify(shownPath)} not this home, so the seat is not connected; nothing is briefed`);
|
|
796
|
-
const hostNote = ws.hostname && ws.hostname !== hostname() && ws.hostname.split(".")[0] !== hostname().split(".")[0] ? ` (workspace row hostname ${ws.hostname}, this host ${hostname()})` : "";
|
|
797
|
-
const identityText = readFileSync(join(dest, "identity.yaml"), "utf8");
|
|
798
|
-
const expectedDid = yamlScalar(identityText, "did");
|
|
799
|
-
const expectedAddress = yamlScalar(identityText, "address");
|
|
800
|
-
// The same-identity check the contract is for: `aw whoami --json` from the
|
|
801
|
-
// new home reports the did and address the CLI now acts as (workspace
|
|
802
|
-
// status carries no did); both must equal the copied identity.yaml.
|
|
803
|
-
let who; try { who = JSON.parse(String(run(["aw", "whoami", "--json"], home, 60000))); } catch (e) { throw new Error(`aw whoami from ${home} answered no JSON (${e.message || e}); the seat is not verified`); }
|
|
804
|
-
const shownDid = who.did || who.identity?.did;
|
|
805
|
-
const shownAddress = who.address || who.identity?.address;
|
|
806
|
-
if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
|
|
807
|
-
if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
|
|
808
|
-
const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
|
|
809
|
-
if (!AWEB_ALIAS_RE.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
|
|
810
|
-
const alias = aliasRaw;
|
|
811
|
-
if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
|
|
812
|
-
writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
813
|
-
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
814
|
-
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
815
|
-
: undefined;
|
|
816
|
-
const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: dest };
|
|
817
|
-
const deliveryBrief = deliveryMode === "session"
|
|
818
|
-
? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
|
|
819
|
-
: "";
|
|
820
|
-
if (deliveryMode === "session") wakeRegister(home, dest);
|
|
821
|
-
const warnings = [];
|
|
822
|
-
if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
|
|
823
|
-
if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
|
|
824
|
-
out({
|
|
825
|
-
meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
|
|
826
|
-
env,
|
|
827
|
-
brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
|
|
828
|
-
...(launch ? { launch } : {}),
|
|
829
|
-
...(warnings.length ? { warning: warnings.join(" | ") } : {}),
|
|
830
|
-
});
|
|
831
|
-
} catch (e) {
|
|
832
|
-
const restored = rollback();
|
|
833
|
-
fatal(`retained identity could not be seated from ${source}: ${e.message || e}${connected ? (restored ? " (the server binding was restored to the legacy home and the copy removed)" : ` (the server binding still points at ${home} and the copy was KEPT there so the seat is recoverable: run aw workspace connect from ${legacyHome} to restore it, or retry the spawn)`) : ""}`);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
838
|
-
function parseOatsTeams(env = process.env) {
|
|
839
|
-
try {
|
|
840
|
-
const rows = JSON.parse(env.OATS_TEAMS || "[]");
|
|
841
|
-
return Array.isArray(rows) ? rows.filter((r) => r && typeof r === "object").map((r) => ({
|
|
842
|
-
label: String(r.label || ""),
|
|
843
|
-
team: typeof r.team === "string" ? r.team : null,
|
|
844
|
-
default: r.default === true,
|
|
845
|
-
from: r.from === "shared" ? "shared" : "local",
|
|
846
|
-
})) : [];
|
|
847
|
-
} catch { return []; }
|
|
848
|
-
}
|
|
849
|
-
function eligibleTeams() { return parseOatsTeams().filter((t) => !t.default && t.label && t.team); }
|
|
850
|
-
function labelsCsv(text) { return String(text || "").split(",").map((s) => s.trim()).filter(Boolean); }
|
|
851
|
-
function primaryTeamLabel() { return defaultTeamLabel() || null; }
|
|
852
|
-
function requestedJoinLabels() { return labelsCsv(settings.join); }
|
|
853
|
-
function validateJoinLabels(labels, { action = "join" } = {}) {
|
|
854
|
-
const eligible = eligibleTeams();
|
|
855
|
-
const byLabel = new Map(eligible.map((t) => [t.label, t]));
|
|
856
|
-
for (const label of labels) {
|
|
857
|
-
if (action === "leave" && label === defaultTeamLabel()) {
|
|
858
|
-
const error = new Error(`E_TEAM_DEFAULT: ${label} is the default team and cannot be left`);
|
|
859
|
-
error.code = "E_TEAM_DEFAULT";
|
|
860
|
-
throw error;
|
|
861
|
-
}
|
|
862
|
-
if (!LABEL_RE.test(label) || !byLabel.has(label)) {
|
|
863
|
-
const choices = eligible.map((t) => t.label).join(", ") || "(none)";
|
|
864
|
-
const error = new Error(`E_TEAM_NOT_ELIGIBLE: ${label} is not an eligible team label for this instance (eligible: ${choices})`);
|
|
865
|
-
error.code = "E_TEAM_NOT_ELIGIBLE";
|
|
866
|
-
throw error;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
return labels.map((label) => byLabel.get(label));
|
|
870
|
-
}
|
|
871
|
-
const joinedTeamsOf = (meta = {}) => Array.isArray(meta.joinedTeams) ? meta.joinedTeams.filter((j) => j && typeof j === "object" && j.label && j.team && j.identityHome) : [];
|
|
872
|
-
const leftTeamsOf = (meta = {}) => Array.isArray(meta.left) ? meta.left.filter((j) => j && typeof j === "object" && j.label && j.team && j.at && j.reason).slice(-20) : [];
|
|
873
|
-
const providerStateDir = () => join(home, ".oats-aweb");
|
|
874
|
-
const providerTeamsFile = () => join(providerStateDir(), "teams.json");
|
|
875
|
-
function readProviderTeamsState(meta = {}) {
|
|
876
|
-
try {
|
|
877
|
-
const doc = JSON.parse(readFileSync(providerTeamsFile(), "utf8"));
|
|
878
|
-
return { joinedTeams: joinedTeamsOf(doc), wakeJoined: doc.wakeJoined === true, left: leftTeamsOf(doc) };
|
|
879
|
-
} catch { return { joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true, left: leftTeamsOf(meta) }; }
|
|
880
|
-
}
|
|
881
|
-
function writeProviderTeamsState(meta) {
|
|
882
|
-
mkdirSync(providerStateDir(), { recursive: true, mode: 0o700 });
|
|
883
|
-
writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true, left: leftTeamsOf(meta) }, null, 2) + "\n", { mode: 0o600 });
|
|
884
|
-
}
|
|
885
|
-
function withProviderTeams(meta = {}) { const state = readProviderTeamsState(meta); return { ...meta, joinedTeams: state.joinedTeams, wakeJoined: state.wakeJoined, left: state.left }; }
|
|
886
|
-
function identityHomeForLabel(label) { return join(home, `.aweb-identity-${label}`); }
|
|
887
|
-
function awWithIdentity(identityHome, args) { return ["aw", "--identity-home", identityHome, ...args]; }
|
|
888
|
-
function commandOutput(e) { return [e?.stdout, e?.stderr, e?.message].filter(Boolean).join("\n"); }
|
|
889
|
-
function joinedWorkspacePresent(identityHome) { return existsSync(join(identityHome, "workspace.yaml")); }
|
|
890
|
-
function serviceForJoinedTeam(root, label, accepted, ...docs) {
|
|
891
|
-
const fromDocs = [...docs, accepted].map((d) => d && (d.service || d.service_url || d.aweb_url || d.workspace?.service || d.workspace?.service_url || d.workspace?.aweb_url)).find(Boolean);
|
|
892
|
-
if (fromDocs) return fromDocs;
|
|
893
|
-
const rootWorkspace = root ? join(resolve(root), ".aw", "workspace.yaml") : undefined;
|
|
894
|
-
const fromRoot = rootWorkspace && existsSync(rootWorkspace) ? yamlScalar(readFileSync(rootWorkspace, "utf8"), "aweb_url") : undefined;
|
|
895
|
-
if (fromRoot) return fromRoot;
|
|
896
|
-
throw new Error(`cannot determine the aweb service for joined team ${label} from ${root || "(unknown root)"}; pass --service <url> (ask the team owner; hosted aweb is https://app.aweb.ai/api)`);
|
|
897
|
-
}
|
|
898
|
-
function releaseReceiptStatus(doc) {
|
|
899
|
-
return doc && typeof doc === "object" && doc.alias_released === true ? "released" : undefined;
|
|
900
|
-
}
|
|
901
|
-
function cleanupJoinedIdentity(entry, fallbackAlias, cwd = home) {
|
|
902
|
-
let receipt, released;
|
|
903
|
-
const alias = entry.alias || fallbackAlias;
|
|
904
|
-
if (!alias) throw new Error(`failed to leave team ${entry.label}; no alias is known for ${entry.identityHome}`);
|
|
905
|
-
try {
|
|
906
|
-
receipt = parseAwJson(run(awWithIdentity(entry.identityHome, ["workspace", "delete", alias, "--json"]), cwd, 60000), "aw workspace delete");
|
|
907
|
-
released = releaseReceiptStatus(receipt);
|
|
908
|
-
} catch (e) {
|
|
909
|
-
throw new Error(`failed to leave team ${entry.label}; kept ${entry.identityHome} so cleanup can be retried: ${String(commandOutput(e)).slice(0, 300)}`);
|
|
910
|
-
}
|
|
911
|
-
if (!released) throw new Error(`failed to leave team ${entry.label}; workspace delete did not report alias_released: true; kept ${entry.identityHome} so cleanup can be retried: ${JSON.stringify(receipt)}`);
|
|
912
|
-
try { rmSync(entry.identityHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
913
|
-
return { released, receipt };
|
|
914
|
-
}
|
|
915
|
-
function acceptedTeamMembership(identityHome, expectedTeam) {
|
|
916
|
-
const listed = parseAwJson(run(awWithIdentity(identityHome, ["id", "team", "list", "--json"]), dirname(identityHome), 60000), "aw id team list");
|
|
917
|
-
const ids = teamIdsOf(listed).map(String);
|
|
918
|
-
if (expectedTeam) return ids.includes(expectedTeam) ? expectedTeam : undefined;
|
|
919
|
-
return ids.length === 1 ? ids[0] : undefined;
|
|
920
|
-
}
|
|
921
|
-
function connectExistingJoinedTeam({ label, identityHome, expectedTeam, root, cwd = home, serviceDocs = [], resumeCommand }) {
|
|
922
|
-
const team = acceptedTeamMembership(identityHome, expectedTeam);
|
|
923
|
-
if (!team) throw new Error(`team root ${dirname(identityHome)} already holds an aweb identity, but aw id team list does not show ${expectedTeam || "exactly one team membership"}; choose a different label or remove the stale root deliberately`);
|
|
924
|
-
try {
|
|
925
|
-
if (!joinedWorkspacePresent(identityHome)) {
|
|
926
|
-
const service = serviceForJoinedTeam(root, label, {}, ...serviceDocs);
|
|
927
|
-
run(awWithIdentity(identityHome, ["workspace", "connect", flagEq("--service", service), flagEq("--team", team), "--json"]), cwd, 60000, { secretSafe: true });
|
|
928
|
-
}
|
|
929
|
-
if (!joinedWorkspacePresent(identityHome)) throw new Error(`workspace connect did not write ${join(identityHome, "workspace.yaml")}`);
|
|
930
|
-
} catch (e) {
|
|
931
|
-
throw new Error(`joined team ${label} resume failed: ${e.message || e}; kept ${identityHome} for retry${resumeCommand ? `; resume with: ${resumeCommand}` : ""}`);
|
|
932
|
-
}
|
|
933
|
-
return { label, team, identityHome, receive: "poll", since: new Date().toISOString() };
|
|
934
|
-
}
|
|
935
|
-
function acceptConnectVerifyJoinedTeam({ label, token, identityHome, alias: requestedAlias, expectedTeam, root, cwd = home, serviceDocs = [], cleanupOnFailure = true, resumeCommand, useAcceptedService = true }) {
|
|
936
|
-
if (!token || typeof token !== "string") throw new Error(`aw id team accept-invite received no invite token for ${label}`);
|
|
937
|
-
const raw = parseSecretJson(run(awWithIdentity(identityHome, ["id", "team", "accept-invite", token, flagEq("--name", requestedAlias), "--local", "--json"]), cwd, JOIN_TIMEOUT_MS, { secrets: [token], secretSafe: true }), "aw id team accept-invite");
|
|
938
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`aw id team accept-invite returned no usable result for joined team ${label}`);
|
|
939
|
-
const alias = typeof raw.alias === "string" && AWEB_ALIAS_RE.test(raw.alias) ? raw.alias : requestedAlias;
|
|
940
|
-
const team = typeof raw.team_id === "string" && raw.team_id ? raw.team_id : expectedTeam;
|
|
941
|
-
if (!team) throw new Error(`aw id team accept-invite returned no team_id for joined team ${label}`);
|
|
942
|
-
if (expectedTeam && team !== expectedTeam) throw new Error(`joined team ${team} differs from requested ${expectedTeam}`);
|
|
943
|
-
const joined = { label, team, identityHome, receive: "poll", since: new Date().toISOString(), alias };
|
|
944
|
-
try {
|
|
945
|
-
if (!joinedWorkspacePresent(identityHome)) {
|
|
946
|
-
const service = serviceForJoinedTeam(root, label, useAcceptedService ? raw : {}, ...serviceDocs);
|
|
947
|
-
run(awWithIdentity(identityHome, ["workspace", "connect", flagEq("--service", service), flagEq("--team", team), "--json"]), cwd, 60000, { secretSafe: true });
|
|
948
|
-
}
|
|
949
|
-
if (!joinedWorkspacePresent(identityHome)) throw new Error(`workspace connect did not write ${join(identityHome, "workspace.yaml")}`);
|
|
950
|
-
} catch (e) {
|
|
951
|
-
if (!cleanupOnFailure) throw new Error(`joined team ${label} was accepted but workspace connect/verification failed: ${e.message || e}; kept ${identityHome} for retry${resumeCommand ? `; resume with: ${resumeCommand}` : ""}`);
|
|
952
|
-
let cleanupNote;
|
|
953
|
-
try { cleanupJoinedIdentity(joined, requestedAlias, cwd); cleanupNote = "; the joined identity was deleted"; }
|
|
954
|
-
catch (cleanupError) { cleanupNote = `; cleanup failed: ${cleanupError.message || cleanupError}`; }
|
|
955
|
-
throw new Error(`joined team ${label} was accepted but workspace connect/verification failed: ${e.message || e}${cleanupNote}`);
|
|
956
|
-
}
|
|
957
|
-
return { raw, joined };
|
|
958
|
-
}
|
|
959
|
-
function readCapabilityMeta() {
|
|
960
|
-
if (process.env.OATS_META) { try { return withProviderTeams(JSON.parse(process.env.OATS_META || "{}")); } catch { return withProviderTeams({}); } }
|
|
961
|
-
try { return withProviderTeams(JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).capabilityMeta?.["oats.aweb"] || {}); } catch { return withProviderTeams({}); }
|
|
962
|
-
}
|
|
963
|
-
function defaultTeamObject(meta = {}) {
|
|
964
|
-
const label = meta.defaultTeam?.label || defaultTeamLabel() || null;
|
|
965
|
-
const team = meta.defaultTeam?.team || meta.team || meta.identity?.team || defaultTeamId() || null;
|
|
966
|
-
if (!label && !team) return null;
|
|
967
|
-
const from = meta.defaultTeam?.from === "soul" ? "soul" : "deployment";
|
|
968
|
-
return { label, team, from };
|
|
969
|
-
}
|
|
970
|
-
function teamsDocument(meta = readCapabilityMeta()) {
|
|
971
|
-
const teams = parseOatsTeams();
|
|
972
|
-
const joined = joinedTeamsOf(meta);
|
|
973
|
-
const joinedLabels = new Set(joined.map((j) => j.label));
|
|
974
|
-
return {
|
|
975
|
-
defaultTeam: defaultTeamObject(meta),
|
|
976
|
-
eligible: teams.filter((t) => !t.default && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label), from: t.from })),
|
|
977
|
-
joined: joined.map((j) => ({ label: j.label, team: j.team, identityHome: j.identityHome, receive: j.receive || "poll", since: j.since })),
|
|
978
|
-
left: leftTeamsOf(meta),
|
|
979
|
-
at: new Date().toISOString(),
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
function mintJoinedTeam(row, meta) {
|
|
983
|
-
const existing = joinedTeamsOf(meta).find((j) => j.label === row.label);
|
|
984
|
-
if (existing) return { meta, joined: existing, changed: false };
|
|
985
|
-
const identityHome = identityHomeForLabel(row.label);
|
|
986
|
-
const root = awebRootForTeam(row.team);
|
|
987
|
-
if (!root) throw new Error(`${awebRootProblem(rootSettingCandidate(row.team))}, so team ${row.label} could not be joined`);
|
|
988
|
-
const inv = parseSecretJson(run(["aw", "team", "invite", flagEq("--team-id", row.team), "--json"], root, 45000, { secretSafe: true }), "aw team invite");
|
|
989
|
-
if (!inv?.token || typeof inv.token !== "string") throw new Error(`aw team invite returned no usable token for ${row.label}`);
|
|
990
|
-
const { joined } = acceptConnectVerifyJoinedTeam({ label: row.label, token: inv.token, identityHome, alias: instance || meta.alias, expectedTeam: row.team, root, cwd: home, serviceDocs: [inv] });
|
|
991
|
-
const next = { ...meta, joinedTeams: [...joinedTeamsOf(meta), joined] };
|
|
992
|
-
return { meta: next, joined, changed: true };
|
|
993
|
-
}
|
|
994
|
-
function leaveJoinedTeam(label, meta) {
|
|
995
|
-
const joined = joinedTeamsOf(meta);
|
|
996
|
-
const entry = joined.find((j) => j.label === label);
|
|
997
|
-
if (!entry) return { meta, changed: false };
|
|
998
|
-
const { released, receipt } = cleanupJoinedIdentity(entry, meta.alias || instance, home);
|
|
999
|
-
return { meta: { ...meta, joinedTeams: joined.filter((j) => j.label !== label) }, changed: true, released, receipt };
|
|
1000
|
-
}
|
|
1001
|
-
function awebRootForTeam(team) {
|
|
1002
|
-
const declared = declaredRootCandidate(team);
|
|
1003
|
-
if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
|
|
1004
|
-
if (declared?.declared) return undefined;
|
|
1005
|
-
const candidate = rootSettingCandidate(team);
|
|
1006
|
-
if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
|
|
1007
|
-
return undefined;
|
|
1008
|
-
}
|
|
1009
|
-
let forwardedSoulArg;
|
|
1010
|
-
function stripForwardedSoul(argv = process.argv.slice(3)) {
|
|
1011
|
-
const out = [];
|
|
1012
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1013
|
-
const arg = argv[i];
|
|
1014
|
-
if (arg === "--soul") { if (i + 1 < argv.length) forwardedSoulArg = argv[++i]; continue; }
|
|
1015
|
-
if (arg.startsWith("--soul=")) { forwardedSoulArg = arg.slice("--soul=".length); continue; }
|
|
1016
|
-
out.push(arg);
|
|
1017
|
-
}
|
|
1018
|
-
return out;
|
|
1019
|
-
}
|
|
1020
|
-
function parseHomeCommandArgs(argv = process.argv.slice(3)) {
|
|
1021
|
-
argv = stripForwardedSoul(argv);
|
|
1022
|
-
const rest = [];
|
|
1023
|
-
let json = false, labels;
|
|
1024
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1025
|
-
const arg = argv[i];
|
|
1026
|
-
if (arg === "--json") { json = true; continue; }
|
|
1027
|
-
if (arg === "--home" && argv[i + 1]) { home = resolve(argv[++i]); process.env.OATS_HOME = home; continue; }
|
|
1028
|
-
if (arg.startsWith("--home=")) { home = resolve(arg.slice("--home=".length)); process.env.OATS_HOME = home; continue; }
|
|
1029
|
-
if (arg === "--labels" && argv[i + 1]) { labels = argv[++i]; continue; }
|
|
1030
|
-
if (arg.startsWith("--labels=")) { labels = arg.slice("--labels=".length); continue; }
|
|
1031
|
-
rest.push(arg);
|
|
1032
|
-
}
|
|
1033
|
-
if (!labels && rest[0]) labels = rest[0];
|
|
1034
|
-
return { json, labels: labelsCsv(labels) };
|
|
1035
|
-
}
|
|
1036
|
-
function outputTeamsDocument(doc, json) {
|
|
1037
|
-
if (json) { console.log(JSON.stringify(doc)); return; }
|
|
1038
|
-
console.log(`default team: ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.team || "unmapped"})` : "none"}`);
|
|
1039
|
-
for (const row of doc.eligible) console.log(`${row.joined ? "joined" : "eligible"}: ${row.label} (${row.team})`);
|
|
1040
|
-
for (const row of doc.joined || []) if (!(doc.eligible || []).some((e) => e.label === row.label)) console.log(`joined: ${row.label} (${row.team})`);
|
|
1041
|
-
for (const row of doc.left || []) console.log(`left: ${row.label} (${row.team}) ${row.reason}`);
|
|
1042
|
-
for (const warning of doc.warnings || []) console.log(`warning: ${warning}`);
|
|
1043
|
-
}
|
|
1044
|
-
const actionWarning = (warning) => String(warning || "").slice(0, 300);
|
|
1045
|
-
const registryOrigin = (value) => typeof value === "string" && /^https?:\/\//i.test(value.trim()) ? value.trim() : undefined;
|
|
1046
|
-
function registryOriginFromJoinedState(row, match, listed) {
|
|
1047
|
-
const fromJson = registryOrigin(match?.registry_origin || match?.registry_url || match?.registry || match?.awid_registry_url || listed?.registry_origin || listed?.registry_url || listed?.registry || listed?.awid_registry_url);
|
|
1048
|
-
if (fromJson) return { registry: fromJson };
|
|
1049
|
-
for (const file of ["identity.yaml", "teams.yaml", "workspace.yaml", "registry.yaml", "config.yaml"]) {
|
|
1050
|
-
try {
|
|
1051
|
-
const text = readFileSync(join(row.identityHome, file), "utf8");
|
|
1052
|
-
for (const key of ["awid_registry_url", "registry_origin", "registry_url", "registry"]) {
|
|
1053
|
-
const found = registryOrigin(yamlScalar(text, key));
|
|
1054
|
-
if (found) return { registry: found };
|
|
1055
|
-
}
|
|
1056
|
-
} catch { /* keep looking */ }
|
|
1057
|
-
}
|
|
1058
|
-
const fromEnv = registryOrigin(process.env.AWID_REGISTRY_URL);
|
|
1059
|
-
if (fromEnv) return { registry: fromEnv };
|
|
1060
|
-
return { registry: null, registryError: "registry origin could not be determined from the joined identity; command omits --registry and aw will use its default" };
|
|
1061
|
-
}
|
|
1062
|
-
function certificateIdFromJoinedCertificateFile(row) {
|
|
1063
|
-
const certFile = join(row.identityHome, "team-certs", `${String(row.team || "").replace(/:/g, "__")}.pem`);
|
|
1064
|
-
if (!existsSync(certFile)) return { found: false };
|
|
1065
|
-
try {
|
|
1066
|
-
const cert = JSON.parse(readFileSync(certFile, "utf8"));
|
|
1067
|
-
if (cert?.team_id !== row.team) return { found: false, error: `certificate file ${certFile} is for ${cert?.team_id || "unknown team"}, not ${row.team}` };
|
|
1068
|
-
const certificateId = typeof cert?.certificate_id === "string" ? cert.certificate_id.trim() : "";
|
|
1069
|
-
if (!certificateId) return { found: false, error: `certificate file ${certFile} did not contain a certificate_id for ${row.team}` };
|
|
1070
|
-
return { found: true, certificateId };
|
|
1071
|
-
} catch (e) {
|
|
1072
|
-
return { found: false, error: `could not read certificate file ${certFile}: ${e.message || e}` };
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
function certificateIdForJoinedTeam(row) {
|
|
1076
|
-
const fromFile = certificateIdFromJoinedCertificateFile(row);
|
|
1077
|
-
let listed, match, listError;
|
|
1078
|
-
try {
|
|
1079
|
-
listed = parseAwJson(run(awWithIdentity(row.identityHome, ["id", "team", "list", "--json"]), home, 60000), "aw id team list");
|
|
1080
|
-
const memberships = teamMemberships(listed);
|
|
1081
|
-
match = memberships.find((m) => String(m?.team_id || m?.id || m?.team || m) === row.team);
|
|
1082
|
-
} catch (e) {
|
|
1083
|
-
listError = actionWarning(e.message || e);
|
|
1084
|
-
}
|
|
1085
|
-
const registry = registryOriginFromJoinedState(row, match, listed);
|
|
1086
|
-
if (fromFile.found) return { certificateId: fromFile.certificateId, ...registry };
|
|
1087
|
-
if (match) {
|
|
1088
|
-
const certificateId = match && typeof match === "object" ? (match.certificate_id || match.cert_id || match.certId || match.certificate?.id || match.cert?.id) : undefined;
|
|
1089
|
-
if (typeof certificateId === "string" && certificateId.trim()) return { certificateId: certificateId.trim(), ...registry };
|
|
1090
|
-
return { certificateId: null, certificateIdError: fromFile.error || `certificate id for ${row.team} was not present in the joined certificate file or aw id team list --json`, ...registry };
|
|
1091
|
-
}
|
|
1092
|
-
return { certificateId: null, certificateIdError: fromFile.error || listError || `aw id team list --json returned no membership for ${row.team}`, ...registry };
|
|
1093
|
-
}
|
|
1094
|
-
function failedLeaveDisposition(row, error) {
|
|
1095
|
-
const text = String(error?.message || error || "");
|
|
1096
|
-
const reason = /team_not_hosted/i.test(text) ? "team_not_hosted" : actionWarning(text);
|
|
1097
|
-
const data = { label: row.label, team: row.team, alias: row.alias || instance || null, ...certificateIdForJoinedTeam(row), at: new Date().toISOString(), reason, ...(reason === "team_not_hosted" ? { cleanup: "controller" } : {}) };
|
|
1098
|
-
const teamName = String(row.team || "").split(":")[0] || row.team;
|
|
1099
|
-
const namespace = String(row.team || "").includes(":") ? String(row.team).split(":").slice(1).join(":") : "<namespace>";
|
|
1100
|
-
const registryArg = data.registry ? ` --registry ${data.registry}` : "";
|
|
1101
|
-
const command = reason === "team_not_hosted" && data.certificateId ? `aw id team remove-member --namespace ${namespace} --team ${teamName} --cert-id ${data.certificateId}${registryArg} --json` : undefined;
|
|
1102
|
-
const ownerCommand = command
|
|
1103
|
-
? `; controller cleanup: ${command}${data.registryError ? ` (${data.registryError})` : ""}`
|
|
1104
|
-
: reason === "team_not_hosted"
|
|
1105
|
-
? `; controller cleanup needs the certificate id, but ${data.certificateIdError || "it could not be read"}${data.registryError ? ` (${data.registryError})` : ""}`
|
|
1106
|
-
: "";
|
|
1107
|
-
return { data: { ...data, ...(command ? { command } : {}) }, warning: `joined team ${row.label} cleanup failed: ${data.reason}${ownerCommand}` };
|
|
1108
|
-
}
|
|
1109
|
-
function runTeamsCommand(kind) {
|
|
1110
|
-
const args = parseHomeCommandArgs();
|
|
1111
|
-
let meta = readCapabilityMeta();
|
|
1112
|
-
const actions = [];
|
|
1113
|
-
const warnings = [];
|
|
1114
|
-
if (kind === "join" || kind === "leave") {
|
|
1115
|
-
if (identityMode === "global" || meta.identity?.mode === "global") {
|
|
1116
|
-
const error = new Error('joined teams need local per-team identities; this home acts as a resident identity through a session grant (identity.mode "global")');
|
|
1117
|
-
error.code = "E_TEAM_GLOBAL_MODE";
|
|
1118
|
-
throw error;
|
|
1119
|
-
}
|
|
1120
|
-
const rows = validateJoinLabels(args.labels, { action: kind });
|
|
1121
|
-
if (!rows.length) { const error = new Error("labels are required"); error.code = "E_BAD_ARGS"; throw error; }
|
|
1122
|
-
// Record every completed label even when a later one fails: a confirmed
|
|
1123
|
-
// leave deleted its home, and a join created a remote identity.
|
|
1124
|
-
try {
|
|
1125
|
-
for (const row of rows) {
|
|
1126
|
-
const result = kind === "join" ? mintJoinedTeam(row, meta) : leaveJoinedTeam(row.label, meta);
|
|
1127
|
-
meta = result.meta;
|
|
1128
|
-
writeProviderTeamsState(meta);
|
|
1129
|
-
const boundedWarning = result.warning ? actionWarning(result.warning) : undefined;
|
|
1130
|
-
actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(boundedWarning ? { warning: boundedWarning } : {}) });
|
|
1131
|
-
if (boundedWarning) warnings.push(`oats-aweb: ${boundedWarning}`);
|
|
1132
|
-
}
|
|
1133
|
-
} catch (e) {
|
|
1134
|
-
// What already happened travels with the failure, so a caller can
|
|
1135
|
-
// reconcile (the operation envelope carries it as error.details).
|
|
1136
|
-
e.partial = { actions };
|
|
1137
|
-
throw e;
|
|
1138
|
-
} finally {
|
|
1139
|
-
const synced = syncWakeReceive(meta);
|
|
1140
|
-
meta = synced.meta;
|
|
1141
|
-
for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`);
|
|
1142
|
-
writeProviderTeamsState(meta);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
const doc = { ...teamsDocument(meta), ...(actions.length ? { actions } : {}), ...(warnings.length ? { warnings } : {}) };
|
|
1146
|
-
if (operation) operationOk(doc);
|
|
1147
|
-
outputTeamsDocument(doc, args.json);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/** The primary identity's team comes only from the kernel's team-model v2
|
|
1151
|
-
* default env. Never fall back to the root's active team. */
|
|
1152
|
-
function resolvePrimaryTeam() {
|
|
1153
|
-
const warnings = [];
|
|
1154
|
-
const team = defaultTeamId();
|
|
1155
|
-
if (!team) fatal(teamConfigRemedy());
|
|
1156
|
-
const root = awebRootForTeam(team);
|
|
1157
|
-
if (!root) fatal(`${awebRootProblem(rootSettingCandidate(team))}, so default team ${defaultTeamLabel() || team} (${team}) could not mint this instance; run oats aweb setup or ask the team's owner for an invite`);
|
|
1158
|
-
return { team, root, from: defaultTeamFromEnv(), label: defaultTeamLabel(), warnings };
|
|
1159
|
-
}
|
|
1160
|
-
function appendProviderEvent(kind, data) {
|
|
1161
|
-
try {
|
|
1162
|
-
const row = { eventsApi: 2, at: new Date().toISOString(), instance, home, incarnation: null, producer: "oats.aweb", kind, data };
|
|
1163
|
-
appendFileSync(join(home, ".oats-events.jsonl"), JSON.stringify(row) + "\n");
|
|
1164
|
-
} catch { /* evidence only */ }
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
/** The home's runtime. Spawn and launch run in the session's own env; a
|
|
1168
|
-
* join/leave may be run by another agent whose OATS_RUNTIME is its own. */
|
|
1169
|
-
function instanceRuntime(meta = {}) {
|
|
1170
|
-
const own = ["spawn", "launch"].includes(event) ? process.env.OATS_RUNTIME : undefined;
|
|
1171
|
-
if (own) return own;
|
|
1172
|
-
if (meta.runtime) return meta.runtime;
|
|
1173
|
-
try { const r = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).runtime; if (r) return r; } catch { /* fall through */ }
|
|
1174
|
-
return process.env.OATS_RUNTIME || undefined;
|
|
1175
|
-
}
|
|
1176
|
-
function primaryIdentityHomeOf(meta = {}) {
|
|
1177
|
-
return meta.identity?.mode === "global" && meta.identity?.grant?.home ? meta.identity.grant.home : join(home, ".aw");
|
|
1178
|
-
}
|
|
1179
|
-
/** Keep the host wake broker's registration for this home in step with the
|
|
1180
|
-
* joined teams, and record each joined team's receive mode. Never throws: a
|
|
1181
|
-
* refused registration leaves that team poll-only with a warning. */
|
|
1182
|
-
function syncWakeReceive(meta) {
|
|
1183
|
-
// A joined home that no longer exists would make aw refuse the whole
|
|
1184
|
-
// registration; it is stale provider state, not a receive identity.
|
|
1185
|
-
const joined = joinedTeamsOf(meta).filter((j) => existsSync(j.identityHome));
|
|
1186
|
-
const delivery = meta.delivery || deliveryMode;
|
|
1187
|
-
const runtime = instanceRuntime(meta);
|
|
1188
|
-
const primary = primaryIdentityHomeOf(meta);
|
|
1189
|
-
const doc = wakeRegistration({ home, primaryIdentityHome: primary, delivery, runtime, joined, backend: process.env.OATS_BACKEND });
|
|
1190
|
-
const warnings = [];
|
|
1191
|
-
let receive = "poll", wakeJoined = false;
|
|
1192
|
-
if (doc) {
|
|
1193
|
-
try { run(["aw", "wake", "register", "--registration-json", "-"], home, 60000, { input: JSON.stringify(doc) }); receive = "native"; wakeJoined = true; }
|
|
1194
|
-
catch (e) { warnings.push(`joined teams stay poll-only: aw wake register refused the multi-identity registration (${e.message || e})`); }
|
|
1195
|
-
} else if (joined.length && !runtimeDeliveryFor({ delivery, runtime })) {
|
|
1196
|
-
warnings.push(`joined teams receive by polling: runtime ${runtime || "unknown"} has no native presentation surface for the host wake broker`);
|
|
1197
|
-
}
|
|
1198
|
-
if (!wakeJoined && meta.wakeJoined) {
|
|
1199
|
-
// Back to one identity: a session home keeps its legacy registration, a
|
|
1200
|
-
// native home leaves the broker entirely.
|
|
1201
|
-
if (delivery === "session") { try { wakeRegister(home, primary); } catch (e) { warnings.push(String(e.message || e)); } }
|
|
1202
|
-
else if (!wakeDeregister(home)) warnings.push("aw wake deregister failed; the broker treats a stale registration as inactive on its own");
|
|
1203
|
-
}
|
|
1204
|
-
const next = { ...meta, ...(runtime ? { runtime } : {}), wakeJoined, joinedTeams: joinedTeamsOf(meta).map((j) => ({ ...j, receive: joined.includes(j) ? receive : "poll" })) };
|
|
1205
|
-
return { meta: next, warnings };
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
if (event === "launch") {
|
|
1209
|
-
if (identityMode === "global" || grantRenewMode() === "launch") globalGrantRenew();
|
|
1210
|
-
let oldMeta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
|
|
1211
|
-
const joined = joinedTeamsOf(oldMeta);
|
|
1212
|
-
if (joined.length && process.env.OATS_TEAMS_SOURCE === "live") {
|
|
1213
|
-
const eligible = new Set(eligibleTeams().map((t) => t.label));
|
|
1214
|
-
const warnings = [];
|
|
1215
|
-
for (const row of joined) if (!eligible.has(row.label)) {
|
|
1216
|
-
try {
|
|
1217
|
-
oldMeta = leaveJoinedTeam(row.label, oldMeta).meta;
|
|
1218
|
-
const left = { label: row.label, team: row.team, at: new Date().toISOString(), reason: "no-longer-eligible" };
|
|
1219
|
-
oldMeta = { ...oldMeta, left: [...leftTeamsOf(oldMeta), left].slice(-20) };
|
|
1220
|
-
appendProviderEvent("aweb-team-left", left);
|
|
1221
|
-
warnings.push(`left joined team ${row.label} because it is no longer eligible`);
|
|
1222
|
-
}
|
|
1223
|
-
catch (e) {
|
|
1224
|
-
const failed = failedLeaveDisposition(row, e);
|
|
1225
|
-
appendProviderEvent("aweb-team-leave-failed", failed.data);
|
|
1226
|
-
warnings.push(failed.warning);
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
// Re-register what remains: the runtime may differ from the last session.
|
|
1230
|
-
const synced = syncWakeReceive(oldMeta);
|
|
1231
|
-
oldMeta = synced.meta;
|
|
1232
|
-
warnings.push(...synced.warnings);
|
|
1233
|
-
writeProviderTeamsState(oldMeta);
|
|
1234
|
-
out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta), ...(warnings.length ? { warning: `oats-aweb: ${warnings.join(" | ")}` } : {}) });
|
|
1235
|
-
}
|
|
1236
|
-
if (joined.length) {
|
|
1237
|
-
const synced = syncWakeReceive(oldMeta);
|
|
1238
|
-
oldMeta = synced.meta;
|
|
1239
|
-
writeProviderTeamsState(oldMeta);
|
|
1240
|
-
out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta), warning: ["oats-aweb: teams-unverified — keeping joined team memberships because live eligible teams are unavailable", ...synced.warnings.map((w) => `oats-aweb: ${w}`)].join(" | ") });
|
|
1241
|
-
}
|
|
1242
|
-
out(retainedLaunchOutput(oldMeta));
|
|
1243
|
-
} else if (event === "spawn") {
|
|
1244
|
-
if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
|
|
1245
|
-
if (identityMode === "global" && requestedJoinLabels().length) fatal('settings.oats.aweb.join is supported only with local per-team identities; identity.mode "global" is explicit resident-grant mode');
|
|
1246
|
-
if (identityMode === "global") globalGrantSpawn();
|
|
1247
|
-
if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
|
|
1248
|
-
let joinRows;
|
|
1249
|
-
try { joinRows = validateJoinLabels(requestedJoinLabels()); }
|
|
1250
|
-
catch (e) { fatal(e.message || e); }
|
|
1251
|
-
let minted; // external identity, once `aw team join` succeeds
|
|
1252
|
-
let spawnMeta; // with joined teams, once any is accepted
|
|
1253
|
-
try {
|
|
1254
|
-
// Team correctness: the kernel's default team id is the only source. ALWAYS
|
|
1255
|
-
// pass --team-id explicitly — never inherit whatever team happens to be
|
|
1256
|
-
// active at mint time — and verify the joined cert matches.
|
|
1257
|
-
// The instance name IS the discoverable alias (the team roster doubles as the
|
|
1258
|
-
// cross-machine instance directory).
|
|
1259
|
-
const primary = resolvePrimaryTeam();
|
|
1260
|
-
const { team, root } = primary;
|
|
1261
|
-
const warnings = [...primary.warnings];
|
|
1262
|
-
// Both of these carry the invite token — one mints it, the other spends it —
|
|
1263
|
-
// so neither their output nor their diagnostics may reach a log.
|
|
1264
|
-
const inv = parseSecretJson(run(["aw", "team", "invite", flagEq("--team-id", team), "--json"], root, 45000, { secretSafe: true }), "aw team invite");
|
|
1265
|
-
if (!inv?.token || typeof inv.token !== "string") fatal("aw team invite returned no usable token, so no identity could be minted");
|
|
1266
|
-
let raw;
|
|
1267
|
-
try {
|
|
1268
|
-
// 120 s: a join on a slow or flapping link is slow, not broken; a killed
|
|
1269
|
-
// join that completed server-side is caught below.
|
|
1270
|
-
raw = parseSecretJson(run(["aw", "team", "join", inv.token, flagEq("--name", instance), "--json"], home, JOIN_TIMEOUT_MS, { secrets: [inv.token], secretSafe: true }), "aw team join");
|
|
1271
|
-
} catch (e) {
|
|
1272
|
-
// The join may have completed after the CLI was killed or reported a
|
|
1273
|
-
// failure: if the home now holds a bound identity, that identity EXISTS
|
|
1274
|
-
// and must be reported so compensation retires it instead of orphaning it.
|
|
1275
|
-
if (joinedLate(home)) {
|
|
1276
|
-
const late = workspaceAliasOf(home);
|
|
1277
|
-
minted = { team, alias: late };
|
|
1278
|
-
fatal(`aw team join was reported failed (${e.message || e}) but the home now holds a bound identity "${late}" on ${team}; reported for compensation so it is retired, not orphaned`, minted);
|
|
1279
|
-
}
|
|
1280
|
-
// A retired alias keeps its certificate until aweb-abim ships, so a
|
|
1281
|
-
// re-spawn under the same name is refused by AWID. Say that, and the
|
|
1282
|
-
// remedy, instead of relaying a bare join error.
|
|
1283
|
-
if (e.aliasConflict) {
|
|
1284
|
-
fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — ${ALIAS_REUSE_REMEDY}`);
|
|
1285
|
-
}
|
|
1286
|
-
throw e;
|
|
1287
|
-
}
|
|
1288
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) fatal("aw team join returned no usable result, so no identity could be minted", minted);
|
|
1289
|
-
// The RESPONSE is not a safe place to take strings from. Suppressing the
|
|
1290
|
-
// failure paths does nothing if a successful reply is copied into meta and
|
|
1291
|
-
// the briefing verbatim: a response echoing the invite token back as the
|
|
1292
|
-
// alias would print it twice, on exit 0 (reviewer-a6aa1c5). Accept a field
|
|
1293
|
-
// only if it is a plausible value of its own kind and is not carrying the
|
|
1294
|
-
// token; otherwise fall back to what WE asked for, which is always known.
|
|
1295
|
-
const clean = (v) => (typeof v === "string" && v.trim() && !v.includes(inv.token) ? v.trim() : undefined);
|
|
1296
|
-
const joined = {
|
|
1297
|
-
alias: (() => { const a = clean(raw.alias); return a && AWEB_ALIAS_RE.test(a) ? a : instance; })(),
|
|
1298
|
-
// Team ids are "<name>:<domain>"; anything else is not one, and the
|
|
1299
|
-
// requested team is the honest fallback.
|
|
1300
|
-
team_id: (() => { const t = clean(raw.team_id); return t && /^[^\s:]+:[^\s:]+$/.test(t) ? t : team; })(),
|
|
1301
|
-
};
|
|
1302
|
-
// External state now exists. Record it immediately so any later failure can
|
|
1303
|
-
// still report it for compensation.
|
|
1304
|
-
minted = { team: joined.team_id, alias: joined.alias };
|
|
1305
|
-
run(["aw", "init", "--do-not-touch-agents-md"], home);
|
|
1306
|
-
const alias = joined.alias;
|
|
1307
|
-
const mismatch = joined.team_id !== team
|
|
1308
|
-
? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : "";
|
|
1309
|
-
// Runtime integration: for Claude Code sessions the aweb-channel plugin
|
|
1310
|
-
// carries real-time push events. This hook does NOT install it. The plugin
|
|
1311
|
-
// is a DECLARED runtime requirement (oats.json), consented once at
|
|
1312
|
-
// `oats install` and verified by the kernel before spawn — installing it here
|
|
1313
|
-
// would mutate the operator's Claude configuration without asking, inside a
|
|
1314
|
-
// spawn, which is exactly the silent host mutation the consent gate exists
|
|
1315
|
-
// to prevent. By the time this runs the kernel has already proven the plugin
|
|
1316
|
-
// is present and enabled, so contributing the flag is safe.
|
|
1317
|
-
// Session delivery: no channel flag, AWEB_DELIVERY=session in the launch
|
|
1318
|
-
// environment (declared in the manifest), and the truth about waking.
|
|
1319
|
-
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
1320
|
-
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
1321
|
-
: undefined;
|
|
1322
|
-
const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: join(home, ".aw") };
|
|
1323
|
-
const channelWarning = undefined;
|
|
1324
|
-
if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
|
|
1325
|
-
const deliveryBrief = deliveryMode === "session"
|
|
1326
|
-
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
1327
|
-
: "";
|
|
1328
|
-
let meta = { team: joined.team_id, alias, delivery: deliveryMode, defaultTeam: { label: primary.label, team: joined.team_id, from: primary.from }, left: [], ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
|
|
1329
|
-
for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; spawnMeta = meta; writeProviderTeamsState(meta); if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
|
|
1330
|
-
if (joinedTeamsOf(meta).length) { const synced = syncWakeReceive(meta); meta = synced.meta; for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`); }
|
|
1331
|
-
writeProviderTeamsState(meta);
|
|
1332
|
-
const defaultTeamBrief = meta.defaultTeam.from === "soul" ? "the default team configured for this soul" : "this deployment's default team";
|
|
1333
|
-
const joinedNow = joinedTeamsOf(meta);
|
|
1334
|
-
const joinedBrief = joinedNow.length ? ` Joined teams: ${joinedNow.map((j) => `${j.label} (${j.team}, receive ${j.receive}, send with \`aw --identity-home ${j.identityHome} mail|chat ...\`)`).join("; ")}.` : "";
|
|
1335
|
-
out({
|
|
1336
|
-
meta,
|
|
1337
|
-
env,
|
|
1338
|
-
brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}, ${defaultTeamBrief}.${mismatch}${deliveryBrief}${joinedBrief} Load the oats-aweb skill before messaging: \`oats aweb teams --json\` shows your teams, \`oats aweb roster\` who you can reach. Coordination stays in your deployment's task layer.`,
|
|
1339
|
-
...(launch ? { launch } : {}),
|
|
1340
|
-
...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : warnings.length ? { warning: warnings.join(" | ") } : channelWarning ? { warning: channelWarning } : {}),
|
|
1341
|
-
});
|
|
1342
|
-
} catch (e) {
|
|
1343
|
-
// A join may already have created a REMOTE identity before the failure.
|
|
1344
|
-
// Hand it back as meta so the kernel's compensation can delete it — losing
|
|
1345
|
-
// it here would strand a roster entry no one owns.
|
|
1346
|
-
fatal(`identity minting failed: ${e.message || e}`, minted && joinedTeamsOf(spawnMeta).length ? { ...minted, joinedTeams: joinedTeamsOf(spawnMeta) } : minted);
|
|
1347
|
-
}
|
|
1348
|
-
} else if (event === "retire") {
|
|
1349
|
-
let meta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
|
|
1350
|
-
const retireWarnings = [];
|
|
1351
|
-
const pendingControllerCleanup = [];
|
|
1352
|
-
const rememberControllerCleanup = (data) => {
|
|
1353
|
-
if (data?.cleanup === "controller") pendingControllerCleanup.push({ label: data.label, team: data.team, alias: data.alias, certificateId: data.certificateId, ...(data.command ? { command: data.command } : {}) });
|
|
1354
|
-
};
|
|
1355
|
-
const retiredMeta = (fields = {}) => ({ ...fields, ...(pendingControllerCleanup.length ? { pendingControllerCleanup } : {}) });
|
|
1356
|
-
if (hasStaleTeamSetting) retireWarnings.push(TEAM_SETTING_MESSAGE);
|
|
1357
|
-
// A retained seat: release the lock and leave the identity alone. Never
|
|
1358
|
-
// aw workspace delete (it would soft-delete the standing identity's row)
|
|
1359
|
-
// and never team retire; the source .aw stays until a human removes it.
|
|
1360
|
-
if (meta.delivery === "session" && (meta.retained || meta.identity?.mode !== "global")) { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
|
|
1361
|
-
if (meta.identity?.mode === "global" && !meta.retained) globalGrantRetire(meta);
|
|
1362
|
-
for (const joined of joinedTeamsOf(meta)) {
|
|
1363
|
-
try { meta = leaveJoinedTeam(joined.label, meta).meta; }
|
|
1364
|
-
catch (e) { const failed = failedLeaveDisposition(joined, e); retireWarnings.push(failed.warning); rememberControllerCleanup(failed.data); }
|
|
1365
|
-
}
|
|
1366
|
-
// A native (channel/pi) home registered with the broker only for its joined
|
|
1367
|
-
// teams; a session home was deregistered above.
|
|
1368
|
-
if (meta.wakeJoined && meta.delivery !== "session") { if (!wakeDeregister(home)) retireWarnings.push("aw wake deregister failed; the broker treats a retired home as inactive on its own"); }
|
|
1369
|
-
meta = { ...meta, wakeJoined: false };
|
|
1370
|
-
writeProviderTeamsState(meta);
|
|
1371
|
-
if (meta.retained) {
|
|
1372
|
-
if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
|
|
1373
|
-
const retainedWarning = `released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}`;
|
|
1374
|
-
out({ meta: retiredMeta({ retired: true, retained: true, identityReleased: true, joinedTeams: joinedTeamsOf(meta), ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }), warning: `oats-aweb: ${[...retireWarnings, retainedWarning].join(" | ")}` });
|
|
1375
|
-
}
|
|
1376
|
-
// No alias means the spawn hook never reported an identity: nothing exists to
|
|
1377
|
-
// undo, which is completion. An alias WITH no local `.aw` is the opposite —
|
|
1378
|
-
// the remote record exists and its key is gone, so the self-delete cannot be
|
|
1379
|
-
// authenticated and the cleanup is incomplete, not vacuous (reviewer-602627c).
|
|
1380
|
-
// A home whose spawn hook could not report its alias (a join killed on
|
|
1381
|
-
// timeout that completed anyway) still carries the alias in its workspace
|
|
1382
|
-
// binding: use it rather than leaving the workspace orphaned.
|
|
1383
|
-
if (!meta.alias) { const late = workspaceAliasOf(home); if (late) meta = { ...meta, alias: late, aliasFromHome: true }; }
|
|
1384
|
-
if (!meta.alias) out({ meta: retiredMeta({ retired: false, reason: "nothing-to-delete" }) });
|
|
1385
|
-
if (!existsSync(join(home, ".aw"))) {
|
|
1386
|
-
out({ meta: retiredMeta({ retired: false, reason: "no-local-identity-key" }), warning: `oats-aweb: alias "${meta.alias}" was minted but ${join(home, ".aw")} is gone, so the remote record cannot be self-deleted and will linger until stale` }, 1);
|
|
1387
|
-
}
|
|
1388
|
-
try {
|
|
1389
|
-
// Self-delete from inside the home, authenticated by its own key — a remote
|
|
1390
|
-
// delete would 409 until the server marks the workspace stale. aw >= 1.36.13
|
|
1391
|
-
// reports whether the certificate was revoked and the alias was released.
|
|
1392
|
-
const raw = run(["aw", "workspace", "delete", meta.alias, "--json"], home);
|
|
1393
|
-
let doc; try { doc = JSON.parse(raw); } catch { doc = undefined; }
|
|
1394
|
-
const released = doc?.alias_released === true;
|
|
1395
|
-
const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
|
|
1396
|
-
if (!released && /team_not_hosted/i.test(reason)) {
|
|
1397
|
-
const failed = failedLeaveDisposition({ label: "default", team: meta.team || meta.defaultTeam?.team || meta.identity?.team || defaultTeamId(), alias: meta.alias, identityHome: join(home, ".aw") }, reason);
|
|
1398
|
-
retireWarnings.push(failed.warning.replace(/^joined team default cleanup failed:/, "default identity cleanup failed:"));
|
|
1399
|
-
rememberControllerCleanup({ ...failed.data, label: "default" });
|
|
1400
|
-
}
|
|
1401
|
-
out({ meta: retiredMeta({ retired: true, aliasReusable: released, aliasReason: reason, joinedTeams: joinedTeamsOf(meta) }), ...(retireWarnings.length ? { warning: `oats-aweb: ${retireWarnings.join(" | ")}` } : released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a different --name (kernels 0.26.0+) or a different --purpose until it is` }) });
|
|
1402
|
-
} catch (e) {
|
|
1403
|
-
// Exit nonzero: during a required-hook rollback this is the signal that
|
|
1404
|
-
// compensation did NOT complete, so the spawn is not reported as cleanly
|
|
1405
|
-
// rolled back while a remote identity still exists.
|
|
1406
|
-
out({ meta: retiredMeta({ retired: false, reason: "self-delete-failed" }), warning: `oats-aweb: self-delete failed (the remote record will linger until stale): ${e.message || e}` }, 1);
|
|
1407
|
-
}
|
|
1408
|
-
} else if (["teams", "join", "leave"].includes(event)) {
|
|
1409
|
-
try { runTeamsCommand(event); process.exit(0); }
|
|
1410
|
-
catch (e) {
|
|
1411
|
-
console.error(e.code ? `${e.code}: ${e.message}` : `oats aweb ${event}: ${e.message || e}`);
|
|
1412
|
-
if (operation) operationFail(e.code, e.message || String(e), e.partial ? { ...e.partial, joined: teamsDocument(readCapabilityMeta()).joined } : undefined);
|
|
1413
|
-
process.exit(1);
|
|
1414
|
-
}
|
|
1415
|
-
} else if (event === "roster") {
|
|
1416
|
-
// Cross-machine directory: every OATS-spawned instance joins the team with
|
|
1417
|
-
// alias = instance name, so the team's member roster lists live instances
|
|
1418
|
-
// wherever they run (plus human members). Local liveness comes from
|
|
1419
|
-
// `oats status` in the deployment; this is the network view.
|
|
1420
|
-
// Default: this instance's default team, listed from the root that minted
|
|
1421
|
-
// it. `--label <label>` lists an eligible (joined or not) workspace team from
|
|
1422
|
-
// the host root that holds it.
|
|
1423
|
-
const argv = stripForwardedSoul(process.argv.slice(3));
|
|
1424
|
-
const labelAt = argv.indexOf("--label");
|
|
1425
|
-
const label = labelAt >= 0 ? argv[labelAt + 1] : (argv.find((a) => a.startsWith("--label=")) || "").slice("--label=".length) || undefined;
|
|
1426
|
-
const meta = readCapabilityMeta();
|
|
1427
|
-
let team, root;
|
|
1428
|
-
if (label) {
|
|
1429
|
-
const row = eligibleTeams().find((t) => t.label === label);
|
|
1430
|
-
if (!row) { console.error(`E_TEAM_NOT_ELIGIBLE: ${label} is not an eligible team label for this instance (eligible: ${eligibleTeams().map((t) => t.label).join(", ") || "(none)"})`); process.exit(1); }
|
|
1431
|
-
team = row.team; root = awebRootForTeam(team);
|
|
1432
|
-
if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}, so the roster of ${label} cannot be read from this host`); process.exit(1); }
|
|
1433
|
-
} else {
|
|
1434
|
-
team = meta.defaultTeam?.team || meta.identity?.team || meta.team || defaultTeamId();
|
|
1435
|
-
root = awebRootForTeam(team);
|
|
1436
|
-
if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}`); process.exit(1); }
|
|
1437
|
-
}
|
|
1438
|
-
if (!team) { console.error(`oats aweb roster: ${teamConfigRemedy()}`); process.exit(1); }
|
|
1439
|
-
const teamFlag = [flagEq(team.includes(":") ? "--team-id" : "--team", team)];
|
|
1440
|
-
const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
|
|
1441
|
-
if (argv.includes("--json")) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
|
|
1442
|
-
console.log(`aweb team ${r.team_id || team} — member roster (cross-machine):`);
|
|
1443
|
-
const members = r.members || [];
|
|
1444
|
-
if (!members.length) console.log(" (no member certificates visible from this workspace)");
|
|
1445
|
-
for (const m of members) console.log(` ${m.alias || m.name || m.did || JSON.stringify(m)}`);
|
|
1446
|
-
console.log(`\nAliases minted by OATS are instance names; message one with \`aw mail send --to <alias> --subject "..." --body-file <file>\`${label ? ` as this team: \`aw --identity-home <identityHome> mail send ...\` (identityHome from \`oats aweb teams --json\`)` : ""}.`);
|
|
1447
|
-
process.exit(0);
|
|
1448
|
-
} else if (event === "setup") {
|
|
1449
|
-
// Guided onboarding — idempotent, prints what it finds and can run one
|
|
1450
|
-
// existing aw primitive when the operator supplies the needed authority.
|
|
1451
|
-
const args = stripForwardedSoul(process.argv.slice(3)).filter((arg) => arg !== "--json");
|
|
1452
|
-
const usage = "usage: oats aweb setup [--username <hosted-user> | --create <label> [--namespace <domain>] | --join <label> [--invite <token>] [--service <url>] [--name <alias>]]";
|
|
1453
|
-
let username, invite, createLabel, createNamespace, joinLabel, joinService, joinName;
|
|
1454
|
-
for (let i = 0; i < args.length; i++) {
|
|
1455
|
-
const arg = args[i];
|
|
1456
|
-
if (arg === "--username" && args[i + 1]) { username = args[++i]; continue; }
|
|
1457
|
-
if (arg.startsWith("--username=") && arg.length > "--username=".length) { username = arg.slice("--username=".length); continue; }
|
|
1458
|
-
if (arg === "--invite" && args[i + 1]) { invite = args[++i]; continue; }
|
|
1459
|
-
if (arg.startsWith("--invite=") && arg.length > "--invite=".length) { invite = arg.slice("--invite=".length); continue; }
|
|
1460
|
-
if (arg === "--join" && args[i + 1]) { joinLabel = args[++i]; continue; }
|
|
1461
|
-
if (arg.startsWith("--join=") && arg.length > "--join=".length) { joinLabel = arg.slice("--join=".length); continue; }
|
|
1462
|
-
if (arg === "--create" && args[i + 1]) { createLabel = args[++i]; continue; }
|
|
1463
|
-
if (arg.startsWith("--create=") && arg.length > "--create=".length) { createLabel = arg.slice("--create=".length); continue; }
|
|
1464
|
-
if (arg === "--namespace" && args[i + 1]) { createNamespace = args[++i]; continue; }
|
|
1465
|
-
if (arg.startsWith("--namespace=") && arg.length > "--namespace=".length) { createNamespace = arg.slice("--namespace=".length); continue; }
|
|
1466
|
-
if (arg === "--service" && args[i + 1]) { joinService = args[++i]; continue; }
|
|
1467
|
-
if (arg.startsWith("--service=") && arg.length > "--service=".length) { joinService = arg.slice("--service=".length); continue; }
|
|
1468
|
-
if (arg === "--name" && args[i + 1]) { joinName = args[++i]; continue; }
|
|
1469
|
-
if (arg.startsWith("--name=") && arg.length > "--name=".length) { joinName = arg.slice("--name=".length); continue; }
|
|
1470
|
-
console.error(`oats aweb setup: ${usage}`);
|
|
1471
|
-
process.exit(2);
|
|
1472
|
-
}
|
|
1473
|
-
if (createNamespace && !createLabel) { console.error(`oats aweb setup: --namespace requires --create\n${usage}`); process.exit(2); }
|
|
1474
|
-
if (invite && !joinLabel) { console.error(`oats aweb setup: --invite requires --join <label> so the team gets its own root\n${usage}`); process.exit(2); }
|
|
1475
|
-
if ((joinService || joinName) && !joinLabel) { console.error(`oats aweb setup: --service/--name require --join <label>\n${usage}`); process.exit(2); }
|
|
1476
|
-
const apiKey = !!process.env.AWEB_API_KEY;
|
|
1477
|
-
const actions = [username ? "--username" : null, joinLabel ? "--join" : null, createLabel ? "--create" : null, apiKey && !createLabel ? "AWEB_API_KEY" : null].filter(Boolean);
|
|
1478
|
-
if (actions.length > 1) { console.error(`oats aweb setup: choose exactly one onboarding authority (${actions.join(", ")})\n${usage}`); process.exit(2); }
|
|
1479
|
-
|
|
1480
|
-
const teamName = defaultTeamId();
|
|
1481
|
-
const teamId = defaultTeamId();
|
|
1482
|
-
const candidate = rootSettingCandidate(teamName);
|
|
1483
|
-
const scope = candidate?.root ? resolve(candidate.root) : process.cwd();
|
|
1484
|
-
console.log(`aweb onboarding — messaging root: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
|
|
1485
|
-
if (!isAbsolute(scope)) {
|
|
1486
|
-
console.log(`${candidate?.key || "settings.oats.aweb.root"} must be an absolute directory whose .aw is the aweb minting root.`);
|
|
1487
|
-
process.exit(0);
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
const want = teamId || teamName;
|
|
1491
|
-
const defaultTeamForUsername = username ? `default:${username}.aweb.ai` : undefined;
|
|
1492
|
-
const readTeamsAt = (rootDir) => {
|
|
1493
|
-
try { return parseAwJson(run(["aw", "team", "list", "--json"], rootDir), "aw team list"); }
|
|
1494
|
-
catch { return { memberships: [] }; }
|
|
1495
|
-
};
|
|
1496
|
-
const readTeams = () => readTeamsAt(scope);
|
|
1497
|
-
const matchingTeam = (teams) => want ? teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`)) : undefined;
|
|
1498
|
-
const rootAlias = () => {
|
|
1499
|
-
try { const who = JSON.parse(run(["aw", "whoami", "--json"], scope)); return who.alias || who.name; } catch { return undefined; }
|
|
1500
|
-
};
|
|
1501
|
-
const setupAlias = () => {
|
|
1502
|
-
const alias = joinName || rootAlias();
|
|
1503
|
-
if (!alias || !AWEB_ALIAS_RE.test(alias)) throw new Error(`--name <alias> is required when no root identity is available; aliases must match the aweb 1-64 character rule`);
|
|
1504
|
-
return alias;
|
|
1505
|
-
};
|
|
1506
|
-
const setupResumeCommand = (label) => `oats aweb setup --soul ${setupSoulArg()} --join ${label} --service <url>`;
|
|
1507
|
-
const setupServiceDocs = (teamRoot) => {
|
|
1508
|
-
const docs = [];
|
|
1509
|
-
if (joinService) docs.push({ aweb_url: joinService });
|
|
1510
|
-
if (process.env.AWEB_URL) docs.push({ aweb_url: process.env.AWEB_URL });
|
|
1511
|
-
const addRoot = (rootDir) => {
|
|
1512
|
-
if (!rootDir || resolve(rootDir) === resolve(teamRoot || "")) return;
|
|
1513
|
-
try { const url = yamlScalar(readFileSync(join(resolve(rootDir), ".aw", "workspace.yaml"), "utf8"), "aweb_url"); if (url) docs.push({ aweb_url: url }); } catch { /* no service there */ }
|
|
1514
|
-
};
|
|
1515
|
-
const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
|
|
1516
|
-
for (const rootDir of Object.values(roots)) if (typeof rootDir === "string") addRoot(rootDir);
|
|
1517
|
-
if (typeof settings.root === "string") addRoot(settings.root);
|
|
1518
|
-
addRoot(scope);
|
|
1519
|
-
return docs;
|
|
1520
|
-
};
|
|
1521
|
-
const hostedCreateUnavailable = () => "creating an additional hosted team needs hosted team creation (aweb-abkh), not yet released in aw or aweb Cloud; use --namespace <domain> for a team you control, or ask the aweb team";
|
|
1522
|
-
const candidateTeamId = (name) => `${name}:${createNamespace}`;
|
|
1523
|
-
const teamExistsError = (e) => e?.status === 409 || /\b409\b|\bconflict\b|\balready exists\b|\bexists\b/i.test(commandOutput(e));
|
|
1524
|
-
const configuredTeamForLabel = (label) => parseOatsTeams().find((t) => t.label === label && t.team)?.team || (label === defaultTeamLabel() ? defaultTeamId() : undefined);
|
|
1525
|
-
const acceptIntoTeamRoot = (label, token, expectedTeam, ...serviceDocs) => {
|
|
1526
|
-
const teamRoot = perTeamRoot(process.env.OATS_WORKSPACE || scope, label);
|
|
1527
|
-
const idHome = join(teamRoot, ".aw");
|
|
1528
|
-
const resumeCommand = setupResumeCommand(label);
|
|
1529
|
-
const docs = [...setupServiceDocs(teamRoot), ...serviceDocs];
|
|
1530
|
-
const recordTeam = expectedTeam || configuredTeamForLabel(label) || `<team for ${label}>`;
|
|
1531
|
-
assertAwebRootSettingRecordable(recordTeam, teamRoot, { start: process.env.OATS_WORKSPACE || scope });
|
|
1532
|
-
let joined;
|
|
1533
|
-
if (existsSync(join(idHome, "identity.yaml"))) {
|
|
1534
|
-
if (existsSync(join(idHome, "workspace.yaml"))) throw new Error(`team root ${teamRoot} already holds a connected aweb identity; choose a different label or remove the stale root deliberately`);
|
|
1535
|
-
joined = connectExistingJoinedTeam({ label, identityHome: idHome, expectedTeam: expectedTeam || configuredTeamForLabel(label), root: scope, cwd: teamRoot, serviceDocs: docs, resumeCommand });
|
|
1536
|
-
} else {
|
|
1537
|
-
if (!token || typeof token !== "string") throw new Error(`--join ${label} needs --invite <token> unless ${teamRoot} already holds an accepted unconnected identity to resume`);
|
|
1538
|
-
mkdirSync(teamRoot, { recursive: true });
|
|
1539
|
-
try {
|
|
1540
|
-
({ joined } = acceptConnectVerifyJoinedTeam({ label, token, identityHome: idHome, alias: setupAlias(), expectedTeam: expectedTeam || configuredTeamForLabel(label), root: scope, cwd: teamRoot, serviceDocs: docs, cleanupOnFailure: false, resumeCommand, useAcceptedService: false }));
|
|
1541
|
-
} catch (e) {
|
|
1542
|
-
if (!existsSync(idHome)) { try { rmSync(teamRoot, { recursive: true, force: true }); } catch { /* best effort after failure before identity creation */ } }
|
|
1543
|
-
throw e;
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
recordAwebRootSetting(joined.team, teamRoot, { start: process.env.OATS_WORKSPACE || scope });
|
|
1547
|
-
return { team: joined.team, teamRoot };
|
|
1548
|
-
};
|
|
1549
|
-
const createTeam = (label) => {
|
|
1550
|
-
if (!createNamespace) throw new Error(hostedCreateUnavailable());
|
|
1551
|
-
const base = normalizeAwebTeamName(label);
|
|
1552
|
-
for (let n = 1; n <= 20; n++) {
|
|
1553
|
-
const name = n === 1 ? base : `${base}-${n}`;
|
|
1554
|
-
const expectedTeam = candidateTeamId(name);
|
|
1555
|
-
const createArgs = ["aw", "id", "team", "create", flagEq("--name", name), flagEq("--namespace", createNamespace), "--json"];
|
|
1556
|
-
try {
|
|
1557
|
-
const created = parseSecretJson(run(createArgs, scope, 120000, { secretSafe: true }), "aw id team create");
|
|
1558
|
-
if (!created?.team_id || typeof created.team_id !== "string") throw new Error("aw id team create returned no team_id");
|
|
1559
|
-
const team = created.team_id;
|
|
1560
|
-
const token = created.invite_token || created.invite || created.token;
|
|
1561
|
-
if (!token || typeof token !== "string") throw new Error("aw id team create returned no invite token");
|
|
1562
|
-
return acceptIntoTeamRoot(label, token, team, created);
|
|
1563
|
-
} catch (e) {
|
|
1564
|
-
if (!teamExistsError(e)) throw e;
|
|
1565
|
-
const teams = readTeams();
|
|
1566
|
-
if (teamIdsOf(teams).some((tid) => String(tid) === expectedTeam)) { recordAwebRootSetting(expectedTeam, scope, { start: process.env.OATS_WORKSPACE || scope }); return { team: expectedTeam, teamRoot: scope, reused: true }; }
|
|
1567
|
-
if (n === 20) throw new Error(`could not create a unique aweb team for ${JSON.stringify(label)} after suffixing through -20`);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
};
|
|
1571
|
-
const recordLocalTeam = (label, team) => {
|
|
1572
|
-
const cli = process.env.OATS_CLI_BIN;
|
|
1573
|
-
if (!cli) throw new Error("OATS_CLI_BIN is required to record a new local team");
|
|
1574
|
-
assertNotFlag(label, "team label");
|
|
1575
|
-
run([cli, "teams", "add", label, flagEq("--team", team)], process.cwd(), 60000);
|
|
1576
|
-
};
|
|
1577
|
-
const setupSoulArg = () => forwardedSoulArg || "<soul>";
|
|
1578
|
-
const missingSharedRows = () => parseOatsTeams().filter((t) => {
|
|
1579
|
-
if (t.from !== "shared" || !t.label || !t.team) return false;
|
|
1580
|
-
const teamRoot = awebRootForTeam(t.team);
|
|
1581
|
-
if (!teamRoot) return true;
|
|
1582
|
-
const ids = new Set(teamIdsOf(readTeamsAt(teamRoot)).map(String));
|
|
1583
|
-
return !ids.has(t.team);
|
|
1584
|
-
});
|
|
1585
|
-
const printSharedMissing = (rows) => {
|
|
1586
|
-
for (const row of rows) console.log(`team ${row.label} (${row.team}) is shared: ask its owner for an invite, then run \`oats aweb setup --soul ${setupSoulArg()} --join ${row.label} --invite <token>\``);
|
|
1587
|
-
};
|
|
1588
|
-
const printVerdict = (teams) => {
|
|
1589
|
-
const match = matchingTeam(teams);
|
|
1590
|
-
if (match) {
|
|
1591
|
-
console.log(`readiness: ready`);
|
|
1592
|
-
console.log(`✓ aweb workspace initialized and member of ${match}.`);
|
|
1593
|
-
if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
|
|
1594
|
-
console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
|
|
1595
|
-
console.log(" Roster: `oats aweb roster` · local: `oats status` (in the deployment)");
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
console.log(`readiness: needs-configuration`);
|
|
1599
|
-
if (!want) {
|
|
1600
|
-
console.log(` no team: ${teamConfigRemedy()}`);
|
|
1601
|
-
const active = teams.active_team || teamIdsOf(teams)[0];
|
|
1602
|
-
if (active) console.log(` This root is a member of ${active}; record it with \`oats teams add <label> --team ${active}\` and \`oats teams default <label>\`.`);
|
|
1603
|
-
else if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; map the workspace team to that id if this is the intended team.`);
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
console.log(` Workspace initialized, but no membership matching "${want}".`);
|
|
1607
|
-
if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; record it with \`oats teams add <label> --team ${defaultTeamForUsername}\` and \`oats teams default <label>\`, then re-run setup.`);
|
|
1608
|
-
console.log(" Existing team path: ask a member for an invite token, then run `oats aweb setup --invite <token>` (uses `aw team join <token>` at the root).");
|
|
1609
|
-
console.log(" Team API-key path: set AWEB_API_KEY in the environment and run `oats aweb setup` (uses `aw init` at the root; the key is never printed).");
|
|
1610
|
-
console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --new-account --username <u>` and creates default:<u>.aweb.ai).");
|
|
1611
|
-
};
|
|
1612
|
-
|
|
1613
|
-
try {
|
|
1614
|
-
const hasRoot = existsSync(join(scope, ".aw"));
|
|
1615
|
-
let teams = hasRoot ? readTeams() : { memberships: [] };
|
|
1616
|
-
const unmappedDefault = !actions.length && defaultTeamLabel() && !defaultTeamId();
|
|
1617
|
-
if (unmappedDefault) {
|
|
1618
|
-
console.log(`team ${defaultTeamLabel()} has no provider id yet: ask its owner to run \`oats aweb setup --create ${defaultTeamLabel()} --namespace <domain>\` and commit the id, or ask the owner for an invite and run \`oats aweb setup --join ${defaultTeamLabel()} --invite <token>\`.`);
|
|
1619
|
-
process.exit(1);
|
|
1620
|
-
}
|
|
1621
|
-
const missingShared = !actions.length ? missingSharedRows() : [];
|
|
1622
|
-
if (missingShared.length) { printSharedMissing(missingShared); process.exit(1); }
|
|
1623
|
-
if (!hasRoot && !actions.length) {
|
|
1624
|
-
console.log(`No aweb workspace at the messaging root yet (${candidate?.key || "settings.oats.aweb.root"}).`);
|
|
1625
|
-
if (!want) console.log(` Also choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
|
|
1626
|
-
console.log(" Choose one guided setup path:");
|
|
1627
|
-
console.log(" oats aweb setup --username <u> # runs `aw init --new-account --username <u>` and creates default:<u>.aweb.ai");
|
|
1628
|
-
console.log(" AWEB_API_KEY=<key> oats aweb setup # runs `aw init` for the hosted team behind the key");
|
|
1629
|
-
console.log(" oats aweb setup --join <label> --invite <token> # accepts into a new per-team root");
|
|
1630
|
-
console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run setup.");
|
|
1631
|
-
console.log(" Own your domain? Use the aweb-team-membership skill for BYOT flows.");
|
|
1632
|
-
process.exit(0);
|
|
1633
|
-
}
|
|
1634
|
-
if (createLabel) {
|
|
1635
|
-
const label = createLabel;
|
|
1636
|
-
if (!createNamespace) throw new Error(hostedCreateUnavailable());
|
|
1637
|
-
mkdirSync(scope, { recursive: true });
|
|
1638
|
-
console.log(`Creating aweb team ${label} in namespace ${createNamespace} for a new per-team root.`);
|
|
1639
|
-
const created = createTeam(label);
|
|
1640
|
-
teams = readTeams();
|
|
1641
|
-
recordLocalTeam(label, created.team);
|
|
1642
|
-
console.log(`✓ created ${created.team}, ${created.reused ? `reused existing member root ${created.teamRoot}` : `accepted it into ${created.teamRoot}`}, recorded roots[${created.team}], and recorded local team ${label} with \`oats teams add ${label} --team ${created.team}\`.`);
|
|
1643
|
-
printVerdict(teams);
|
|
1644
|
-
process.exit(0);
|
|
1645
|
-
}
|
|
1646
|
-
if (joinLabel) {
|
|
1647
|
-
console.log(invite ? `Accepting invite for ${joinLabel} into a new per-team root (token withheld).` : `Resuming join for ${joinLabel} in its per-team root.`);
|
|
1648
|
-
const accepted = acceptIntoTeamRoot(joinLabel, invite, undefined);
|
|
1649
|
-
console.log(`✓ joined ${accepted.team} as ${joinLabel} in ${accepted.teamRoot}; recorded settings.oats.aweb.roots[${accepted.team}].`);
|
|
1650
|
-
process.exit(0);
|
|
1651
|
-
}
|
|
1652
|
-
if (hasRoot && !matchingTeam(teams) && !actions.length) { printVerdict(teams); process.exit(0); }
|
|
1653
|
-
if (!hasRoot || !matchingTeam(teams)) {
|
|
1654
|
-
if (actions.length) mkdirSync(scope, { recursive: true });
|
|
1655
|
-
if (username) {
|
|
1656
|
-
console.log(`Running aw init --new-account --username <u> at ${scope} (username withheld from repeated logs).`);
|
|
1657
|
-
run(["aw", "init", "--new-account", "--username", username], scope, 120000, { secrets: [username], unsetEnv: ["AWEB_API_KEY"] });
|
|
1658
|
-
} else if (apiKey) {
|
|
1659
|
-
console.log(`Running aw init at ${scope} with AWEB_API_KEY from the environment (key withheld).`);
|
|
1660
|
-
run(["aw", "init"], scope, 120000, { secretSafe: true });
|
|
1661
|
-
}
|
|
1662
|
-
teams = readTeams();
|
|
1663
|
-
}
|
|
1664
|
-
printVerdict(teams);
|
|
1665
|
-
process.exit(0);
|
|
1666
|
-
} catch (e) {
|
|
1667
|
-
console.error(`oats aweb setup: ${e.message || e}`);
|
|
1668
|
-
process.exit(1);
|
|
1669
|
-
}
|
|
1670
|
-
} else {
|
|
1671
|
-
warn(`unknown event "${event}" (expected spawn|retire)`);
|
|
1672
|
-
}
|