linkgravity 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +22 -3
- package/bin/completion.js +50 -0
- package/bin/completions/_lgy +33 -0
- package/bin/completions/lgy.bash +18 -0
- package/bin/completions/lgy.fish +32 -0
- package/bin/setup.js +15 -0
- package/package.json +1 -1
- package/src/api/ui_routes.py +7 -0
- package/src/approval/protected_paths.py +42 -0
- package/src/core/logger.py +14 -0
- package/src/main_discord.py +0 -2
- package/src/messengers/slack_adapter.py +4 -0
package/bin/cli.js
CHANGED
|
@@ -49,6 +49,9 @@ function runPm2(args, silent = true) {
|
|
|
49
49
|
...process.env,
|
|
50
50
|
// pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
|
|
51
51
|
PYTHONUNBUFFERED: '1',
|
|
52
|
+
// pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
|
|
53
|
+
// survives unless a value is passed every time.
|
|
54
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
|
|
52
55
|
// Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
|
|
53
56
|
// so the bot's own `node` lookup for voice-service would fail without this.
|
|
54
57
|
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
|
|
@@ -106,7 +109,7 @@ function runSudoStepThen(sudoCommand, successMessage) {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
// Matches a leading timestamp from either loguru or aiohttp's access-log format
|
|
112
|
+
// Matches a leading timestamp from either loguru or aiohttp's access-log format.
|
|
110
113
|
const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
|
|
111
114
|
// loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
|
|
112
115
|
// eslint-disable-next-line no-control-regex
|
|
@@ -390,7 +393,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
390
393
|
}
|
|
391
394
|
|
|
392
395
|
info('Starting LinkGravity daemon...');
|
|
393
|
-
runPm2([
|
|
396
|
+
runPm2([
|
|
397
|
+
'start',
|
|
398
|
+
LGY_SCRIPT_PATH,
|
|
399
|
+
'--interpreter',
|
|
400
|
+
pythonExe,
|
|
401
|
+
'--name',
|
|
402
|
+
LGY_PM2_NAME,
|
|
403
|
+
'--update-env',
|
|
404
|
+
]);
|
|
394
405
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
395
406
|
} else if (cmd === 'stop') {
|
|
396
407
|
info('Stopping LinkGravity daemon...');
|
|
@@ -616,7 +627,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
616
627
|
|
|
617
628
|
if (!procBeforeUpdate) {
|
|
618
629
|
info("Daemon wasn't running - starting it fresh...");
|
|
619
|
-
runPm2([
|
|
630
|
+
runPm2([
|
|
631
|
+
'start',
|
|
632
|
+
LGY_SCRIPT_PATH,
|
|
633
|
+
'--interpreter',
|
|
634
|
+
pythonExe,
|
|
635
|
+
'--name',
|
|
636
|
+
LGY_PM2_NAME,
|
|
637
|
+
'--update-env',
|
|
638
|
+
]);
|
|
620
639
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
621
640
|
} else if (wasOnline) {
|
|
622
641
|
info('Restarting daemon to apply the update...');
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// bash and fish read these directories lazily, on the first completion attempt, so neither needs
|
|
6
|
+
// an rc line. zsh's default fpath has no home-directory entry, so it gets three.
|
|
7
|
+
const TARGETS = {
|
|
8
|
+
bash: { src: 'lgy.bash', dest: ['.local', 'share', 'bash-completion', 'completions', 'lgy'] },
|
|
9
|
+
zsh: { src: '_lgy', dest: ['.local', 'share', 'zsh', 'site-functions', '_lgy'], rc: '.zshrc' },
|
|
10
|
+
fish: { src: 'lgy.fish', dest: ['.config', 'fish', 'completions', 'lgy.fish'] },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MARKER = '# linkgravity completion';
|
|
14
|
+
|
|
15
|
+
function zshRcBlock(dir) {
|
|
16
|
+
return [
|
|
17
|
+
'',
|
|
18
|
+
MARKER,
|
|
19
|
+
`fpath+=("${dir}")`,
|
|
20
|
+
'autoload -Uz _lgy',
|
|
21
|
+
'whence compdef > /dev/null && compdef _lgy lgy linkgravity',
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function installCompletion(shell = path.basename(process.env.SHELL || '')) {
|
|
27
|
+
const target = TARGETS[shell];
|
|
28
|
+
if (!target) return null;
|
|
29
|
+
|
|
30
|
+
const dest = path.join(os.homedir(), ...target.dest);
|
|
31
|
+
let rcUpdated = false;
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
34
|
+
fs.copyFileSync(path.join(__dirname, 'completions', target.src), dest);
|
|
35
|
+
|
|
36
|
+
if (target.rc) {
|
|
37
|
+
const rc = path.join(os.homedir(), target.rc);
|
|
38
|
+
const existing = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
39
|
+
if (!existing.includes(MARKER)) {
|
|
40
|
+
fs.appendFileSync(rc, zshRcBlock(path.dirname(dest)));
|
|
41
|
+
rcUpdated = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return { shell, file: dest, rc: rcUpdated ? path.join(os.homedir(), target.rc) : null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { installCompletion };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#compdef lgy linkgravity
|
|
2
|
+
|
|
3
|
+
local -a commands
|
|
4
|
+
commands=(
|
|
5
|
+
'version:Print the installed version'
|
|
6
|
+
'start:Start bot in the background'
|
|
7
|
+
'stop:Stop the background bot'
|
|
8
|
+
'restart:Restart the background bot'
|
|
9
|
+
'logs:View bot logs'
|
|
10
|
+
'status:Show daemon status'
|
|
11
|
+
'enable:Start automatically on system boot'
|
|
12
|
+
'disable:Remove bot from system boot'
|
|
13
|
+
'setup:Run the configuration wizard'
|
|
14
|
+
'update:Install a newer version if one exists'
|
|
15
|
+
'help:Show the help message'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if (( CURRENT == 2 )); then
|
|
19
|
+
_describe 'command' commands
|
|
20
|
+
return
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
case "$words[2]" in
|
|
24
|
+
logs)
|
|
25
|
+
[[ "$words[CURRENT-1]" == (-n|--tail) ]] && return
|
|
26
|
+
_values 'flag' \
|
|
27
|
+
'-f[Follow the log output]' \
|
|
28
|
+
'-n[Number of lines to show]' \
|
|
29
|
+
'--tail[Number of lines to show]' \
|
|
30
|
+
'-t[Show timestamps]' \
|
|
31
|
+
'--timestamp[Show timestamps]'
|
|
32
|
+
;;
|
|
33
|
+
esac
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
_lgy_completion() {
|
|
2
|
+
local cur prev
|
|
3
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
4
|
+
prev="${COMP_WORDS[COMP_CWORD - 1]}"
|
|
5
|
+
|
|
6
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
7
|
+
COMPREPLY=($(compgen -W "version start stop restart logs status enable disable setup update help" -- "$cur"))
|
|
8
|
+
return
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
if [ "${COMP_WORDS[1]}" = "logs" ]; then
|
|
12
|
+
case "$prev" in
|
|
13
|
+
-n | --tail) return ;;
|
|
14
|
+
esac
|
|
15
|
+
COMPREPLY=($(compgen -W "-f -n --tail -t --timestamp" -- "$cur"))
|
|
16
|
+
fi
|
|
17
|
+
}
|
|
18
|
+
complete -F _lgy_completion lgy linkgravity
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
complete -c lgy -n __fish_use_subcommand -a version -d 'Print the installed version'
|
|
2
|
+
complete -c lgy -n __fish_use_subcommand -a start -d 'Start bot in the background'
|
|
3
|
+
complete -c lgy -n __fish_use_subcommand -a stop -d 'Stop the background bot'
|
|
4
|
+
complete -c lgy -n __fish_use_subcommand -a restart -d 'Restart the background bot'
|
|
5
|
+
complete -c lgy -n __fish_use_subcommand -a logs -d 'View bot logs'
|
|
6
|
+
complete -c lgy -n __fish_use_subcommand -a status -d 'Show daemon status'
|
|
7
|
+
complete -c lgy -n __fish_use_subcommand -a enable -d 'Start automatically on system boot'
|
|
8
|
+
complete -c lgy -n __fish_use_subcommand -a disable -d 'Remove bot from system boot'
|
|
9
|
+
complete -c lgy -n __fish_use_subcommand -a setup -d 'Run the configuration wizard'
|
|
10
|
+
complete -c lgy -n __fish_use_subcommand -a update -d 'Install a newer version if one exists'
|
|
11
|
+
complete -c lgy -n __fish_use_subcommand -a help -d 'Show the help message'
|
|
12
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s f -d 'Follow the log output'
|
|
13
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s n -d 'Number of lines to show'
|
|
14
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -l tail -d 'Number of lines to show'
|
|
15
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -s t -d 'Show timestamps'
|
|
16
|
+
complete -c lgy -n '__fish_seen_subcommand_from logs' -l timestamp -d 'Show timestamps'
|
|
17
|
+
complete -c linkgravity -n __fish_use_subcommand -a version -d 'Print the installed version'
|
|
18
|
+
complete -c linkgravity -n __fish_use_subcommand -a start -d 'Start bot in the background'
|
|
19
|
+
complete -c linkgravity -n __fish_use_subcommand -a stop -d 'Stop the background bot'
|
|
20
|
+
complete -c linkgravity -n __fish_use_subcommand -a restart -d 'Restart the background bot'
|
|
21
|
+
complete -c linkgravity -n __fish_use_subcommand -a logs -d 'View bot logs'
|
|
22
|
+
complete -c linkgravity -n __fish_use_subcommand -a status -d 'Show daemon status'
|
|
23
|
+
complete -c linkgravity -n __fish_use_subcommand -a enable -d 'Start automatically on system boot'
|
|
24
|
+
complete -c linkgravity -n __fish_use_subcommand -a disable -d 'Remove bot from system boot'
|
|
25
|
+
complete -c linkgravity -n __fish_use_subcommand -a setup -d 'Run the configuration wizard'
|
|
26
|
+
complete -c linkgravity -n __fish_use_subcommand -a update -d 'Install a newer version if one exists'
|
|
27
|
+
complete -c linkgravity -n __fish_use_subcommand -a help -d 'Show the help message'
|
|
28
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s f -d 'Follow the log output'
|
|
29
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s n -d 'Number of lines to show'
|
|
30
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -l tail -d 'Number of lines to show'
|
|
31
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -s t -d 'Show timestamps'
|
|
32
|
+
complete -c linkgravity -n '__fish_seen_subcommand_from logs' -l timestamp -d 'Show timestamps'
|
package/bin/setup.js
CHANGED
|
@@ -494,6 +494,21 @@ async function runSetup() {
|
|
|
494
494
|
await platformMenu(choice);
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
const { installCompletion } = require('./completion');
|
|
498
|
+
const installed = installCompletion();
|
|
499
|
+
if (installed) {
|
|
500
|
+
p.note(
|
|
501
|
+
[
|
|
502
|
+
`Installed for ${installed.shell} at ${installed.file}.`,
|
|
503
|
+
installed.rc ? `Added a line to ${installed.rc}.` : null,
|
|
504
|
+
'Open a new shell to use it.',
|
|
505
|
+
]
|
|
506
|
+
.filter(Boolean)
|
|
507
|
+
.join('\n'),
|
|
508
|
+
'Tab completion',
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
497
512
|
p.outro('Setup complete.');
|
|
498
513
|
}
|
|
499
514
|
|
package/package.json
CHANGED
package/src/api/ui_routes.py
CHANGED
|
@@ -7,6 +7,7 @@ import uuid
|
|
|
7
7
|
from aiohttp import web
|
|
8
8
|
|
|
9
9
|
from api.server import is_tool_allowed
|
|
10
|
+
from approval.protected_paths import protected_reason
|
|
10
11
|
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
11
12
|
from messengers.base import ScopeOption
|
|
12
13
|
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
@@ -87,6 +88,12 @@ async def handle_approve_request(request):
|
|
|
87
88
|
# DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
|
|
88
89
|
logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
|
|
89
90
|
|
|
91
|
+
# Ahead of the auto-allow lookup: this must not be overridable by a persistent grant.
|
|
92
|
+
blocked = protected_reason(tool_input)
|
|
93
|
+
if blocked:
|
|
94
|
+
logger.warning(f"Blocked {tool_name} touching protected config: {tool_input!r}")
|
|
95
|
+
return web.json_response({"decision": "deny", "reason": blocked})
|
|
96
|
+
|
|
90
97
|
target_thread = None
|
|
91
98
|
target_thread_id = None
|
|
92
99
|
for thread_id_str, sess in session_manager.get_all_sessions().items():
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from config import WORKSPACE_DIR
|
|
6
|
+
|
|
7
|
+
AGENT_SUBDIR = "workspace"
|
|
8
|
+
|
|
9
|
+
_DIR_MARKER = f"{WORKSPACE_DIR.parent.name}/{WORKSPACE_DIR.name}"
|
|
10
|
+
|
|
11
|
+
# Shell commands arrive as one opaque string, so they are matched textually rather than resolved.
|
|
12
|
+
CONFIG_DIR_RE = re.compile(re.escape(_DIR_MARKER) + rf"(?!/{re.escape(AGENT_SUBDIR)}\b)")
|
|
13
|
+
|
|
14
|
+
NAME_RE = re.compile(r"(?<![\w.-])(?:lgy\.json|persistent_tools\.json|approve_token)(?![\w.])")
|
|
15
|
+
|
|
16
|
+
REASON = "LinkGravity's own configuration is off limits - it holds the bot tokens."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_inside_config(value: str) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
resolved = Path(os.path.expandvars(os.path.expanduser(value))).resolve()
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return False
|
|
24
|
+
root = WORKSPACE_DIR.resolve()
|
|
25
|
+
return resolved.is_relative_to(root) and not resolved.is_relative_to(root / AGENT_SUBDIR)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def protected_reason(tool_input) -> str | None:
|
|
29
|
+
if isinstance(tool_input, list):
|
|
30
|
+
return next((r for r in map(protected_reason, tool_input) if r), None)
|
|
31
|
+
if not isinstance(tool_input, dict):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
for value in tool_input.values():
|
|
35
|
+
if isinstance(value, (dict, list)):
|
|
36
|
+
nested = protected_reason(value)
|
|
37
|
+
if nested:
|
|
38
|
+
return nested
|
|
39
|
+
elif isinstance(value, str) and value:
|
|
40
|
+
if NAME_RE.search(value) or CONFIG_DIR_RE.search(value) or _is_inside_config(value):
|
|
41
|
+
return REASON
|
|
42
|
+
return None
|
package/src/core/logger.py
CHANGED
|
@@ -6,6 +6,17 @@ from sys import stdout
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
class _InterceptHandler(logging.Handler):
|
|
10
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
11
|
+
try:
|
|
12
|
+
level = logger.level(record.levelname).name
|
|
13
|
+
except ValueError:
|
|
14
|
+
level = record.levelno
|
|
15
|
+
# Without patching, {name} resolves to this file's frame instead of the library that logged.
|
|
16
|
+
patched = logger.patch(lambda r, name=record.name: r.update(name=name))
|
|
17
|
+
patched.opt(exception=record.exc_info).log(level, record.getMessage())
|
|
18
|
+
|
|
19
|
+
|
|
9
20
|
def init_logger(workspace_dir: Path):
|
|
10
21
|
logging.getLogger("discord").setLevel(logging.WARNING)
|
|
11
22
|
# httpx is what python-telegram-bot uses under the hood for every getUpdates
|
|
@@ -22,6 +33,9 @@ def init_logger(workspace_dir: Path):
|
|
|
22
33
|
# Defaults to INFO - set LOG_LEVEL=DEBUG then `lgy restart` for
|
|
23
34
|
# verbose detail (e.g. agy_runner.py's raw agy stdout capture).
|
|
24
35
|
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
36
|
+
# force=True drops handlers third-party libraries install for themselves, which otherwise
|
|
37
|
+
# print in their own format alongside loguru's.
|
|
38
|
+
logging.basicConfig(handlers=[_InterceptHandler()], level=getattr(logging, level, logging.INFO), force=True)
|
|
25
39
|
logger.add(stdout, level=level, format=log_format, colorize=True)
|
|
26
40
|
logger.add(
|
|
27
41
|
LOG_DIR / "bot.log",
|
package/src/main_discord.py
CHANGED
|
@@ -281,8 +281,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
|
|
|
281
281
|
logger.critical("Missing DISCORD_TOKEN - run `lgy setup`")
|
|
282
282
|
return
|
|
283
283
|
|
|
284
|
-
discord.utils.setup_logging()
|
|
285
|
-
|
|
286
284
|
async with bot:
|
|
287
285
|
from cogs.voice_cog import VoiceCog
|
|
288
286
|
from config import bot_settings, save_bot_settings
|
|
@@ -234,6 +234,8 @@ class SlackAdapter(MessengerAdapter):
|
|
|
234
234
|
user_id = (body.get("user") or {}).get("id")
|
|
235
235
|
if allowed(user_id, "slack"):
|
|
236
236
|
return True
|
|
237
|
+
logger.warning(f"Rejected Slack interaction from unauthorized user {user_id}")
|
|
238
|
+
# Modal submissions carry no channel, so there is nowhere to post the notice.
|
|
237
239
|
channel = (body.get("channel") or {}).get("id")
|
|
238
240
|
if channel and user_id:
|
|
239
241
|
try:
|
|
@@ -245,6 +247,8 @@ class SlackAdapter(MessengerAdapter):
|
|
|
245
247
|
return False
|
|
246
248
|
|
|
247
249
|
async def handle_view_submission(self, body: dict) -> None:
|
|
250
|
+
if not await self._reject_unauthorized(body):
|
|
251
|
+
return
|
|
248
252
|
callback_id = (body.get("view") or {}).get("callback_id")
|
|
249
253
|
handler = self._view_callbacks.pop(callback_id, None)
|
|
250
254
|
if handler is None:
|