@cohortapp/agent-sdk 2.11.15 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model.test.mjs — model/provider setup section (order 30) tests.
|
|
3
|
+
*
|
|
4
|
+
* The oauth-token mode is the fleet DEFAULT (§3.9): the operator pastes the
|
|
5
|
+
* output of `claude setup-token`, headless reads CLAUDE_CODE_OAUTH_TOKEN from
|
|
6
|
+
* the environment, and .env ends up 0600 with the token + the prefer flag.
|
|
7
|
+
*
|
|
8
|
+
* Run: node --test lib/setup/sections/model.test.mjs
|
|
9
|
+
*/
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, readFileSync, writeFileSync, statSync, existsSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import model, { AUTH_MODES, DEFAULT_AUTH_MODE } from "./model.mjs";
|
|
20
|
+
import * as io from "../io.mjs";
|
|
21
|
+
|
|
22
|
+
const MAESTRO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function tmpRoot(envBody) {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-modelsec-"));
|
|
26
|
+
if (envBody !== undefined) writeFileSync(join(root, ".env"), envBody);
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
function ctxFor(root, answers = {}, extra = {}) {
|
|
30
|
+
return { agentRoot: root, maestroRoot: MAESTRO_ROOT, flags: { headless: true }, headless: true, answers, io, ...extra };
|
|
31
|
+
}
|
|
32
|
+
function readEnv(root) { return readFileSync(join(root, ".env"), "utf-8"); }
|
|
33
|
+
function withEnv(patch, fn) {
|
|
34
|
+
const saved = {};
|
|
35
|
+
for (const k of Object.keys(patch)) { saved[k] = process.env[k]; if (patch[k] === undefined) delete process.env[k]; else process.env[k] = patch[k]; }
|
|
36
|
+
return Promise.resolve().then(fn).finally(() => {
|
|
37
|
+
for (const k of Object.keys(patch)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("section shape: id/order=30/hooks; oauth-token is the default of three modes", () => {
|
|
42
|
+
assert.equal(model.id, "model");
|
|
43
|
+
assert.equal(model.order, 30);
|
|
44
|
+
for (const h of ["detect", "prompt", "apply", "verify"]) assert.equal(typeof model[h], "function");
|
|
45
|
+
assert.deepEqual([...AUTH_MODES], ["oauth-token", "subscription", "api-key"]);
|
|
46
|
+
assert.equal(DEFAULT_AUTH_MODE, "oauth-token");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("headless with no answers: provider defaults to oauth-token and the token is read from CLAUDE_CODE_OAUTH_TOKEN", async () => {
|
|
50
|
+
const root = tmpRoot("");
|
|
51
|
+
try {
|
|
52
|
+
await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fromenv" }, async () => {
|
|
53
|
+
const ctx = ctxFor(root);
|
|
54
|
+
await model.prompt(ctx);
|
|
55
|
+
assert.equal(ctx.answers.model.provider, "oauth-token");
|
|
56
|
+
assert.equal(ctx.answers.model.hasToken, true);
|
|
57
|
+
assert.equal(ctx.answers.claudeOauthToken, "sk-ant-oat01-fromenv", "the value lives under the key the checkpoint redacts, never under answers.model");
|
|
58
|
+
await model.apply(ctx);
|
|
59
|
+
});
|
|
60
|
+
const env = readEnv(root);
|
|
61
|
+
assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-fromenv$/m);
|
|
62
|
+
assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
|
|
63
|
+
assert.doesNotMatch(env, /^ANTHROPIC_API_KEY=/m);
|
|
64
|
+
assert.equal(statSync(join(root, ".env")).mode & 0o777, 0o600, ".env is chmod 600 after apply");
|
|
65
|
+
const d = await model.detect(ctxFor(root));
|
|
66
|
+
assert.equal(d.status, "complete");
|
|
67
|
+
assert.match(d.summary, /OAuth token/i);
|
|
68
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the prompt copy tells the operator to run `claude setup-token` on any machine and paste the result", async () => {
|
|
72
|
+
const root = tmpRoot("");
|
|
73
|
+
try {
|
|
74
|
+
const seen = [];
|
|
75
|
+
const spyIo = {
|
|
76
|
+
...io,
|
|
77
|
+
select: async (_ctx, opts) => { seen.push(opts); return opts.default; },
|
|
78
|
+
secret: async (_ctx, opts) => { seen.push(opts); return "pasted-token"; },
|
|
79
|
+
confirm: async (_ctx, opts) => { seen.push(opts); return false; },
|
|
80
|
+
};
|
|
81
|
+
const ctx = ctxFor(root, {}, { io: spyIo });
|
|
82
|
+
await model.prompt(ctx);
|
|
83
|
+
const select = seen.find((o) => o.key === "provider");
|
|
84
|
+
assert.ok(select, "the provider select ran");
|
|
85
|
+
assert.equal(select.default, "oauth-token");
|
|
86
|
+
const tokenChoice = select.choices.find((c) => c.value === "oauth-token");
|
|
87
|
+
assert.match(tokenChoice.label, /claude setup-token/);
|
|
88
|
+
const secret = seen.find((o) => o.key === "claudeOauthToken");
|
|
89
|
+
assert.ok(secret, "the token secret prompt ran");
|
|
90
|
+
assert.match(secret.message, /claude setup-token/);
|
|
91
|
+
assert.match(secret.message, /any machine/i);
|
|
92
|
+
assert.match(secret.message, /paste/i);
|
|
93
|
+
assert.equal(secret.env, "CLAUDE_CODE_OAUTH_TOKEN");
|
|
94
|
+
assert.equal(ctx.answers.claudeOauthToken, "pasted-token");
|
|
95
|
+
assert.equal(ctx.answers.model.hasToken, true);
|
|
96
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("apply is idempotent and never clobbers an existing token", async () => {
|
|
100
|
+
const root = tmpRoot("CLAUDE_CODE_OAUTH_TOKEN=old-token\n");
|
|
101
|
+
try {
|
|
102
|
+
const ctx = ctxFor(root, { provider: "oauth-token", claudeOauthToken: "new-token" });
|
|
103
|
+
await model.prompt(ctx);
|
|
104
|
+
await model.apply(ctx);
|
|
105
|
+
await model.apply(ctx);
|
|
106
|
+
const env = readEnv(root);
|
|
107
|
+
assert.equal((env.match(/^CLAUDE_CODE_OAUTH_TOKEN=/gm) || []).length, 1);
|
|
108
|
+
assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=old-token$/m);
|
|
109
|
+
assert.equal((env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/gm) || []).length, 1);
|
|
110
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("oauth-token chosen but no token available: still writes the prefer flag, detect reports keychain mode", async () => {
|
|
114
|
+
const root = tmpRoot("");
|
|
115
|
+
try {
|
|
116
|
+
await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: undefined }, async () => {
|
|
117
|
+
const ctx = ctxFor(root);
|
|
118
|
+
await model.prompt(ctx);
|
|
119
|
+
await model.apply(ctx);
|
|
120
|
+
});
|
|
121
|
+
const env = readEnv(root);
|
|
122
|
+
assert.doesNotMatch(env, /^CLAUDE_CODE_OAUTH_TOKEN=/m);
|
|
123
|
+
assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
|
|
124
|
+
const d = await model.detect(ctxFor(root));
|
|
125
|
+
assert.equal(d.status, "complete");
|
|
126
|
+
assert.match(d.summary, /keychain/i);
|
|
127
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("subscription (keychain) and api-key modes keep working", async () => {
|
|
131
|
+
const a = tmpRoot("");
|
|
132
|
+
const b = tmpRoot(undefined); // no .env at all → created 0600
|
|
133
|
+
try {
|
|
134
|
+
const ca = ctxFor(a, { provider: "subscription" });
|
|
135
|
+
await model.prompt(ca); await model.apply(ca);
|
|
136
|
+
assert.match(readEnv(a), /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
|
|
137
|
+
assert.doesNotMatch(readEnv(a), /^CLAUDE_CODE_OAUTH_TOKEN=/m);
|
|
138
|
+
|
|
139
|
+
const cb = ctxFor(b, { provider: "api-key", anthropicApiKey: "sk-ant-api03-x" });
|
|
140
|
+
await model.prompt(cb); await model.apply(cb);
|
|
141
|
+
assert.ok(existsSync(join(b, ".env")));
|
|
142
|
+
assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-x$/m);
|
|
143
|
+
assert.doesNotMatch(readEnv(b), /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=/m);
|
|
144
|
+
assert.equal(statSync(join(b, ".env")).mode & 0o777, 0o600);
|
|
145
|
+
const d = await model.detect(ctxFor(b));
|
|
146
|
+
assert.match(d.summary, /API key/);
|
|
147
|
+
} finally { rmSync(a, { recursive: true, force: true }); rmSync(b, { recursive: true, force: true }); }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("legacy flat answers (no answers.model) still resolve; a bare token answer implies oauth-token", async () => {
|
|
151
|
+
const root = tmpRoot("");
|
|
152
|
+
try {
|
|
153
|
+
const ctx = ctxFor(root, { claudeOauthToken: "flat-token" });
|
|
154
|
+
await model.apply(ctx); // no prompt() — the headless resume path
|
|
155
|
+
const env = readEnv(root);
|
|
156
|
+
assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=flat-token$/m);
|
|
157
|
+
assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
|
|
158
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("verify uses the injectable probe (ctx.deps.probeClaude) and surfaces auth + detail", async () => {
|
|
162
|
+
const root = tmpRoot("CLAUDE_CODE_OAUTH_TOKEN=t\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
|
|
163
|
+
try {
|
|
164
|
+
const seen = [];
|
|
165
|
+
const probeClaude = (agentRoot) => { seen.push(agentRoot); return { ok: true, mode: "subscription", auth: "unknown", name: "Claude auth", detail: "could not verify" }; };
|
|
166
|
+
const r = await model.verify(ctxFor(root, {}, { deps: { probeClaude } }));
|
|
167
|
+
assert.deepEqual(seen, [root]);
|
|
168
|
+
assert.equal(r.ok, true);
|
|
169
|
+
assert.equal(r.checks[0].auth, "unknown");
|
|
170
|
+
assert.equal(r.checks[0].detail, "could not verify");
|
|
171
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Review 2026-09-08 findings
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
test("a .env seeded from .env.example (empty `KEY=` lines) is FILLED IN, not treated as already set", async () => {
|
|
179
|
+
// .env.example ships `CLAUDE_CODE_OAUTH_TOKEN=` and `MAESTRO_PREFER_SUBSCRIPTION_AUTH=`
|
|
180
|
+
// as empty placeholders, and doctor's remedy is "copy from .env.example" —
|
|
181
|
+
// so this is the common path, and it used to leave the seat with no auth.
|
|
182
|
+
const root = tmpRoot(readFileSync(join(MAESTRO_ROOT, ".env.example"), "utf-8"));
|
|
183
|
+
try {
|
|
184
|
+
await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: undefined }, async () => {
|
|
185
|
+
const ctx = ctxFor(root, { provider: "oauth-token", claudeOauthToken: "sk-ant-oat01-REAL" });
|
|
186
|
+
await model.prompt(ctx);
|
|
187
|
+
await model.apply(ctx);
|
|
188
|
+
});
|
|
189
|
+
const env = readEnv(root);
|
|
190
|
+
assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-REAL$/m);
|
|
191
|
+
assert.equal((env.match(/^CLAUDE_CODE_OAUTH_TOKEN=/gm) || []).length, 1, "the placeholder line is replaced in place, not duplicated");
|
|
192
|
+
assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
|
|
193
|
+
assert.equal((env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=/gm) || []).length, 1);
|
|
194
|
+
const d = await model.detect(ctxFor(root));
|
|
195
|
+
assert.equal(d.status, "complete");
|
|
196
|
+
assert.match(d.summary, /OAuth token/i);
|
|
197
|
+
// The api-key lane had the same latent bug.
|
|
198
|
+
const b = tmpRoot("ANTHROPIC_API_KEY=\n");
|
|
199
|
+
try {
|
|
200
|
+
const cb = ctxFor(b, { provider: "api-key", anthropicApiKey: "sk-ant-api03-x" });
|
|
201
|
+
await model.prompt(cb); await model.apply(cb);
|
|
202
|
+
assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-x$/m);
|
|
203
|
+
assert.equal((readEnv(b).match(/^ANTHROPIC_API_KEY=/gm) || []).length, 1);
|
|
204
|
+
} finally { rmSync(b, { recursive: true, force: true }); }
|
|
205
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("the pasted token and API key never sit in ctx.answers.model — nothing for progress.json to leak", async () => {
|
|
209
|
+
const root = tmpRoot("");
|
|
210
|
+
try {
|
|
211
|
+
const spyIo = {
|
|
212
|
+
...io,
|
|
213
|
+
select: async (_ctx, opts) => opts.default,
|
|
214
|
+
secret: async (_ctx, _opts) => "sk-ant-oat01-PASTED",
|
|
215
|
+
confirm: async () => false,
|
|
216
|
+
};
|
|
217
|
+
const ctx = ctxFor(root, {}, { io: spyIo });
|
|
218
|
+
await model.prompt(ctx);
|
|
219
|
+
const flat = JSON.stringify(ctx.answers.model);
|
|
220
|
+
assert.doesNotMatch(flat, /PASTED/, "answers.model carries no credential value");
|
|
221
|
+
assert.equal(ctx.answers.model.provider, "oauth-token");
|
|
222
|
+
assert.equal(ctx.answers.model.hasToken, true);
|
|
223
|
+
// The secret lives under its own top-level key, which the checkpoint's
|
|
224
|
+
// SECRET_ANSWER_KEY_RE already strips on every save.
|
|
225
|
+
assert.equal(ctx.answers.claudeOauthToken, "sk-ant-oat01-PASTED");
|
|
226
|
+
await model.apply(ctx);
|
|
227
|
+
assert.match(readEnv(root), /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-PASTED$/m);
|
|
228
|
+
|
|
229
|
+
const b = tmpRoot("");
|
|
230
|
+
try {
|
|
231
|
+
const spyB = { ...spyIo, select: async () => "api-key", secret: async () => "sk-ant-api03-PASTED" };
|
|
232
|
+
const cb = ctxFor(b, {}, { io: spyB });
|
|
233
|
+
await model.prompt(cb);
|
|
234
|
+
assert.doesNotMatch(JSON.stringify(cb.answers.model), /PASTED/);
|
|
235
|
+
assert.equal(cb.answers.anthropicApiKey, "sk-ant-api03-PASTED");
|
|
236
|
+
await model.apply(cb);
|
|
237
|
+
assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-PASTED$/m);
|
|
238
|
+
} finally { rmSync(b, { recursive: true, force: true }); }
|
|
239
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
240
|
+
});
|
|
@@ -35,6 +35,7 @@ import { mergeAgentJson, readAgentJson } from "../sot.mjs";
|
|
|
35
35
|
import { cohortPullEnv, pullAndPlan } from "../enroll-from-cohort.mjs";
|
|
36
36
|
import { bootstrapAndPersist } from "../../org/bootstrap-context.mjs";
|
|
37
37
|
import { runGenerator } from "../run-generator.mjs";
|
|
38
|
+
import { looksLikeOrgId } from "../../cli/doctor-checks.mjs";
|
|
38
39
|
|
|
39
40
|
const CONFIG_REL = join("config", "org.yaml");
|
|
40
41
|
|
|
@@ -332,7 +333,9 @@ export default {
|
|
|
332
333
|
});
|
|
333
334
|
ctx.answers.orgBase = base;
|
|
334
335
|
if (!base) return;
|
|
335
|
-
ctx.answers.orgId = await io.text(ctx, { message: "Org
|
|
336
|
+
ctx.answers.orgId = await io.text(ctx, { message: "Org ID (optional — the ID, org_default_<slug>, NOT the slug)", key: "orgId", env: "COHORT_ORG_ID", default: "" });
|
|
337
|
+
const idWarning = orgIdWarning(ctx.answers.orgId);
|
|
338
|
+
if (idWarning) io.note(` ⚠ ${idWarning}`, { dim: false });
|
|
336
339
|
|
|
337
340
|
// Two honest credential lanes (hq now ships pairing.request/approve):
|
|
338
341
|
// apikey — an admin mints an org API key (Bearer) which you paste here.
|
|
@@ -442,12 +445,20 @@ export default {
|
|
|
442
445
|
checks.push({ name: "config/org.yaml present", ok: cfgPresent, remedy: cfgPresent ? undefined : "run `maestro setup --only org`" });
|
|
443
446
|
|
|
444
447
|
const cfg = await readOrgConfig(ctx.agentRoot);
|
|
448
|
+
// F2: the org-ID pin is compared by hq with strict equality against the
|
|
449
|
+
// org ID; the slug 401s every call. Judged here (the wizard's own gate)
|
|
450
|
+
// as well as by doctor, so a slug never survives setup unremarked.
|
|
451
|
+
const pin = String((cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.orgId) || "").trim();
|
|
452
|
+
if (pin) {
|
|
453
|
+
const w = orgIdWarning(pin);
|
|
454
|
+
checks.push({ name: "COHORT_ORG_ID is the org ID (not the slug)", ok: !w, remedy: w || undefined });
|
|
455
|
+
}
|
|
445
456
|
const enabled = isEnabled(cfg);
|
|
446
457
|
if (!enabled) {
|
|
447
458
|
// Org enrollment is OPTIONAL — a not-yet-enrolled agent WARNs (remedy shown)
|
|
448
459
|
// but does not hard-fail setup. ok=true so the agent can run standalone.
|
|
449
460
|
checks.push({ name: "org knowledge plane (not enrolled — optional)", ok: true, remedy: "set org.cohort.base + token in config/org.yaml, or pair with the org admin" });
|
|
450
|
-
return { ok:
|
|
461
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
451
462
|
}
|
|
452
463
|
|
|
453
464
|
// Best-effort, NON-BLOCKING reachability probe. A GET /v1/snapshot round-trips
|
|
@@ -473,3 +484,17 @@ export default {
|
|
|
473
484
|
return { ok: checks.every((c) => c.ok), checks };
|
|
474
485
|
},
|
|
475
486
|
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* F2: is this COHORT_ORG_ID value the org SLUG rather than the org ID? Pure.
|
|
490
|
+
* hq compares the `x-org-id` pin with strict equality against the org ID
|
|
491
|
+
* (`org_default_<slug>`), so a slug 401s every call the agent makes — and the
|
|
492
|
+
* wizard used to invite exactly that with "Org id / slug (optional)".
|
|
493
|
+
* @param {string} value
|
|
494
|
+
* @returns {string|null} a warning naming the likely ID, or null when fine/blank
|
|
495
|
+
*/
|
|
496
|
+
export function orgIdWarning(value) {
|
|
497
|
+
const v = String(value || "").trim();
|
|
498
|
+
if (!v || looksLikeOrgId(v)) return null;
|
|
499
|
+
return `COHORT_ORG_ID=${v} looks like the org SLUG — hq compares the pin strictly against the org ID (org_default_<slug>), so every call would 401. Use the ID, probably org_default_${v.toLowerCase()} (Cohort → Settings shows it); \`maestro doctor\` checks it against the server.`;
|
|
500
|
+
}
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join, dirname } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import yaml from "js-yaml";
|
|
14
14
|
|
|
15
|
-
import org, { buildOrgConfig, readOrgConfig, pullProfileIntoAgentConfig, orgContextShapesBacklog, requestPairing, deriveAgentIdentity } from "./org.mjs";
|
|
15
|
+
import org, { buildOrgConfig, readOrgConfig, pullProfileIntoAgentConfig, orgContextShapesBacklog, requestPairing, deriveAgentIdentity, orgIdWarning } from "./org.mjs";
|
|
16
16
|
import { isEnabled } from "../../org/client.mjs";
|
|
17
17
|
import { readAgentJson, writeAgentJson } from "../sot.mjs";
|
|
18
18
|
import * as io from "../io.mjs";
|
|
@@ -311,3 +311,36 @@ test("verify does NOT block / hard-fail on an unreachable enabled server (fail-o
|
|
|
311
311
|
assert.ok(reach, "a reachability check is recorded");
|
|
312
312
|
assert.equal(reach.ok, true, "reachability must never hard-fail setup");
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// F2 (audit 2026-09-08): the wizard must not silently accept the org SLUG as
|
|
317
|
+
// COHORT_ORG_ID — hq compares the pin with strict equality against the org ID
|
|
318
|
+
// (`org_default_<slug>`), so a slug 401s every call.
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
test("orgIdWarning: a slug-shaped value gets a warning naming the ID form; an ID or blank gets none", () => {
|
|
322
|
+
assert.equal(orgIdWarning(""), null);
|
|
323
|
+
assert.equal(orgIdWarning("org_default_acme"), null);
|
|
324
|
+
const w = orgIdWarning("acme");
|
|
325
|
+
assert.match(w, /slug/i);
|
|
326
|
+
assert.match(w, /org_default_acme/);
|
|
327
|
+
assert.match(w, /401/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("verify: a slug-shaped orgId in config/org.yaml is a failing check with the remedy; the ID form passes", async () => {
|
|
331
|
+
const root = tmpRoot();
|
|
332
|
+
try {
|
|
333
|
+
await org.apply(ctxFor(root, { orgBase: "https://cohort.example", orgId: "acme", orgToken: "tok-123" }));
|
|
334
|
+
const bad = await org.verify(ctxFor(root));
|
|
335
|
+
const shape = bad.checks.find((c) => /COHORT_ORG_ID/.test(c.name));
|
|
336
|
+
assert.ok(shape, "the shape check ran");
|
|
337
|
+
assert.equal(shape.ok, false);
|
|
338
|
+
assert.match(shape.remedy, /org_default_acme/);
|
|
339
|
+
assert.equal(bad.ok, false);
|
|
340
|
+
|
|
341
|
+
writeFileSync(join(root, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n base: https://cohort.example\n orgId: org_default_acme\n token: tok-123\n");
|
|
342
|
+
const good = await org.verify(ctxFor(root));
|
|
343
|
+
const shape2 = good.checks.find((c) => /COHORT_ORG_ID/.test(c.name));
|
|
344
|
+
assert.ok(shape2 && shape2.ok === true, "the ID form is accepted");
|
|
345
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
346
|
+
});
|
|
@@ -209,10 +209,15 @@ export default {
|
|
|
209
209
|
const where = r.section ? ` [${r.section}]` : "";
|
|
210
210
|
io.note(`${mark} ${r.name}${where}`);
|
|
211
211
|
if (!r.ok && r.remedy) io.note(` ↳ ${r.remedy}`, { dim: true });
|
|
212
|
+
else if (r.ok && r.detail) io.note(` ↳ ${r.detail}`, { dim: true });
|
|
212
213
|
}
|
|
213
214
|
if (result.probe && !result.probe.ok && result.probe.remedy) {
|
|
214
215
|
io.note(` ✗ ${result.probe.name}`);
|
|
215
216
|
io.note(` ↳ ${result.probe.remedy}`, { dim: true });
|
|
217
|
+
} else if (result.probe && result.probe.ok && result.probe.detail) {
|
|
218
|
+
// Fail-open outcomes (auth "unknown") pass the gate but are never silent.
|
|
219
|
+
io.note(` ✓ ${result.probe.name}`);
|
|
220
|
+
io.note(` ↳ ${result.probe.detail}`, { dim: true });
|
|
216
221
|
}
|
|
217
222
|
},
|
|
218
223
|
};
|
package/lib/setup/state.mjs
CHANGED
|
@@ -63,29 +63,49 @@ export const SECRET_ANSWER_KEY_RE =
|
|
|
63
63
|
* actually lives.
|
|
64
64
|
*
|
|
65
65
|
* The dropped key names are recorded on `_redacted` so the file still SAYS what
|
|
66
|
-
* was removed. Names are not secrets; values are.
|
|
66
|
+
* was removed. Names are not secrets; values are. The scan RECURSES: section
|
|
67
|
+
* bags (`answers.model`, …) are objects, and a credential nested one level
|
|
68
|
+
* down is exactly as leaked as a top-level one.
|
|
67
69
|
*
|
|
68
70
|
* @param {object|null|undefined} answers
|
|
69
71
|
* @returns {{answers:object, redacted:string[]}}
|
|
70
72
|
*/
|
|
71
73
|
export function redactSecretAnswers(answers) {
|
|
72
74
|
const src = answers && typeof answers === "object" ? answers : {};
|
|
73
|
-
const out = {};
|
|
74
75
|
const redacted = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
|
|
78
|
-
redacted.push(k);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
out[k] = v;
|
|
82
|
-
}
|
|
76
|
+
const out = redactInto(src, "", redacted);
|
|
77
|
+
delete out._redacted;
|
|
83
78
|
const prior = Array.isArray(src._redacted) ? src._redacted : [];
|
|
84
79
|
const all = [...new Set([...prior, ...redacted])].sort();
|
|
85
80
|
if (all.length) out._redacted = all;
|
|
86
81
|
return { answers: out, redacted };
|
|
87
82
|
}
|
|
88
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The recursive walk behind redactSecretAnswers. Sections keep their own bag
|
|
86
|
+
* under `answers.<section>` (e.g. `answers.model = {provider, …}`), so a
|
|
87
|
+
* credential one level down used to sail past a top-level-only scan and land
|
|
88
|
+
* in the 0644 checkpoint — the .env was chmod 600 for nothing. Every object at
|
|
89
|
+
* every depth is scanned; arrays are walked element-wise (a dropped key inside
|
|
90
|
+
* an element leaves the element in place so indices stay stable). Dotted
|
|
91
|
+
* paths (`model.oauthToken`) go on `_redacted` so the file still says what
|
|
92
|
+
* was removed.
|
|
93
|
+
*/
|
|
94
|
+
function redactInto(value, path, redacted) {
|
|
95
|
+
if (Array.isArray(value)) return value.map((v, i) => redactInto(v, `${path}[${i}]`, redacted));
|
|
96
|
+
if (value === null || typeof value !== "object") return value;
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [k, v] of Object.entries(value)) {
|
|
99
|
+
const full = path ? `${path}.${k}` : k;
|
|
100
|
+
if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
|
|
101
|
+
redacted.push(full);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
out[k] = redactInto(v, full, redacted);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
/** Absolute path to the progress file for an agent root. */
|
|
90
110
|
export function progressPath(agentRoot) {
|
|
91
111
|
return join(agentRoot, PROGRESS_REL);
|
package/lib/setup/state.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
|
-
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import {
|
|
@@ -90,3 +90,26 @@ test("mergeAnswers updates without disturbing sections", () => {
|
|
|
90
90
|
assert.equal(p.answers.tone, "warm");
|
|
91
91
|
assert.equal(p.sections.identity.status, "complete");
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
test("redactSecretAnswers recurses: a credential nested under a section bag never reaches progress.json", () => {
|
|
95
|
+
const root = mkdtempSync(join(tmpdir(), "setup-state-nested-"));
|
|
96
|
+
try {
|
|
97
|
+
mergeAnswers(root, {
|
|
98
|
+
claudeOauthToken: "sk-ant-oat01-FLAT",
|
|
99
|
+
model: { provider: "oauth-token", oauthToken: "sk-ant-oat01-NESTED", apiKey: "sk-ant-api03-NESTED", routing: false },
|
|
100
|
+
deep: { a: { b: { apiKey: "sk-deep" } }, keep: "yes" },
|
|
101
|
+
list: [{ token: "in-a-list" }, "plain"],
|
|
102
|
+
});
|
|
103
|
+
const raw = readFileSync(progressPath(root), "utf-8");
|
|
104
|
+
assert.doesNotMatch(raw, /FLAT|NESTED|sk-deep|in-a-list/, "no credential value on disk at any depth");
|
|
105
|
+
const p = loadProgress(root);
|
|
106
|
+
assert.equal(p.answers.model.provider, "oauth-token", "non-secret siblings survive");
|
|
107
|
+
assert.equal(p.answers.model.routing, false);
|
|
108
|
+
assert.equal(p.answers.deep.keep, "yes");
|
|
109
|
+
assert.deepEqual(p.answers.list, [{}, "plain"]);
|
|
110
|
+
assert.ok(p.answers._redacted.includes("claudeOauthToken"));
|
|
111
|
+
assert.ok(p.answers._redacted.includes("model.oauthToken"));
|
|
112
|
+
assert.ok(p.answers._redacted.includes("model.apiKey"));
|
|
113
|
+
assert.ok(p.answers._redacted.includes("deep.a.b.apiKey"));
|
|
114
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
115
|
+
});
|
package/lib/singleton.js
CHANGED
|
@@ -46,10 +46,16 @@ function locksDir() {
|
|
|
46
46
|
*
|
|
47
47
|
* Handles stale PID files (process died without cleanup).
|
|
48
48
|
*
|
|
49
|
+
* By default SIGINT/SIGTERM handlers are installed that release the lock and
|
|
50
|
+
* `process.exit(0)`. A caller that must run its own shutdown on those signals
|
|
51
|
+
* (the session supervisor forwards SIGTERM to its multiplexer before exiting)
|
|
52
|
+
* passes `{ signals: false }`; the `exit` cleanup is registered either way.
|
|
53
|
+
*
|
|
49
54
|
* @param {string} name - Service name (e.g., "slack-events", "daemon", "huddle-server")
|
|
55
|
+
* @param {{signals?: boolean}} [opts]
|
|
50
56
|
* @returns {boolean}
|
|
51
57
|
*/
|
|
52
|
-
export function acquireLock(name) {
|
|
58
|
+
export function acquireLock(name, opts = {}) {
|
|
53
59
|
const dir = locksDir();
|
|
54
60
|
mkdirSync(dir, { recursive: true });
|
|
55
61
|
const pidFile = join(dir, `${name}.pid`);
|
|
@@ -96,8 +102,10 @@ export function acquireLock(name) {
|
|
|
96
102
|
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
97
103
|
};
|
|
98
104
|
process.on("exit", cleanup);
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
if (opts.signals !== false) {
|
|
106
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
107
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
108
|
+
}
|
|
101
109
|
|
|
102
110
|
return true;
|
|
103
111
|
}
|
package/lib/singleton.test.mjs
CHANGED
|
@@ -133,3 +133,19 @@ test("a legacy pid-only lock for a live PID is honoured", () => {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
136
|
+
|
|
137
|
+
test("acquireLock(name, {signals:false}) installs no SIGTERM/SIGINT handlers — the caller owns shutdown; exit cleanup stays", () => {
|
|
138
|
+
const term = process.listenerCount("SIGTERM");
|
|
139
|
+
const int = process.listenerCount("SIGINT");
|
|
140
|
+
const exit = process.listenerCount("exit");
|
|
141
|
+
assert.equal(acquireLock("owned", { signals: false }), true);
|
|
142
|
+
assert.equal(process.listenerCount("SIGTERM"), term, "no SIGTERM handler");
|
|
143
|
+
assert.equal(process.listenerCount("SIGINT"), int, "no SIGINT handler");
|
|
144
|
+
assert.equal(process.listenerCount("exit"), exit + 1, "the exit cleanup is still registered");
|
|
145
|
+
releaseLock("owned");
|
|
146
|
+
// Default behaviour unchanged: signal handlers are installed.
|
|
147
|
+
assert.equal(acquireLock("default"), true);
|
|
148
|
+
assert.equal(process.listenerCount("SIGTERM"), term + 1);
|
|
149
|
+
assert.equal(process.listenerCount("SIGINT"), int + 1);
|
|
150
|
+
releaseLock("default");
|
|
151
|
+
});
|
package/lib/subagents/lock.mjs
CHANGED
|
@@ -245,7 +245,7 @@ export function queueOutbox(agentRoot, op, payload) {
|
|
|
245
245
|
const seq = String(++outboxSeq).padStart(4, "0");
|
|
246
246
|
const salt = randomBytes(3).toString("hex");
|
|
247
247
|
const p = join(outboxDir(agentRoot), `${ts}-${seq}-${salt}-${String(op).replace(/[^\w.-]/g, "_")}.json`);
|
|
248
|
-
|
|
248
|
+
writeJsonAtomic(p, { op: String(op), payload: payload || {}, queuedAt: new Date().toISOString() });
|
|
249
249
|
return p;
|
|
250
250
|
} catch {
|
|
251
251
|
return null;
|