@rubytech/create-maxy-code 0.1.216 → 0.1.221
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/package.json +1 -1
- package/payload/platform/lib/mcp-eager/dist/index.d.ts +0 -55
- package/payload/platform/lib/mcp-eager/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-eager/dist/index.js +0 -76
- package/payload/platform/lib/mcp-eager/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-eager/src/index.ts +0 -101
- package/payload/platform/plugins/admin/PLUGIN.md +2 -3
- package/payload/platform/plugins/admin/hooks/__tests__/hook-emit-stale-lock-ttl.test.sh +102 -0
- package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +1 -2
- package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +32 -3
- package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +20 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +18 -18
- package/payload/platform/plugins/admin/skills/plugin-management/SKILL.md +2 -2
- package/payload/platform/plugins/browser/mcp/dist/index.js +19 -19
- package/payload/platform/plugins/browser/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/platform.md +10 -17
- package/payload/platform/plugins/docs/references/troubleshooting.md +7 -0
- package/payload/platform/plugins/email/mcp/dist/index.js +12 -12
- package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/index.js +8 -8
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/references/graph-primitives.md +2 -2
- package/payload/platform/plugins/url-get/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/url-get/mcp/dist/index.js.map +1 -1
- package/payload/platform/scripts/check-no-esm-require.mjs +3 -0
- package/payload/platform/scripts/setup-account.sh +0 -15
- package/payload/platform/services/claude-session-manager/dist/auth-snapshot.d.ts +4 -0
- package/payload/platform/services/claude-session-manager/dist/auth-snapshot.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/auth-snapshot.js +50 -0
- package/payload/platform/services/claude-session-manager/dist/auth-snapshot.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +0 -14
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +44 -71
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +12 -31
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +3 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +55 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +27 -20
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +23 -17
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +94 -73
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts +4 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.js +16 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +12 -46
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +24 -73
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +4 -5
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +4 -5
- package/payload/server/public/assets/AdminShell-BcxEZnpu.js +1 -0
- package/payload/server/public/assets/{admin-DjGGOx2K.js → admin-DMo5DWKH.js} +1 -1
- package/payload/server/public/assets/{data-WBEvtRV-.js → data-D6IQdm0R.js} +1 -1
- package/payload/server/public/assets/{graph-DsOLsIPf.js → graph-PkZKVkWD.js} +1 -1
- package/payload/server/public/data.html +2 -2
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +49 -12
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +0 -169
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +0 -204
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-memory-write-passthrough.test.sh +0 -118
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +0 -409
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts +0 -116
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts.map +0 -1
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js +0 -125
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js.map +0 -1
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/package.json +0 -7
- package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +0 -1
package/package.json
CHANGED
|
@@ -20,41 +20,6 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
22
22
|
export declare const ALWAYS_LOAD_META_KEY: "anthropic/alwaysLoad";
|
|
23
|
-
/**
|
|
24
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
25
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
26
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
27
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
28
|
-
* time whether it is loaded inside the admin seat.
|
|
29
|
-
*
|
|
30
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
31
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
32
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
33
|
-
*/
|
|
34
|
-
export declare function isAdminSeat(env?: NodeJS.ProcessEnv): boolean;
|
|
35
|
-
/**
|
|
36
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
37
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
38
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
39
|
-
*/
|
|
40
|
-
export declare function adminBlockedResponse(toolName: string, owner: string): {
|
|
41
|
-
content: {
|
|
42
|
-
type: "text";
|
|
43
|
-
text: string;
|
|
44
|
-
}[];
|
|
45
|
-
isError: true;
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
49
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
50
|
-
*
|
|
51
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
52
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
53
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
54
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
55
|
-
* owner, handler))` mechanically.
|
|
56
|
-
*/
|
|
57
|
-
export declare function withAdminGate<H extends (...args: any[]) => any>(toolName: string, owner: string, handler: H): H;
|
|
58
23
|
/**
|
|
59
24
|
* Loose structural type for any object exposing a `registerTool` method. The
|
|
60
25
|
* MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
|
|
@@ -92,25 +57,5 @@ type LocalZodRawShape = Record<string, unknown>;
|
|
|
92
57
|
* the consumer's own zod version) infers the handler args.
|
|
93
58
|
*/
|
|
94
59
|
export declare function eagerTool<Args extends LocalZodRawShape>(server: EagerCapableServer, name: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
|
|
95
|
-
/**
|
|
96
|
-
* Loose structural type for the legacy four-arg `server.tool(name, desc,
|
|
97
|
-
* schema, handler)` call shape — used when the admin seat must register a
|
|
98
|
-
* specialist-owned tool as deferred (not eager) so it does not appear in
|
|
99
|
-
* the admin's eager tool list.
|
|
100
|
-
*/
|
|
101
|
-
type DeferredCapableServer = {
|
|
102
|
-
tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any;
|
|
103
|
-
};
|
|
104
|
-
/**
|
|
105
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
106
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
107
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
108
|
-
*
|
|
109
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
110
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
111
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
112
|
-
* admin seat refuses with the dispatch hint.
|
|
113
|
-
*/
|
|
114
|
-
export declare function eagerToolGated<Args extends LocalZodRawShape>(server: EagerCapableServer & DeferredCapableServer, name: string, owner: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
|
|
115
60
|
export {};
|
|
116
61
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN"}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ALWAYS_LOAD_META_KEY = void 0;
|
|
4
|
-
exports.isAdminSeat = isAdminSeat;
|
|
5
|
-
exports.adminBlockedResponse = adminBlockedResponse;
|
|
6
|
-
exports.withAdminGate = withAdminGate;
|
|
7
4
|
exports.eagerTool = eagerTool;
|
|
8
|
-
exports.eagerToolGated = eagerToolGated;
|
|
9
5
|
/**
|
|
10
6
|
* MCP eager-load helper.
|
|
11
7
|
*
|
|
@@ -28,59 +24,6 @@ exports.eagerToolGated = eagerToolGated;
|
|
|
28
24
|
*/
|
|
29
25
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
30
26
|
exports.ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad";
|
|
31
|
-
/**
|
|
32
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
33
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
34
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
35
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
36
|
-
* time whether it is loaded inside the admin seat.
|
|
37
|
-
*
|
|
38
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
39
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
40
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
41
|
-
*/
|
|
42
|
-
function isAdminSeat(env = process.env) {
|
|
43
|
-
return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
47
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
48
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
49
|
-
*/
|
|
50
|
-
function adminBlockedResponse(toolName, owner) {
|
|
51
|
-
return {
|
|
52
|
-
content: [
|
|
53
|
-
{
|
|
54
|
-
type: "text",
|
|
55
|
-
text: `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
|
|
56
|
-
`Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
isError: true,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
64
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
65
|
-
*
|
|
66
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
67
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
68
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
69
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
70
|
-
* owner, handler))` mechanically.
|
|
71
|
-
*/
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
-
function withAdminGate(toolName, owner, handler) {
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
-
const wrapped = ((...args) => {
|
|
76
|
-
if (isAdminSeat(process.env)) {
|
|
77
|
-
process.stderr.write(`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`);
|
|
78
|
-
return adminBlockedResponse(toolName, owner);
|
|
79
|
-
}
|
|
80
|
-
return handler(...args);
|
|
81
|
-
});
|
|
82
|
-
return wrapped;
|
|
83
|
-
}
|
|
84
27
|
/**
|
|
85
28
|
* Register an MCP tool that the Claude Code SDK eager-loads into the
|
|
86
29
|
* model's prompt at session boot, bypassing the ToolSearch round-trip.
|
|
@@ -103,23 +46,4 @@ handler) {
|
|
|
103
46
|
_meta: { [exports.ALWAYS_LOAD_META_KEY]: true },
|
|
104
47
|
}, handler);
|
|
105
48
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
108
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
109
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
110
|
-
*
|
|
111
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
112
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
113
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
114
|
-
* admin seat refuses with the dispatch hint.
|
|
115
|
-
*/
|
|
116
|
-
function eagerToolGated(server, name, owner, description, inputSchema,
|
|
117
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
-
handler) {
|
|
119
|
-
if (isAdminSeat(process.env)) {
|
|
120
|
-
server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
eagerTool(server, name, description, inputSchema, handler);
|
|
124
|
-
}
|
|
125
49
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AA4DA,8BAiBC;AA7ED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AA0BpE;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC"}
|
|
@@ -21,72 +21,6 @@
|
|
|
21
21
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
22
22
|
export const ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad" as const;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
26
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
27
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
28
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
29
|
-
* time whether it is loaded inside the admin seat.
|
|
30
|
-
*
|
|
31
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
32
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
33
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
34
|
-
*/
|
|
35
|
-
export function isAdminSeat(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
-
return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
41
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
42
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
43
|
-
*/
|
|
44
|
-
export function adminBlockedResponse(toolName: string, owner: string): {
|
|
45
|
-
content: { type: "text"; text: string }[];
|
|
46
|
-
isError: true;
|
|
47
|
-
} {
|
|
48
|
-
return {
|
|
49
|
-
content: [
|
|
50
|
-
{
|
|
51
|
-
type: "text",
|
|
52
|
-
text:
|
|
53
|
-
`Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
|
|
54
|
-
`Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
|
|
55
|
-
},
|
|
56
|
-
],
|
|
57
|
-
isError: true,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
63
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
64
|
-
*
|
|
65
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
66
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
67
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
68
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
69
|
-
* owner, handler))` mechanically.
|
|
70
|
-
*/
|
|
71
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
-
export function withAdminGate<H extends (...args: any[]) => any>(
|
|
73
|
-
toolName: string,
|
|
74
|
-
owner: string,
|
|
75
|
-
handler: H,
|
|
76
|
-
): H {
|
|
77
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
-
const wrapped = ((...args: any[]) => {
|
|
79
|
-
if (isAdminSeat(process.env)) {
|
|
80
|
-
process.stderr.write(
|
|
81
|
-
`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`,
|
|
82
|
-
);
|
|
83
|
-
return adminBlockedResponse(toolName, owner);
|
|
84
|
-
}
|
|
85
|
-
return handler(...args);
|
|
86
|
-
}) as unknown as H;
|
|
87
|
-
return wrapped;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
24
|
/**
|
|
91
25
|
* Loose structural type for any object exposing a `registerTool` method. The
|
|
92
26
|
* MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
|
|
@@ -142,38 +76,3 @@ export function eagerTool<Args extends LocalZodRawShape>(
|
|
|
142
76
|
handler,
|
|
143
77
|
);
|
|
144
78
|
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Loose structural type for the legacy four-arg `server.tool(name, desc,
|
|
148
|
-
* schema, handler)` call shape — used when the admin seat must register a
|
|
149
|
-
* specialist-owned tool as deferred (not eager) so it does not appear in
|
|
150
|
-
* the admin's eager tool list.
|
|
151
|
-
*/
|
|
152
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
-
type DeferredCapableServer = { tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any };
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
157
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
158
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
159
|
-
*
|
|
160
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
161
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
162
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
163
|
-
* admin seat refuses with the dispatch hint.
|
|
164
|
-
*/
|
|
165
|
-
export function eagerToolGated<Args extends LocalZodRawShape>(
|
|
166
|
-
server: EagerCapableServer & DeferredCapableServer,
|
|
167
|
-
name: string,
|
|
168
|
-
owner: string,
|
|
169
|
-
description: string,
|
|
170
|
-
inputSchema: Args,
|
|
171
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
172
|
-
handler: (...args: any[]) => any,
|
|
173
|
-
): void {
|
|
174
|
-
if (isAdminSeat(process.env)) {
|
|
175
|
-
server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
eagerTool(server, name, description, inputSchema, handler);
|
|
179
|
-
}
|
|
@@ -106,7 +106,7 @@ Tools are available via the `admin` MCP server.
|
|
|
106
106
|
|
|
107
107
|
**Admin-seat authoring-skill gate (Task 516).** `skill-load` and `plugin-read` refuse to return the body of a content-producer-owned authoring skill — `professional-document`, `a4-print-documents`, `publish-site` — when the caller is the admin seat (`MAXY_SESSION_ROLE=admin` with no `MAXY_SPECIALIST`). The refusal relays one line — "This deliverable is owned by content-producer. Dispatch it with the Agent tool and subagent_type content-producer." — so the admin's next action is the dispatch, not inline authoring. `plugin-read` extracts the skill slug from a `skills/<slug>/…` file path (or a slug buried in `pluginName`), so the raw-SKILL.md read path can't bypass the gate; `PLUGIN.md` and plain `references/*` reads are untouched. content-producer's own spawns carry `MAXY_SPECIALIST=content-producer` and resolve these skills normally (it reaches them via the native `Skill` tool regardless), and the public seat is out of scope. The gate is a pure name+env check before any filesystem read and fails open for any non-admin or unidentified seat — it never bricks a legitimate load. Each block emits `[admin-skill-gate] role=admin skill=<name> decision=block reason=content-producer-owned`. This is the enforcement layer; the IDENTITY.md delegation paragraph documents the intent but is not the gate ([[feedback_deterministic_means_remove_llm]], [[feedback_doctrine_paragraph_is_not_a_gate]]). Skills the admin invokes directly are deliberately excluded: `unzip-attachment` (its SKILL.md says "Invoked by the admin agent directly — admin owns all unzipping", then routes the extracted tree to a specialist) and `deck-pages` (admin-owned, a content-producer refusal target). The set is each skill's own SKILL.md "Invoked by" declaration, not the admin plugin's skill catalogue (which lists these admin-hosted skills for discovery — the catalogue is the inline path this gate closes).
|
|
108
108
|
|
|
109
|
-
**Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is
|
|
109
|
+
**Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is forbidden by IDENTITY.md doctrine — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
|
|
110
110
|
|
|
111
111
|
**AdminUser graph stamp + boot self-heal.** The seed cypher in `platform/scripts/seed-neo4j.sh` stamps `:AdminUser.accountId` + `:AdminUser.role='owner'` ON CREATE so the graph-write gate's account-scoped MATCH succeeds on the first user-domain write. Admin-server boot additionally runs `platform/ui/app/lib/adminuser-self-heal.ts` to repair legacy nodes whose `accountId` or `role` is null — emits `[adminuser-self-heal] healed=<N> userId=<8> accountId=<8>` once per boot, idempotent. The `graph-write-gate` reject log gains a `subReason=` field (`no-admin-user-node` / `admin-user-no-accountid` / `no-business-or-personal-profile`) for operator diagnosis. See `.docs/agents.md` § "AdminUser graph stamp contract" for the full contract.
|
|
112
112
|
|
|
@@ -139,12 +139,11 @@ Tools are available via the `admin` MCP server.
|
|
|
139
139
|
|
|
140
140
|
## Hooks
|
|
141
141
|
|
|
142
|
-
- `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
|
|
143
142
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
144
143
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
145
144
|
- `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
|
|
146
145
|
- `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
|
|
147
|
-
- `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `
|
|
146
|
+
- `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
|
|
148
147
|
- `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
|
|
149
148
|
- `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `sessionId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
|
|
150
149
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Task 569 — stale-lockdir TTL reclamation in _hook_acquire_lock and
|
|
3
|
+
# _drain_acquire_lock. A backdated lockdir is force-removed on acquire;
|
|
4
|
+
# a freshly held lockdir is not.
|
|
5
|
+
set -u
|
|
6
|
+
|
|
7
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
8
|
+
LIB="$ROOT/lib/hook-emit.sh"
|
|
9
|
+
DRAIN="$ROOT/post-tool-use-agent.sh"
|
|
10
|
+
[[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
|
|
11
|
+
[[ -f "$DRAIN" ]] || { echo "FAIL: $DRAIN missing"; exit 1; }
|
|
12
|
+
|
|
13
|
+
TMP=$(mktemp -d)
|
|
14
|
+
export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
|
|
15
|
+
export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
|
|
16
|
+
# Tight test budget — 20 × 10ms = 200ms ceiling so the negative case
|
|
17
|
+
# returns quickly.
|
|
18
|
+
export MAXY_HOOK_LOCK_ATTEMPTS=20
|
|
19
|
+
export MAXY_HOOK_LOCK_SLEEP=0.01
|
|
20
|
+
export MAXY_HOOK_LOCK_TTL_SECONDS=2
|
|
21
|
+
mkdir -p "$MAXY_HOOK_BUFFER_DIR"
|
|
22
|
+
|
|
23
|
+
PASS=0; FAIL=0
|
|
24
|
+
pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
25
|
+
fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
|
|
26
|
+
|
|
27
|
+
# shellcheck disable=SC1090
|
|
28
|
+
source "$LIB" || { fail "source library"; exit 1; }
|
|
29
|
+
pass "source library"
|
|
30
|
+
|
|
31
|
+
# Test 1: a stale lockdir (mtime backdated 10s; TTL=2s) is reclaimed and
|
|
32
|
+
# acquire succeeds.
|
|
33
|
+
LOCK1="$TMP/lock-stale"
|
|
34
|
+
mkdir "$LOCK1"
|
|
35
|
+
# Backdate mtime by 10s. -t needs YYYYMMDDhhmm.SS form; portable form
|
|
36
|
+
# uses -d on GNU and -A/-r on BSD, so we use python to set it.
|
|
37
|
+
python3 -c "import os,time; os.utime('$LOCK1', (time.time()-10, time.time()-10))"
|
|
38
|
+
if _hook_acquire_lock "$LOCK1"; then
|
|
39
|
+
pass "stale lockdir reclaimed (emit-path)"
|
|
40
|
+
else
|
|
41
|
+
fail "stale lockdir not reclaimed within budget (emit-path)"
|
|
42
|
+
fi
|
|
43
|
+
# Verify observability line.
|
|
44
|
+
if grep -q "\[hook-propagate-error\] reason=stale-lock-reclaimed lockdir=$LOCK1" "$MAXY_HOOK_SERVER_LOG"; then
|
|
45
|
+
pass "stale-lock-reclaimed observability line emitted"
|
|
46
|
+
else
|
|
47
|
+
fail "stale-lock-reclaimed line missing from server.log"
|
|
48
|
+
fi
|
|
49
|
+
rmdir "$LOCK1" 2>/dev/null || true
|
|
50
|
+
|
|
51
|
+
# Test 2: a fresh lockdir (mtime = now) is NOT reclaimed and acquire
|
|
52
|
+
# returns 1 within the attempt budget.
|
|
53
|
+
LOCK2="$TMP/lock-fresh"
|
|
54
|
+
mkdir "$LOCK2"
|
|
55
|
+
START=$(date +%s)
|
|
56
|
+
if _hook_acquire_lock "$LOCK2"; then
|
|
57
|
+
fail "fresh lockdir wrongly reclaimed (emit-path)"
|
|
58
|
+
else
|
|
59
|
+
END=$(date +%s)
|
|
60
|
+
pass "fresh lockdir not reclaimed; acquire timed out (emit-path, ${END}-${START}s)"
|
|
61
|
+
fi
|
|
62
|
+
# Verify no stale-lock-reclaimed line was written for LOCK2.
|
|
63
|
+
if grep -q "lockdir=$LOCK2 ageSeconds=" "$MAXY_HOOK_SERVER_LOG"; then
|
|
64
|
+
fail "fresh lockdir wrongly logged as stale (emit-path)"
|
|
65
|
+
else
|
|
66
|
+
pass "no stale-lock log for fresh lockdir (emit-path)"
|
|
67
|
+
fi
|
|
68
|
+
rmdir "$LOCK2" 2>/dev/null || true
|
|
69
|
+
|
|
70
|
+
# Test 3: same property for the drainer-side function. Extract
|
|
71
|
+
# _drain_acquire_lock + its age helper from post-tool-use-agent.sh and
|
|
72
|
+
# source them in isolation.
|
|
73
|
+
DRAIN_LIB="$TMP/drain-lock.sh"
|
|
74
|
+
awk '
|
|
75
|
+
/^_drain_lockdir_age_seconds\(\)/,/^}/ { print; next }
|
|
76
|
+
/^_drain_acquire_lock\(\)/,/^}/ { print; next }
|
|
77
|
+
' "$DRAIN" > "$DRAIN_LIB"
|
|
78
|
+
[[ -s "$DRAIN_LIB" ]] || { fail "drain lock extract empty"; }
|
|
79
|
+
# shellcheck disable=SC1090
|
|
80
|
+
source "$DRAIN_LIB" || fail "source drain lib"
|
|
81
|
+
|
|
82
|
+
LOCK3="$TMP/lock-drain-stale"
|
|
83
|
+
mkdir "$LOCK3"
|
|
84
|
+
python3 -c "import os,time; os.utime('$LOCK3', (time.time()-10, time.time()-10))"
|
|
85
|
+
if _drain_acquire_lock "$LOCK3"; then
|
|
86
|
+
pass "stale lockdir reclaimed (drain-path)"
|
|
87
|
+
else
|
|
88
|
+
fail "stale lockdir not reclaimed (drain-path)"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
LOCK4="$TMP/lock-drain-fresh"
|
|
92
|
+
mkdir "$LOCK4"
|
|
93
|
+
if _drain_acquire_lock "$LOCK4"; then
|
|
94
|
+
fail "fresh lockdir wrongly reclaimed (drain-path)"
|
|
95
|
+
else
|
|
96
|
+
pass "fresh lockdir not reclaimed; acquire timed out (drain-path)"
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
rm -rf "$TMP"
|
|
100
|
+
echo "----"
|
|
101
|
+
echo "$PASS pass, $FAIL fail"
|
|
102
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
# server.log alongside the [whatsapp-ingest] script lines.
|
|
53
53
|
#
|
|
54
54
|
# Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
|
|
55
|
-
# message shown to the agent). Fail-closed on terminal stdin
|
|
56
|
-
# pre-tool-use.sh.
|
|
55
|
+
# message shown to the agent). Fail-closed on terminal stdin.
|
|
57
56
|
|
|
58
57
|
set -uo pipefail
|
|
59
58
|
|
|
@@ -14,11 +14,39 @@
|
|
|
14
14
|
# (tests) without flock(1).
|
|
15
15
|
: "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
|
|
16
16
|
: "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
|
|
17
|
+
# Task 569 — TTL for orphaned lockdirs left behind when a holder dies
|
|
18
|
+
# between `mkdir lock` and `rmdir lock` (SIGKILL, OOM, systemd restart).
|
|
19
|
+
# Default 5s is far longer than the legitimate hold (microseconds for a
|
|
20
|
+
# rename or append) and shorter than operator-noticeable propagation lag.
|
|
21
|
+
: "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
|
|
22
|
+
|
|
23
|
+
# _hook_lockdir_age_seconds <lockdir> — prints integer age in seconds, or
|
|
24
|
+
# -1 if the lockdir cannot be stat'd. Portable across Linux (`stat -c`)
|
|
25
|
+
# and macOS (`stat -f`).
|
|
26
|
+
_hook_lockdir_age_seconds() {
|
|
27
|
+
local lockdir="$1" mtime now
|
|
28
|
+
mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
|
|
29
|
+
[[ -z "$mtime" ]] && { echo -1; return; }
|
|
30
|
+
now=$(date +%s)
|
|
31
|
+
echo $((now - mtime))
|
|
32
|
+
}
|
|
17
33
|
|
|
18
34
|
# _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
|
|
35
|
+
# On each mkdir failure, if the lockdir is older than
|
|
36
|
+
# MAXY_HOOK_LOCK_TTL_SECONDS, the orphaned holder is presumed dead: the
|
|
37
|
+
# lockdir is force-removed, a stale-lock-reclaimed observability line is
|
|
38
|
+
# emitted, and one mkdir retry is attempted before resuming the spin.
|
|
19
39
|
_hook_acquire_lock() {
|
|
20
|
-
local lockdir="$1" i=0
|
|
40
|
+
local lockdir="$1" i=0 age
|
|
21
41
|
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
42
|
+
age=$(_hook_lockdir_age_seconds "$lockdir")
|
|
43
|
+
if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
|
|
44
|
+
rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
|
|
45
|
+
mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
|
|
46
|
+
printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
|
|
47
|
+
"$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
|
|
48
|
+
mkdir "$lockdir" 2>/dev/null && return 0
|
|
49
|
+
fi
|
|
22
50
|
i=$((i+1))
|
|
23
51
|
[[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
|
|
24
52
|
sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
|
|
@@ -102,8 +130,9 @@ PY
|
|
|
102
130
|
|
|
103
131
|
# hook_block_with_emit <agentId> <hookName> <toolName> <reason>
|
|
104
132
|
# <userMessage> [durationMs]
|
|
105
|
-
# Convenience wrapper for
|
|
106
|
-
# echoes the user-facing block message to stderr,
|
|
133
|
+
# Convenience wrapper for any PreToolUse hook that records a block decision:
|
|
134
|
+
# emits the propagation record, echoes the user-facing block message to stderr,
|
|
135
|
+
# and exits 2.
|
|
107
136
|
hook_block_with_emit() {
|
|
108
137
|
local agentId="$1" hookName="$2" toolName="$3" reason="$4"
|
|
109
138
|
local userMessage="$5" durationMs="${6:-0}"
|
|
@@ -28,6 +28,8 @@ set -uo pipefail
|
|
|
28
28
|
: "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
|
|
29
29
|
: "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
|
|
30
30
|
: "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
|
|
31
|
+
# Task 569 — TTL for orphaned lockdirs (see hook-emit.sh for rationale).
|
|
32
|
+
: "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
|
|
31
33
|
|
|
32
34
|
if [ -t 0 ]; then
|
|
33
35
|
# No stdin — no correlation possible. Fail-open.
|
|
@@ -65,9 +67,26 @@ NOW_EPOCH=$(date +%s)
|
|
|
65
67
|
N_OBSERVED=0
|
|
66
68
|
N_EMITTED=0
|
|
67
69
|
|
|
70
|
+
# Task 569 — same stale-lockdir TTL reclamation as hook-emit.sh.
|
|
71
|
+
_drain_lockdir_age_seconds() {
|
|
72
|
+
local lockdir="$1" mtime now
|
|
73
|
+
mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
|
|
74
|
+
[[ -z "$mtime" ]] && { echo -1; return; }
|
|
75
|
+
now=$(date +%s)
|
|
76
|
+
echo $((now - mtime))
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
_drain_acquire_lock() {
|
|
69
|
-
local lockdir="$1" i=0
|
|
80
|
+
local lockdir="$1" i=0 age
|
|
70
81
|
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
82
|
+
age=$(_drain_lockdir_age_seconds "$lockdir")
|
|
83
|
+
if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
|
|
84
|
+
rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
|
|
85
|
+
mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
|
|
86
|
+
printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
|
|
87
|
+
"$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
|
|
88
|
+
mkdir "$lockdir" 2>/dev/null && return 0
|
|
89
|
+
fi
|
|
71
90
|
i=$((i+1))
|
|
72
91
|
[[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
|
|
73
92
|
sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
|
|
@@ -755,8 +755,8 @@ eagerTool(server, "account-update", "Update a user-configurable setting in accou
|
|
|
755
755
|
}
|
|
756
756
|
});
|
|
757
757
|
// Plugin enable/disable: deterministic write to account.json.enabledPlugins.
|
|
758
|
-
//
|
|
759
|
-
// so the agent
|
|
758
|
+
// IDENTITY.md doctrine forbids the agent's direct Edit on account.json,
|
|
759
|
+
// so the agent must not toggle enablement by hand-editing the file. This
|
|
760
760
|
// tool is the legitimate path: validates the plugin name, refuses core plugins,
|
|
761
761
|
// confirms the plugin directory exists, and atomically updates the array.
|
|
762
762
|
// Entitlement-bearing fields (tier, purchasedPlugins) are NOT writable here.
|